Browse Source

perf(app): virtualize session timeline rows (#26949)

Co-authored-by: Brendan Allan <git@brendonovich.dev>
Luke Parker 2 months ago
parent
commit
5452ab6db7

+ 2 - 2
bun.lock

@@ -764,7 +764,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",
@@ -4904,7 +4904,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=="],
 

+ 1 - 1
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",

+ 63 - 201
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"
@@ -75,7 +75,6 @@ type VcsMode = "git" | "branch"
 
 type SessionHistoryWindowInput = {
   sessionID: () => string | undefined
-  messagesReady: () => boolean
   loaded: () => number
   visibleUserMessages: () => UserMessage[]
   historyMore: () => boolean
@@ -85,205 +84,74 @@ 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 userMessages = createMemo(() => input.visibleUserMessages(), emptyUserMessages, {
+    equals: same,
   })
 
-  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 cancelShiftReset = () => {
+    if (shiftFrame === undefined) return
+    cancelAnimationFrame(shiftFrame)
+    shiftFrame = undefined
   }
 
-  const renderedUserMessages = createMemo(
-    () => {
-      const msgs = input.visibleUserMessages()
-      const start = turnStart()
-      if (start <= 0) return msgs
-      return msgs.slice(start)
-    },
-    emptyUserMessages,
-    {
-      equals: same,
-    },
-  )
-
-  const preserveScroll = (fn: () => void) => {
-    const el = input.scroller()
-    if (!el) {
-      fn()
-      return
-    }
-    const beforeTop = el.scrollTop
-    const beforeHeight = el.scrollHeight
-    fn()
-    requestAnimationFrame(() => {
-      const delta = el.scrollHeight - beforeHeight
-      if (!delta) return
-      el.scrollTop = beforeTop + delta
+  const 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 +160,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,
   }
@@ -333,6 +192,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(() => {
@@ -737,6 +597,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 +1264,8 @@ export default function Page() {
     },
   )
 
-  const historyWindow = createSessionHistoryWindow({
+  const historyLoader = createSessionHistoryLoader({
     sessionID: () => params.id,
-    messagesReady,
     loaded: () => messages().length,
     visibleUserMessages,
     historyMore,
@@ -1427,9 +1287,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 +1299,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 },
@@ -1749,15 +1608,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,
   })
@@ -1830,20 +1688,23 @@ export default function Page() {
         >
           <div class="flex-1 min-h-0 overflow-hidden">
             <Switch>
+              <Match when={params.id && mobileChanges()}>
+                <div class="relative h-full overflow-hidden">
+                  {reviewContent({
+                    diffStyle: "unified",
+                    classes: {
+                      root: "pb-8",
+                      header: "px-4",
+                      container: "px-4",
+                    },
+                    loadingClass: "px-4 py-4 text-text-weak",
+                    emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
+                  })}
+                </div>
+              </Match>
               <Match when={params.id}>
                 <Show when={messagesReady()}>
                   <MessageTimeline
-                    mobileChanges={mobileChanges()}
-                    mobileFallback={reviewContent({
-                      diffStyle: "unified",
-                      classes: {
-                        root: "pb-8",
-                        header: "px-4",
-                        container: "px-4",
-                      },
-                      loadingClass: "px-4 py-4 text-text-weak",
-                      emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
-                    })}
                     actions={actions}
                     scroll={ui.scroll}
                     onResumeScroll={resumeScroll}
@@ -1853,8 +1714,11 @@ export default function Page() {
                     onMarkScrollGesture={markScrollGesture}
                     hasScrollGesture={hasScrollGesture}
                     onUserScroll={markUserScroll}
-                    onTurnBackfillScroll={historyWindow.onScrollerScroll}
+                    onHistoryScroll={historyLoader.onScrollerScroll}
                     onAutoScrollInteraction={autoScroll.handleInteraction}
+                    shouldAnchorBottom={() =>
+                      !location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
+                    }
                     centered={centered()}
                     setContentRef={(el) => {
                       content = el
@@ -1863,14 +1727,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
+                    }}
                   />
                 </Show>
               </Match>

+ 364 - 0
packages/app/src/pages/session/message-timeline.data.ts

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

+ 1070 - 604
packages/app/src/pages/session/message-timeline.tsx

@@ -1,8 +1,36 @@
-import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX, createSignal } from "solid-js"
+import {
+  createEffect,
+  createMemo,
+  createSignal,
+  For,
+  Index,
+  Match,
+  Switch,
+  on,
+  onCleanup,
+  Show,
+  mapArray,
+  untrack,
+  type Accessor,
+  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,
+  Message,
+  MessageDivider,
+  Part as MessagePart,
+  partDefaultOpen,
+  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 +38,25 @@ 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,
+  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"
@@ -31,45 +70,54 @@ import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
 import { messageAgentColor } from "@/utils/agent"
 import { sessionTitle } from "@/utils/session-title"
-import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
 import { makeTimer } from "@solid-primitives/timer"
-
-type MessageComment = {
-  path: string
-  comment: string
-  selection?: {
-    startLine: number
-    endLine: number
-  }
-}
+import { MessageComment, SummaryDiff, Timeline, TimelineRow, TimelineRowMap } from "./message-timeline.data"
 
 const emptyMessages: MessageType[] = []
+const emptyParts: PartType[] = []
+const emptyTools: ToolPart[] = []
+const emptyAssistantMessages: AssistantMessage[] = []
 const idle = { type: "idle" as const }
-type UserActions = {
-  fork?: (input: { sessionID: string; messageID: string }) => Promise<void> | void
-  revert?: (input: { sessionID: string; messageID: string }) => Promise<void> | void
+
+type FramedTimelineRow = Exclude<TimelineRow.TimelineRow, { _tag: "BottomSpacer" }>
+type TimelineRowByTag<T extends TimelineRow.TimelineRow["_tag"]> = Extract<TimelineRow.TimelineRow, { _tag: T }>
+
+function sameKeys(a: readonly string[] | undefined, b: readonly string[] | undefined) {
+  if (a === b) return true
+  if (!a || !b) return false
+  if (a.length !== b.length) return false
+  return a.every((key, index) => key === b[index])
 }
 
-const messageComments = (parts: Part[]): MessageComment[] =>
-  parts.flatMap((part) => {
-    if (part.type !== "text" || !(part as TextPart).synthetic) return []
-    const next = readCommentMetadata(part.metadata) ?? parseCommentNote(part.text)
-    if (!next) return []
-    return [
-      {
-        path: next.path,
-        comment: next.comment,
-        selection: next.selection
-          ? {
-              startLine: next.selection.startLine,
-              endLine: next.selection.endLine,
-            }
-          : undefined,
-      },
-    ]
+const timelineCacheLimit = 16
+const timelineFallbackItemSize = 60
+const timelineCache = new Map<string, { keys: readonly string[]; cache: VirtualizerHandle["cache"] }>()
+
+function readTimelineCache(id: string, keys: readonly string[]) {
+  const entry = timelineCache.get(id)
+  if (!entry) return
+  if (sameKeys(entry.keys, keys)) return entry.cache
+  timelineCache.delete(id)
+}
+
+function writeTimelineCache(id: string, keys: readonly string[], handle: VirtualizerHandle | undefined) {
+  if (!handle || keys.length === 0) return
+  timelineCache.delete(id)
+  timelineCache.set(id, { keys: keys.slice(), cache: handle.cache })
+  while (timelineCache.size > timelineCacheLimit) timelineCache.delete(timelineCache.keys().next().value!)
+}
+
+function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
+  if (!previous?.length) return rows
+  const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
+  return rows.map((row) => {
+    const existing = byKey.get(TimelineRow.key(row))
+    if (!existing) return row
+    return TimelineRow.equals(existing, row) ? existing : row
   })
+}
 
-const 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,106 +158,114 @@ 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
+  return (
+    <div data-slot="session-turn-thinking">
+      <TextShimmer text={language.t("ui.sessionTurn.status.thinking")} />
+      <Show when={!props.showReasoningSummaries}>
+        <TextReveal text={props.reasoningHeading} class="session-turn-thinking-heading" travel={25} duration={700} />
+      </Show>
+    </div>
+  )
 }
 
-/**
- * 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) {
+function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
+  const language = useLanguage()
+  const maxFiles = 10
   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
+    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)))
 
-  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 (
+    <div
+      data-slot="session-turn-diffs"
+      data-component="session-turn-diffs-group"
+      data-show-all={showAll() || undefined}
+    >
+      <div data-slot="session-turn-diffs-header">
+        <span data-slot="session-turn-diffs-label">
+          {props.diffs.length} {language.t("ui.sessionTurn.diffs.changed")}{" "}
+          {language.t(props.diffs.length === 1 ? "ui.common.file.one" : "ui.common.file.other")}
+        </span>
+        <DiffChanges changes={props.diffs} />
+        <Show when={overflow() > 0}>
+          <span data-slot="session-turn-diffs-toggle" onClick={() => setState("showAll", !showAll())}>
+            {showAll() ? language.t("ui.sessionTurn.diffs.showLess") : language.t("ui.sessionTurn.diffs.showAll")}
+          </span>
+        </Show>
+      </div>
+      <div data-component="session-turn-diffs-content">
+        <Accordion
+          multiple
+          style={{ "--sticky-accordion-offset": "44px" }}
+          value={expanded()}
+          onChange={(value) => setState("expanded", Array.isArray(value) ? value : value ? [value] : [])}
+        >
+          <For each={visible()}>
+            {(diff) => {
+              const opened = createMemo(() => expanded().includes(diff.file))
+
+              return (
+                <Accordion.Item value={diff.file}>
+                  <StickyAccordionHeader>
+                    <Accordion.Trigger>
+                      <div data-slot="session-turn-diff-trigger">
+                        <span data-slot="session-turn-diff-path">
+                          <Show when={diff.file.includes("/")}>
+                            <span data-slot="session-turn-diff-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
+                          </Show>
+                          <span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
+                        </span>
+                        <div data-slot="session-turn-diff-meta">
+                          <span data-slot="session-turn-diff-changes">
+                            <DiffChanges changes={diff} />
+                          </span>
+                          <span data-slot="session-turn-diff-chevron">
+                            <Icon name="chevron-down" size="small" />
+                          </span>
+                        </div>
+                      </div>
+                    </Accordion.Trigger>
+                  </StickyAccordionHeader>
+                  <Accordion.Content>
+                    <Show when={opened()}>
+                      <TimelineDiffView diff={diff} />
+                    </Show>
+                  </Accordion.Content>
+                </Accordion.Item>
+              )
+            }}
+          </For>
+        </Accordion>
+        <Show when={!showAll() && overflow() > 0}>
+          <div data-slot="session-turn-diffs-more" onClick={() => setState("showAll", true)}>
+            {language.t("ui.sessionTurn.diffs.more", { count: String(overflow()) })}
+          </div>
+        </Show>
+      </div>
+    </div>
   )
+}
 
-  const isStaging = createMemo(() => {
-    const key = input.sessionKey()
-    return state.activeSession === key && state.completedSession !== key
-  })
+function TimelineDiffView(props: { diff: SummaryDiff }) {
+  const fileComponent = useFileComponent()
+  const view = normalize(props.diff)
 
-  onCleanup(cancel)
-  return { messages: stagedUserMessages, isStaging }
+  return (
+    <div data-slot="session-turn-diff-view" data-scrollable>
+      <Dynamic component={fileComponent} mode="diff" fileDiff={view.fileDiff} />
+    </div>
+  )
 }
 
 export function MessageTimeline(props: {
-  mobileChanges: boolean
-  mobileFallback: JSX.Element
   actions?: UserActions
   scroll: { overflow: boolean; bottom: boolean; jump: boolean }
   onResumeScroll: () => void
@@ -219,16 +275,15 @@ export function MessageTimeline(props: {
   onMarkScrollGesture: (target?: EventTarget | null) => void
   hasScrollGesture: () => boolean
   onUserScroll: () => void
-  onTurnBackfillScroll: () => void
+  onHistoryScroll: () => void
   onAutoScrollInteraction: (event: MouseEvent) => void
+  shouldAnchorBottom: () => boolean
   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 +297,27 @@ export function MessageTimeline(props: {
   const { params, sessionKey } = useSessionKey()
   const platform = usePlatform()
 
-  const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
+  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<string, AssistantMessage[]>()
+    for (const message of sessionMessages()) {
+      if (message.role !== "assistant") continue
+      const messages = result.get(message.parentID)
+      if (messages) {
+        messages.push(message)
+        continue
+      }
+      result.set(message.parentID, [message])
+    }
+    return result
+  })
   const pending = createMemo(() =>
     sessionMessages().findLast(
       (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
@@ -317,11 +386,12 @@ export function MessageTimeline(props: {
     return sync.data.message[id] ?? emptyMessages
   })
   const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
+  const getMsgParts = (msgId: string) => sync.data.part[msgId] ?? emptyParts
   const childTaskDescription = createMemo(() => {
     const id = sessionID()
     if (!id) return
     return parentMessages()
-      .flatMap((message) => sync.data.part[message.id] ?? [])
+      .flatMap((message) => getMsgParts(message.id))
       .map((part) => taskDescription(part, id))
       .findLast((value): value is string => !!value)
   })
@@ -333,12 +403,139 @@ 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 messageRowMemos = createMemo(
+    mapArray(
+      () => props.userMessages,
+      (userMessage, indexAccessor) => {
+        return createMemo((previous: TimelineRow.TimelineRow[] | undefined) => {
+          const rows = Timeline.constructMessageRows(
+            userMessage,
+            getMsgParts,
+            assistantMessagesByParent().get(userMessage.id) ?? emptyAssistantMessages,
+            indexAccessor(),
+            settings.general.showReasoningSummaries(),
+            sessionStatus().type,
+            activeMessageID() === userMessage.id,
+          )
+
+          return reuseTimelineRows(previous, rows)
+        })
+      },
+    ),
+  )
+
+  const timelineRows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) => {
+    const rows = messageRowMemos().flatMap((memo) => memo())
+    if (rows.length === 0) return rows
+    return reuseTimelineRows(previous, [...rows, new TimelineRow.BottomSpacer()])
+  })
+  const timelineRowByKey = createMemo(() => new Map(timelineRows().map((row) => [TimelineRow.key(row), row] as const)))
+  const timelineRowKeys = createMemo(() => [...timelineRowByKey().keys()], [] as string[], { equals: sameKeys })
+  const virtualCache = createMemo(() => readTimelineCache(sessionKey(), timelineRowKeys()))
+  const messageRowIndex = createMemo(() => {
+    const result = new Map<string, number>()
+    timelineRows().forEach((row, index) => {
+      if (!("userMessageID" in row)) return
+      if (result.has(row.userMessageID)) return
+      result.set(row.userMessageID, index)
+    })
+    return result
+  })
+  const keepMounted = createMemo(() => {
+    const id = activeMessageID()
+    if (!id) return
+    const rows = timelineRows()
+    const index = rows.findLastIndex((row) => "userMessageID" in row && row.userMessageID === id)
+    if (index < 0) return
+    return [index]
+  })
+  const activeAssistantMessages = createMemo(() => {
+    const id = activeMessageID() ?? props.userMessages[props.userMessages.length - 1]?.id
+    if (!id) return emptyAssistantMessages
+    return assistantMessagesByParent().get(id) ?? emptyAssistantMessages
+  })
+  const activeAssistantContentVersion = createMemo(() =>
+    activeAssistantMessages()
+      .flatMap((message) => [
+        `${message.id}:${message.time.completed ?? ""}:${message.error?.name ?? ""}`,
+        ...getMsgParts(message.id).map((part) => {
+          if (part.type === "text" || part.type === "reasoning") return `${part.id}:${part.type}:${part.text.length}`
+          if (part.type === "tool") {
+            const metadata = "metadata" in part.state ? part.state.metadata : undefined
+            const output = "output" in part.state && typeof part.state.output === "string" ? part.state.output.length : 0
+            const metadataOutput =
+              metadata && typeof metadata === "object" && "output" in metadata && typeof metadata.output === "string"
+                ? metadata.output.length
+                : 0
+            return `${part.id}:${part.tool}:${part.state.status}:${output}:${metadataOutput}`
+          }
+          return `${part.id}:${part.type}`
+        }),
+      ])
+      .join("|"),
+  )
+
+  createEffect(
+    on(
+      () => [timelineRowKeys(), activeAssistantContentVersion(), sessionStatus().type] as const,
+      () => {
+        if (!virtualizer) return
+        if (!props.shouldAnchorBottom() && !measuredBottomAnchored) return
+        const keys = timelineRowKeys()
+        if (keys.length === 0) return
+        virtualizer.scrollToIndex(keys.length - 1, { align: "end" })
+        scheduleMeasuredBottomAnchor()
+      },
+      { defer: true },
+    ),
+  )
+
+  createEffect(() => {
+    props.setRevealMessage?.((id) => {
+      const index = messageRowIndex().get(id)
+      if (index === undefined) return
+      virtualizer?.scrollToIndex(index, { align: "center" })
+    })
+  })
+
+  let cacheSessionKey = sessionKey()
+  let cacheRowKeys = timelineRowKeys()
+  let virtualizerSessionKey = cacheSessionKey
+  let virtualizerRowKeys = cacheRowKeys
+  let bottomAnchorSessionKey = ""
+
+  const maybeAnchorBottom = () => {
+    const key = sessionKey()
+    if (bottomAnchorSessionKey === key) return
+    if (!virtualizer) return
+    const keys = timelineRowKeys()
+    if (keys.length === 0) return
+    bottomAnchorSessionKey = key
+    if (!props.shouldAnchorBottom()) return
+    virtualizer.scrollToIndex(keys.length - 1, { align: "end" })
+  }
+
+  createEffect(
+    on(
+      () => [sessionKey(), timelineRowKeys()] as const,
+      (next, prev) => {
+        if (prev && prev[0] !== next[0]) writeTimelineCache(prev[0], prev[1], virtualizer)
+        cacheSessionKey = next[0]
+        cacheRowKeys = next[1]
+        if (virtualizer) {
+          virtualizerSessionKey = cacheSessionKey
+          virtualizerRowKeys = cacheRowKeys
+          maybeAnchorBottom()
+        }
+      },
+      { defer: true },
+    ),
+  )
+
+  onCleanup(() => {
+    writeTimelineCache(virtualizerSessionKey, virtualizerRowKeys, virtualizer)
+    props.setRevealMessage?.(() => {})
   })
 
   const [title, setTitle] = createStore({
@@ -360,14 +557,150 @@ export function MessageTimeline(props: {
 
   let more: HTMLButtonElement | undefined
   let head: HTMLDivElement | undefined
+  let listRoot: HTMLDivElement | undefined
+  let listFrame: number | undefined
+  let contentFrame: number | undefined
+  let bottomAnchorFrame: number | undefined
+  let bottomAnchorFrames = 0
+  let measuredBottomAnchored = true
+  const [scrollRoot, setScrollRoot] = createSignal<HTMLDivElement>()
+
+  const updateTitleMetrics = () => {
+    if (!head || head.clientWidth <= 0) return
+    setBar("ms", pace(head.clientWidth))
+  }
 
-  createResizeObserver(
-    () => head,
-    () => {
-      if (!head || head.clientWidth <= 0) return
-      setBar("ms", pace(head.clientWidth))
-    },
-  )
+  createResizeObserver(() => head, updateTitleMetrics)
+
+  const isMeasuredBottom = (root: HTMLDivElement) => root.scrollHeight - root.clientHeight - root.scrollTop <= 4
+
+  function anchorMeasuredBottom() {
+    if (!listRoot) return false
+    if (!measuredBottomAnchored) return false
+    listRoot.scrollTop = listRoot.scrollHeight
+    return true
+  }
+
+  function scheduleMeasuredBottomAnchor() {
+    // Workaround for virtua issue #301: virtua does not expose a synchronous item-resize hook for
+    // "stay at bottom if already at bottom". Tool rows can briefly outgrow the measured virtual
+    // height, so keep the scroll container bottom-locked for a few frames while measurement settles.
+    bottomAnchorFrames = 90
+    if (bottomAnchorFrame !== undefined) return
+
+    const tick = () => {
+      bottomAnchorFrame = undefined
+      if (!anchorMeasuredBottom()) {
+        bottomAnchorFrames = 0
+        return
+      }
+
+      bottomAnchorFrames = working() ? 12 : bottomAnchorFrames - 1
+      if (bottomAnchorFrames <= 0) return
+      bottomAnchorFrame = requestAnimationFrame(tick)
+    }
+
+    bottomAnchorFrame = requestAnimationFrame(tick)
+  }
+
+  const bindContentRoot = (root: HTMLDivElement) => {
+    const child = root.firstElementChild
+    props.setContentRef(child instanceof HTMLDivElement ? child : root)
+  }
+
+  const scheduleContentRoot = (root: HTMLDivElement) => {
+    if (contentFrame !== undefined) cancelAnimationFrame(contentFrame)
+    contentFrame = requestAnimationFrame(() => {
+      contentFrame = undefined
+      if (listRoot !== root) return
+      bindContentRoot(root)
+    })
+  }
+
+  const connectListRoot = (root: HTMLDivElement) => {
+    if (listRoot !== root) return
+    if (!root.isConnected || !root.ownerDocument.defaultView) {
+      listFrame = requestAnimationFrame(() => {
+        listFrame = undefined
+        connectListRoot(root)
+      })
+      return
+    }
+
+    props.setScrollRef(root)
+    measuredBottomAnchored = isMeasuredBottom(root)
+    setScrollRoot(root)
+    scheduleContentRoot(root)
+  }
+
+  const bindListRoot = (root: HTMLDivElement) => {
+    if (root === listRoot) return
+
+    if (listFrame !== undefined) cancelAnimationFrame(listFrame)
+    if (contentFrame !== undefined) cancelAnimationFrame(contentFrame)
+    listRoot = root
+    setScrollRoot(undefined)
+    connectListRoot(root)
+  }
+
+  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 handleListTouchStart = (event: TouchEvent) => {
+    touchGesture = event.touches[0]?.clientY
+  }
+
+  const handleListTouchMove = (event: TouchEvent & { currentTarget: HTMLDivElement }) => {
+    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: 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 }) => {
+    measuredBottomAnchored = isMeasuredBottom(event.currentTarget)
+    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)
+    if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
+    setScrollRoot(undefined)
+    props.setScrollRef(undefined)
+  })
 
   const viewShare = () => {
     const url = shareUrl()
@@ -623,496 +956,629 @@ 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<number | undefined>(
+      (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 = getMsgParts(message.id)
+      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 getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
+
+  const renderAssistantPartGroup = (row: Accessor<TimelineRowMap["AssistantPart"]>) => {
+    if (untrack(row).group.type === "context") {
+      const parts = createMemo(() => {
+        const group = row().group
+        if (group.type !== "context") return emptyTools
+        return group.refs
+          .map((ref) => getMsgPart(ref.messageID, ref.partID))
+          .filter((part): part is ToolPart => part?.type === "tool")
+      })
+
+      return <ContextToolGroup parts={parts()} busy={workingTurn(row().userMessageID) && row().lastAssistantPart} />
+    }
+
+    const message = createMemo(() => {
+      const group = row().group
+      if (group.type !== "part") return
+      return messageByID().get(group.ref.messageID)
+    })
+    const part = createMemo(() => {
+      const group = row().group
+      if (group.type !== "part") return
+      return getMsgPart(group.ref.messageID, group.ref.partID)
+    })
+
+    return (
+      <Show when={message()}>
+        {(message) => (
+          <Show when={part()}>
+            {(part) => (
+              <MessagePart
+                part={part()}
+                message={message()}
+                showAssistantCopyPartID={assistantCopyPartID(row().userMessageID)}
+                turnDurationMs={turnDurationMs(row().userMessageID)}
+                defaultOpen={partDefaultOpen(
+                  part(),
+                  settings.general.shellToolPartsExpanded(),
+                  settings.general.editToolPartsExpanded(),
+                )}
+                deferToolContent={false}
+              />
+            )}
+          </Show>
+        )}
+      </Show>
+    )
+  }
+
+  function TimelineRowFrame(input: { row: Accessor<FramedTimelineRow>; children: JSX.Element }) {
+    const anchor = () => {
+      const row = input.row()
+      return row._tag === "CommentStrip" || (row._tag === "UserMessage" && row.anchor)
+    }
+    const previousUserMessage = () => {
+      const row = input.row()
+      return (row._tag === "CommentStrip" || row._tag === "UserMessage") && row.previousUserMessage
+    }
+    const previousAssistantPart = () => {
+      const row = input.row()
+      return row._tag === "AssistantPart" && row.previousAssistantPart
+    }
+
+    return (
+      <div
+        id={anchor() ? props.anchor(input.row().userMessageID) : undefined}
+        data-message-id={input.row().userMessageID}
+        data-timeline-row={input.row()._tag}
+        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": previousUserMessage(),
+          "pt-3": previousAssistantPart(),
+        }}
+      >
+        <div data-component="session-turn" class="min-w-0 w-full relative" style={{ height: "auto" }}>
+          {input.children}
+        </div>
+      </div>
+    )
+  }
+
+  const renderTimelineRow = (row: Accessor<TimelineRow.TimelineRow>) => {
+    switch (row()._tag) {
+      case "CommentStrip": {
+        const commentStripRow = row as Accessor<TimelineRowByTag<"CommentStrip">>
+        const comments = createMemo(() =>
+          getMsgParts(commentStripRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []),
+        )
+        return (
+          <TimelineRowFrame row={commentStripRow}>
+            <div class="w-full px-4 md:px-5 pb-2">
+              <div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
+                <div class="flex w-max min-w-full justify-end gap-2">
+                  <Index each={comments()}>
+                    {(comment) => (
+                      <div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
+                        <div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
+                          <FileIcon node={{ path: comment().path, type: "file" }} class="size-3.5 shrink-0" />
+                          <span class="truncate">{getFilename(comment().path)}</span>
+                          <Show when={comment().selection}>
+                            {(selection) => (
+                              <span class="shrink-0 text-text-weak">
+                                {selection().startLine === selection().endLine
+                                  ? `:${selection().startLine}`
+                                  : `:${selection().startLine}-${selection().endLine}`}
+                              </span>
+                            )}
+                          </Show>
+                        </div>
+                        <div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words">
+                          {comment().comment}
+                        </div>
+                      </div>
+                    )}
+                  </Index>
+                </div>
+              </div>
+            </div>
+          </TimelineRowFrame>
+        )
+      }
+      case "UserMessage": {
+        const userMessageRow = row as Accessor<TimelineRowByTag<"UserMessage">>
+        const message = createMemo(() => {
+          const m = messageByID().get(userMessageRow().userMessageID)
+          if (m?.role === "user") return m
+        })
+        return (
+          <TimelineRowFrame row={userMessageRow}>
+            <Show when={message()}>
+              {(message) => (
+                <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
+                  <div data-slot="session-turn-message-content" aria-live="off">
+                    <Message message={message()} parts={getMsgParts(userMessageRow().userMessageID)} actions={props.actions} />
+                  </div>
+                </div>
+              )}
+            </Show>
+          </TimelineRowFrame>
+        )
+      }
+      case "TurnDivider": {
+        const turnDividerRow = row as Accessor<TimelineRowByTag<"TurnDivider">>
+        return (
+          <TimelineRowFrame row={turnDividerRow}>
+            <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
+              <div data-slot="session-turn-compaction">
+                <MessageDivider
+                  label={language.t(
+                    turnDividerRow().label === "compaction" ? "ui.messagePart.compaction" : "ui.message.interrupted",
+                  )}
+                />
+              </div>
+            </div>
+          </TimelineRowFrame>
+        )
+      }
+      case "AssistantPart": {
+        const assistantPartRow = row as Accessor<TimelineRowByTag<"AssistantPart">>
+        return (
+          <TimelineRowFrame row={assistantPartRow}>
+            <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
+              <div data-slot="session-turn-assistant-content" aria-hidden={workingTurn(assistantPartRow().userMessageID)}>
+                {renderAssistantPartGroup(assistantPartRow)}
+              </div>
+            </div>
+          </TimelineRowFrame>
+        )
+      }
+      case "Thinking": {
+        const thinkingRow = row as Accessor<TimelineRowByTag<"Thinking">>
+        return (
+          <TimelineRowFrame row={thinkingRow}>
+            <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
+              <TimelineThinkingRow
+                reasoningHeading={thinkingRow().reasoningHeading}
+                showReasoningSummaries={settings.general.showReasoningSummaries()}
+              />
+            </div>
+          </TimelineRowFrame>
+        )
+      }
+      case "Retry": {
+        const retryRow = row as Accessor<TimelineRowByTag<"Retry">>
+        return (
+          <TimelineRowFrame row={retryRow}>
+            <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
+              <SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} />
+            </div>
+          </TimelineRowFrame>
+        )
+      }
+      case "DiffSummary": {
+        const diffSummaryRow = row as Accessor<TimelineRowByTag<"DiffSummary">>
+        return (
+          <TimelineRowFrame row={diffSummaryRow}>
+            <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
+              <TimelineDiffSummaryRow diffs={diffSummaryRow().diffs} />
+            </div>
+          </TimelineRowFrame>
+        )
+      }
+      case "Error": {
+        const errorRow = row as Accessor<TimelineRowByTag<"Error">>
+        return (
+          <TimelineRowFrame row={errorRow}>
+            <div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
+              <Card variant="error" class="error-card">
+                {errorRow().text}
+              </Card>
+            </div>
+          </TimelineRowFrame>
+        )
+      }
+      case "BottomSpacer":
+        return <div data-timeline-row="bottom-spacer" aria-hidden="true" class="h-16" />
+    }
+  }
+
+  function TimelineRowView(props: { rowKey: string }) {
+    return (
+      <Show when={timelineRowByKey().get(props.rowKey)}>
+        {(item) => renderTimelineRow(item)}
+      </Show>
+    )
+  }
+
   return (
-    <Show
-      when={!props.mobileChanges}
-      fallback={<div class="relative h-full overflow-hidden">{props.mobileFallback}</div>}
-    >
-      <div class="relative w-full h-full min-w-0">
-        <div
-          class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
-          classList={{
-            "opacity-100 translate-y-0 scale-100": props.scroll.overflow && props.scroll.jump && !staging.isStaging(),
-            "opacity-0 translate-y-2 scale-95 pointer-events-none":
-              !props.scroll.overflow || !props.scroll.jump || staging.isStaging(),
-          }}
+    <div class="relative w-full h-full min-w-0">
+      <div
+        class="absolute left-1/2 -translate-x-1/2 bottom-6 z-[60] pointer-events-none transition-all duration-200 ease-out"
+        classList={{
+          "opacity-100 translate-y-0 scale-100": props.scroll.overflow && props.scroll.jump,
+          "opacity-0 translate-y-2 scale-95 pointer-events-none": !props.scroll.overflow || !props.scroll.jump,
+        }}
+      >
+        <button
+          class="pointer-events-auto flex items-center justify-center w-10 h-8 bg-transparent border-none cursor-pointer p-0 group"
+          onClick={props.onResumeScroll}
         >
-          <button
-            class="pointer-events-auto flex items-center justify-center w-10 h-8 bg-transparent border-none cursor-pointer p-0 group"
-            onClick={props.onResumeScroll}
+          <div
+            class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-border-weaker-base bg-[color-mix(in_srgb,var(--surface-raised-stronger-non-alpha)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--border-weak-base)] group-hover:[--icon-base:var(--icon-hover)]"
+            style={{
+              "box-shadow":
+                "0 51px 60px 0 rgba(0,0,0,0.10), 0 15px 18px 0 rgba(0,0,0,0.12), 0 6.386px 7.513px 0 rgba(0,0,0,0.12), 0 2.31px 2.717px 0 rgba(0,0,0,0.20)",
+            }}
           >
-            <div
-              class="flex items-center justify-center w-8 h-6 rounded-[6px] border border-border-weaker-base bg-[color-mix(in_srgb,var(--surface-raised-stronger-non-alpha)_80%,transparent)] backdrop-blur-[0.75px] transition-colors group-hover:border-[var(--border-weak-base)] group-hover:[--icon-base:var(--icon-hover)]"
-              style={{
-                "box-shadow":
-                  "0 51px 60px 0 rgba(0,0,0,0.10), 0 15px 18px 0 rgba(0,0,0,0.12), 0 6.386px 7.513px 0 rgba(0,0,0,0.12), 0 2.31px 2.717px 0 rgba(0,0,0,0.20)",
-              }}
-            >
-              <Icon name="arrow-down-to-line" size="small" />
-            </div>
-          </button>
-        </div>
-        <ScrollView
-          viewportRef={props.setScrollRef}
-          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.onTurnBackfillScroll()
-            if (!props.hasScrollGesture()) return
-            props.onUserScroll()
-            props.onAutoScrollHandleScroll()
-            props.onMarkScrollGesture(e.currentTarget)
-          }}
-          onClick={props.onAutoScrollInteraction}
-          class="relative min-w-0 w-full h-full"
-          style={{
-            "--session-title-height": showHeader() ? "40px" : "0px",
-            "--sticky-accordion-top": showHeader() ? "48px" : "0px",
-          }}
-        >
-          <div ref={props.setContentRef} class="min-w-0 w-full">
-            <Show when={showHeader()}>
+            <Icon name="arrow-down-to-line" size="small" />
+          </div>
+        </button>
+      </div>
+      <ScrollView
+        viewportRef={bindListRoot}
+        onWheel={handleListWheel}
+        onTouchStart={handleListTouchStart}
+        onTouchMove={handleListTouchMove}
+        onTouchEnd={handleListTouchEnd}
+        onTouchCancel={handleListTouchEnd}
+        onPointerDown={handleListPointerDown}
+        onScroll={handleListScroll}
+        onClick={props.onAutoScrollInteraction}
+        class="relative min-w-0 w-full h-full"
+        style={{
+          "--session-title-height": showHeader() ? "40px" : "0px",
+          "--sticky-accordion-top": showHeader() ? "48px" : "0px",
+        }}
+      >
+        <Show when={showHeader()}>
+          <div
+            ref={(el) => {
+              head = el
+              updateTitleMetrics()
+            }}
+            data-session-title
+            classList={{
+              "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
+              "w-full": true,
+              "pb-4": true,
+              "pl-2 pr-3 md:pl-4 md:pr-3": true,
+              "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
+            }}
+          >
+            <Show when={workingStatus() !== "hidden" && settings.general.showSessionProgressBar()}>
               <div
-                ref={(el) => {
-                  head = el
-                  setBar("ms", pace(el.clientWidth))
-                }}
-                data-session-title
-                classList={{
-                  "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
-                  relative: true,
-                  "w-full": true,
-                  "pb-4": true,
-                  "pl-2 pr-3 md:pl-4 md:pr-3": true,
-                  "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
+                data-component="session-progress"
+                data-state={workingStatus()}
+                aria-hidden="true"
+                style={{
+                  "--session-progress-color": tint() ?? "var(--icon-interactive-base)",
+                  "--session-progress-ms": `${bar.ms}ms`,
                 }}
               >
-                <Show when={workingStatus() !== "hidden" && settings.general.showSessionProgressBar()}>
+                <div data-component="session-progress-bar" />
+              </div>
+            </Show>
+            <div class="h-12 w-full flex items-center justify-between gap-2">
+              <div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
+                <div class="flex items-center min-w-0 grow-1">
+                  <Show when={parentID()}>
+                    <button
+                      type="button"
+                      data-slot="session-title-parent"
+                      class="min-w-0 max-w-[40%] truncate text-14-medium text-text-weak transition-colors hover:text-text-base"
+                      onClick={navigateParent}
+                    >
+                      {parentTitle()}
+                    </button>
+                    <span
+                      data-slot="session-title-separator"
+                      class="px-2 text-14-medium text-text-weak"
+                      aria-hidden="true"
+                    >
+                      /
+                    </span>
+                  </Show>
                   <div
-                    data-component="session-progress"
-                    data-state={workingStatus()}
-                    aria-hidden="true"
+                    class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
                     style={{
-                      "--session-progress-color": tint() ?? "var(--icon-interactive-base)",
-                      "--session-progress-ms": `${bar.ms}ms`,
+                      width: working() ? "16px" : "0px",
+                      "margin-right": working() ? "8px" : "0px",
                     }}
+                    aria-hidden="true"
                   >
-                    <div data-component="session-progress-bar" />
-                  </div>
-                </Show>
-                <div class="h-12 w-full flex items-center justify-between gap-2">
-                  <div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
-                    <div class="flex items-center min-w-0 grow-1">
-                      <Show when={parentID()}>
-                        <button
-                          type="button"
-                          data-slot="session-title-parent"
-                          class="min-w-0 max-w-[40%] truncate text-14-medium text-text-weak transition-colors hover:text-text-base"
-                          onClick={navigateParent}
-                        >
-                          {parentTitle()}
-                        </button>
-                        <span
-                          data-slot="session-title-separator"
-                          class="px-2 text-14-medium text-text-weak"
-                          aria-hidden="true"
-                        >
-                          /
-                        </span>
-                      </Show>
+                    <Show when={workingStatus() !== "hidden"}>
                       <div
-                        class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
-                        style={{
-                          width: working() ? "16px" : "0px",
-                          "margin-right": working() ? "8px" : "0px",
-                        }}
-                        aria-hidden="true"
+                        class="transition-opacity duration-200 ease-out"
+                        classList={{ "opacity-0": workingStatus() === "hiding" }}
                       >
-                        <Show when={workingStatus() !== "hidden"}>
-                          <div
-                            class="transition-opacity duration-200 ease-out"
-                            classList={{ "opacity-0": workingStatus() === "hiding" }}
-                          >
-                            <Spinner class="size-4" style={{ color: tint() ?? "var(--icon-interactive-base)" }} />
-                          </div>
-                        </Show>
+                        <Spinner class="size-4" style={{ color: tint() ?? "var(--icon-interactive-base)" }} />
                       </div>
-                      <Show when={childTitle() || title.editing}>
-                        <Show
-                          when={title.editing}
-                          fallback={
-                            <h1
-                              data-slot="session-title-child"
-                              class="text-14-medium text-text-strong truncate grow-1 min-w-0"
-                              onDblClick={openTitleEditor}
-                            >
-                              {childTitle()}
-                            </h1>
-                          }
+                    </Show>
+                  </div>
+                  <Show when={childTitle() || title.editing}>
+                    <Show
+                      when={title.editing}
+                      fallback={
+                        <h1
+                          data-slot="session-title-child"
+                          class="text-14-medium text-text-strong truncate grow-1 min-w-0"
+                          onDblClick={openTitleEditor}
                         >
-                          <InlineInput
-                            ref={(el) => {
-                              titleRef = el
-                            }}
-                            data-slot="session-title-child"
-                            value={title.draft}
-                            disabled={titleMutation.isPending}
-                            class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px] pl-1 -ml-1"
-                            style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
-                            onInput={(event) => setTitle("draft", event.currentTarget.value)}
-                            onKeyDown={(event) => {
-                              event.stopPropagation()
-                              if (event.key === "Enter") {
+                          {childTitle()}
+                        </h1>
+                      }
+                    >
+                      <InlineInput
+                        ref={(el) => {
+                          titleRef = el
+                        }}
+                        data-slot="session-title-child"
+                        value={title.draft}
+                        disabled={titleMutation.isPending}
+                        class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px] pl-1 -ml-1"
+                        style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
+                        onInput={(event) => setTitle("draft", event.currentTarget.value)}
+                        onKeyDown={(event) => {
+                          event.stopPropagation()
+                          if (event.key === "Enter") {
+                            event.preventDefault()
+                            void saveTitleEditor()
+                            return
+                          }
+                          if (event.key === "Escape") {
+                            event.preventDefault()
+                            closeTitleEditor()
+                          }
+                        }}
+                        onBlur={closeTitleEditor}
+                      />
+                    </Show>
+                  </Show>
+                </div>
+              </div>
+              <Show when={sessionID()} keyed>
+                {(id) => (
+                  <div class="shrink-0 flex items-center gap-3">
+                    <SessionContextUsage placement="bottom" />
+                    <Show when={!parentID()}>
+                      <DropdownMenu
+                        gutter={4}
+                        placement="bottom-end"
+                        open={title.menuOpen}
+                        onOpenChange={(open) => {
+                          setTitle("menuOpen", open)
+                          if (open) return
+                        }}
+                      >
+                        <DropdownMenu.Trigger
+                          as={IconButton}
+                          icon="dot-grid"
+                          variant="ghost"
+                          class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
+                          classList={{
+                            "bg-surface-base-active": share.open || title.pendingShare,
+                          }}
+                          aria-label={language.t("common.moreOptions")}
+                          aria-expanded={title.menuOpen || share.open || title.pendingShare}
+                          ref={(el: HTMLButtonElement) => {
+                            more = el
+                          }}
+                        />
+                        <DropdownMenu.Portal>
+                          <DropdownMenu.Content
+                            style={{ "min-width": "104px" }}
+                            onCloseAutoFocus={(event) => {
+                              if (title.pendingRename) {
                                 event.preventDefault()
-                                void saveTitleEditor()
+                                setTitle("pendingRename", false)
+                                openTitleEditor()
                                 return
                               }
-                              if (event.key === "Escape") {
+                              if (title.pendingShare) {
                                 event.preventDefault()
-                                closeTitleEditor()
+                                requestAnimationFrame(() => {
+                                  setShare({ open: true, dismiss: null })
+                                  setTitle("pendingShare", false)
+                                })
                               }
                             }}
-                            onBlur={closeTitleEditor}
-                          />
-                        </Show>
-                      </Show>
-                    </div>
-                  </div>
-                  <Show when={sessionID()} keyed>
-                    {(id) => (
-                      <div class="shrink-0 flex items-center gap-3">
-                        <SessionContextUsage placement="bottom" />
-                        <Show when={!parentID()}>
-                          <DropdownMenu
-                            gutter={4}
-                            placement="bottom-end"
-                            open={title.menuOpen}
-                            onOpenChange={(open) => {
-                              setTitle("menuOpen", open)
-                              if (open) return
-                            }}
                           >
-                            <DropdownMenu.Trigger
-                              as={IconButton}
-                              icon="dot-grid"
-                              variant="ghost"
-                              class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
-                              classList={{
-                                "bg-surface-base-active": share.open || title.pendingShare,
-                              }}
-                              aria-label={language.t("common.moreOptions")}
-                              aria-expanded={title.menuOpen || share.open || title.pendingShare}
-                              ref={(el: HTMLButtonElement) => {
-                                more = el
+                            <DropdownMenu.Item
+                              onSelect={() => {
+                                setTitle("pendingRename", true)
+                                setTitle("menuOpen", false)
                               }}
-                            />
-                            <DropdownMenu.Portal>
-                              <DropdownMenu.Content
-                                style={{ "min-width": "104px" }}
-                                onCloseAutoFocus={(event) => {
-                                  if (title.pendingRename) {
-                                    event.preventDefault()
-                                    setTitle("pendingRename", false)
-                                    openTitleEditor()
-                                    return
-                                  }
-                                  if (title.pendingShare) {
-                                    event.preventDefault()
-                                    requestAnimationFrame(() => {
-                                      setShare({ open: true, dismiss: null })
-                                      setTitle("pendingShare", false)
-                                    })
-                                  }
+                            >
+                              <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
+                            </DropdownMenu.Item>
+                            <Show when={shareEnabled()}>
+                              <DropdownMenu.Item
+                                onSelect={() => {
+                                  setTitle({ pendingShare: true, menuOpen: false })
                                 }}
                               >
-                                <DropdownMenu.Item
-                                  onSelect={() => {
-                                    setTitle("pendingRename", true)
-                                    setTitle("menuOpen", false)
-                                  }}
-                                >
-                                  <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
-                                </DropdownMenu.Item>
-                                <Show when={shareEnabled()}>
-                                  <DropdownMenu.Item
-                                    onSelect={() => {
-                                      setTitle({ pendingShare: true, menuOpen: false })
-                                    }}
-                                  >
-                                    <DropdownMenu.ItemLabel>
-                                      {language.t("session.share.action.share")}
-                                    </DropdownMenu.ItemLabel>
-                                  </DropdownMenu.Item>
-                                </Show>
-                                <DropdownMenu.Item onSelect={() => void archiveSession(id)}>
-                                  <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
-                                </DropdownMenu.Item>
-                                <DropdownMenu.Separator />
-                                <DropdownMenu.Item
-                                  onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
-                                >
-                                  <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
-                                </DropdownMenu.Item>
-                              </DropdownMenu.Content>
-                            </DropdownMenu.Portal>
-                          </DropdownMenu>
-
-                          <KobaltePopover
-                            open={share.open}
-                            anchorRef={() => more}
-                            placement="bottom-end"
-                            gutter={4}
-                            modal={false}
-                            onOpenChange={(open) => {
-                              if (open) setShare("dismiss", null)
-                              setShare("open", open)
+                                <DropdownMenu.ItemLabel>
+                                  {language.t("session.share.action.share")}
+                                </DropdownMenu.ItemLabel>
+                              </DropdownMenu.Item>
+                            </Show>
+                            <DropdownMenu.Item onSelect={() => void archiveSession(id)}>
+                              <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
+                            </DropdownMenu.Item>
+                            <DropdownMenu.Separator />
+                            <DropdownMenu.Item
+                              onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
+                            >
+                              <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
+                            </DropdownMenu.Item>
+                          </DropdownMenu.Content>
+                        </DropdownMenu.Portal>
+                      </DropdownMenu>
+
+                      <KobaltePopover
+                        open={share.open}
+                        anchorRef={() => more}
+                        placement="bottom-end"
+                        gutter={4}
+                        modal={false}
+                        onOpenChange={(open) => {
+                          if (open) setShare("dismiss", null)
+                          setShare("open", open)
+                        }}
+                      >
+                        <KobaltePopover.Portal>
+                          <KobaltePopover.Content
+                            data-component="popover-content"
+                            style={{ "min-width": "320px" }}
+                            onEscapeKeyDown={(event) => {
+                              setShare({ dismiss: "escape", open: false })
+                              event.preventDefault()
+                              event.stopPropagation()
+                            }}
+                            onPointerDownOutside={() => {
+                              setShare({ dismiss: "outside", open: false })
+                            }}
+                            onFocusOutside={() => {
+                              setShare({ dismiss: "outside", open: false })
+                            }}
+                            onCloseAutoFocus={(event) => {
+                              if (share.dismiss === "outside") event.preventDefault()
+                              setShare("dismiss", null)
                             }}
                           >
-                            <KobaltePopover.Portal>
-                              <KobaltePopover.Content
-                                data-component="popover-content"
-                                style={{ "min-width": "320px" }}
-                                onEscapeKeyDown={(event) => {
-                                  setShare({ dismiss: "escape", open: false })
-                                  event.preventDefault()
-                                  event.stopPropagation()
-                                }}
-                                onPointerDownOutside={() => {
-                                  setShare({ dismiss: "outside", open: false })
-                                }}
-                                onFocusOutside={() => {
-                                  setShare({ dismiss: "outside", open: false })
-                                }}
-                                onCloseAutoFocus={(event) => {
-                                  if (share.dismiss === "outside") event.preventDefault()
-                                  setShare("dismiss", null)
-                                }}
-                              >
-                                <div class="flex flex-col p-3">
-                                  <div class="flex flex-col gap-1">
-                                    <div class="text-13-medium text-text-strong">
-                                      {language.t("session.share.popover.title")}
-                                    </div>
-                                    <div class="text-12-regular text-text-weak">
-                                      {shareUrl()
-                                        ? language.t("session.share.popover.description.shared")
-                                        : language.t("session.share.popover.description.unshared")}
-                                    </div>
-                                  </div>
-                                  <div class="mt-3 flex flex-col gap-2">
-                                    <Show
-                                      when={shareUrl()}
-                                      fallback={
-                                        <Button
-                                          size="large"
-                                          variant="primary"
-                                          class="w-full"
-                                          onClick={shareSession}
-                                          disabled={shareMutation.isPending}
-                                        >
-                                          {shareMutation.isPending
-                                            ? language.t("session.share.action.publishing")
-                                            : language.t("session.share.action.publish")}
-                                        </Button>
-                                      }
+                            <div class="flex flex-col p-3">
+                              <div class="flex flex-col gap-1">
+                                <div class="text-13-medium text-text-strong">
+                                  {language.t("session.share.popover.title")}
+                                </div>
+                                <div class="text-12-regular text-text-weak">
+                                  {shareUrl()
+                                    ? language.t("session.share.popover.description.shared")
+                                    : language.t("session.share.popover.description.unshared")}
+                                </div>
+                              </div>
+                              <div class="mt-3 flex flex-col gap-2">
+                                <Show
+                                  when={shareUrl()}
+                                  fallback={
+                                    <Button
+                                      size="large"
+                                      variant="primary"
+                                      class="w-full"
+                                      onClick={shareSession}
+                                      disabled={shareMutation.isPending}
                                     >
-                                      <div class="flex flex-col gap-2">
-                                        <TextField
-                                          value={shareUrl() ?? ""}
-                                          readOnly
-                                          copyable
-                                          copyKind="link"
-                                          tabIndex={-1}
-                                          class="w-full"
-                                        />
-                                        <div class="grid grid-cols-2 gap-2">
-                                          <Button
-                                            size="large"
-                                            variant="secondary"
-                                            class="w-full shadow-none border border-border-weak-base"
-                                            onClick={unshareSession}
-                                            disabled={unshareMutation.isPending}
-                                          >
-                                            {unshareMutation.isPending
-                                              ? language.t("session.share.action.unpublishing")
-                                              : language.t("session.share.action.unpublish")}
-                                          </Button>
-                                          <Button
-                                            size="large"
-                                            variant="primary"
-                                            class="w-full"
-                                            onClick={viewShare}
-                                            disabled={unshareMutation.isPending}
-                                          >
-                                            {language.t("session.share.action.view")}
-                                          </Button>
-                                        </div>
-                                      </div>
-                                    </Show>
+                                      {shareMutation.isPending
+                                        ? language.t("session.share.action.publishing")
+                                        : language.t("session.share.action.publish")}
+                                    </Button>
+                                  }
+                                >
+                                  <div class="flex flex-col gap-2">
+                                    <TextField
+                                      value={shareUrl() ?? ""}
+                                      readOnly
+                                      copyable
+                                      copyKind="link"
+                                      tabIndex={-1}
+                                      class="w-full"
+                                    />
+                                    <div class="grid grid-cols-2 gap-2">
+                                      <Button
+                                        size="large"
+                                        variant="secondary"
+                                        class="w-full shadow-none border border-border-weak-base"
+                                        onClick={unshareSession}
+                                        disabled={unshareMutation.isPending}
+                                      >
+                                        {unshareMutation.isPending
+                                          ? language.t("session.share.action.unpublishing")
+                                          : language.t("session.share.action.unpublish")}
+                                      </Button>
+                                      <Button
+                                        size="large"
+                                        variant="primary"
+                                        class="w-full"
+                                        onClick={viewShare}
+                                        disabled={unshareMutation.isPending}
+                                      >
+                                        {language.t("session.share.action.view")}
+                                      </Button>
+                                    </div>
                                   </div>
-                                </div>
-                              </KobaltePopover.Content>
-                            </KobaltePopover.Portal>
-                          </KobaltePopover>
-                        </Show>
-                      </div>
-                    )}
-                  </Show>
-                </div>
-              </div>
-            </Show>
-            <div
-              role="log"
-              data-slot="session-turn-list"
-              class="flex flex-col items-start justify-start pb-16 transition-[margin]"
-              classList={{
-                "w-full": true,
-                "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
-                "mt-0.5": props.centered,
-                "mt-0": !props.centered,
-              }}
-            >
-              <Show when={props.turnStart > 0 || props.historyMore}>
-                <div class="w-full flex justify-center">
-                  <Button
-                    variant="ghost"
-                    size="large"
-                    class="text-12-medium opacity-50"
-                    disabled={props.historyLoading}
-                    onClick={props.onLoadEarlier}
-                  >
-                    {props.historyLoading
-                      ? language.t("session.messages.loadingEarlier")
-                      : language.t("session.messages.loadEarlier")}
-                  </Button>
-                </div>
-              </Show>
-              <For each={rendered()}>
-                {(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 (
-                    <div
-                      id={props.anchor(messageID)}
-                      data-message-id={messageID}
-                      classList={{
-                        "min-w-0 w-full max-w-full": true,
-                        "md:max-w-200 2xl:max-w-[1000px]": props.centered,
-                      }}
-                      style={{
-                        "content-visibility": active() ? undefined : "auto",
-                        "contain-intrinsic-size": active() ? undefined : "auto 500px",
-                      }}
-                    >
-                      <Show when={commentCount() > 0}>
-                        <div class="w-full px-4 md:px-5 pb-2">
-                          <div class="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
-                            <div class="flex w-max min-w-full justify-end gap-2">
-                              <Index each={comments()}>
-                                {(commentAccessor: () => MessageComment) => {
-                                  const comment = createMemo(() => commentAccessor())
-                                  return (
-                                    <Show when={comment()}>
-                                      {(c) => (
-                                        <div class="shrink-0 max-w-[260px] rounded-[6px] border border-border-weak-base bg-background-stronger px-2.5 py-2">
-                                          <div class="flex items-center gap-1.5 min-w-0 text-11-medium text-text-strong">
-                                            <FileIcon
-                                              node={{ path: c().path, type: "file" }}
-                                              class="size-3.5 shrink-0"
-                                            />
-                                            <span class="truncate">{getFilename(c().path)}</span>
-                                            <Show when={c().selection}>
-                                              {(selection) => (
-                                                <span class="shrink-0 text-text-weak">
-                                                  {selection().startLine === selection().endLine
-                                                    ? `:${selection().startLine}`
-                                                    : `:${selection().startLine}-${selection().endLine}`}
-                                                </span>
-                                              )}
-                                            </Show>
-                                          </div>
-                                          <div class="pt-1 text-12-regular text-text-strong whitespace-pre-wrap break-words">
-                                            {c().comment}
-                                          </div>
-                                        </div>
-                                      )}
-                                    </Show>
-                                  )
-                                }}
-                              </Index>
+                                </Show>
+                              </div>
                             </div>
-                          </div>
-                        </div>
-                      </Show>
-                      <SessionTurn
-                        sessionID={sessionID() ?? ""}
-                        messageID={messageID}
-                        messages={sessionMessages()}
-                        actions={props.actions}
-                        active={active()}
-                        status={active() ? sessionStatus() : undefined}
-                        showReasoningSummaries={settings.general.showReasoningSummaries()}
-                        shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
-                        editToolDefaultOpen={settings.general.editToolPartsExpanded()}
-                        classes={{
-                          root: "min-w-0 w-full relative",
-                          content: "flex flex-col justify-between !overflow-visible",
-                          container: "w-full px-4 md:px-5",
-                        }}
-                      />
-                    </div>
-                  )
-                }}
-              </For>
+                          </KobaltePopover.Content>
+                        </KobaltePopover.Portal>
+                      </KobaltePopover>
+                    </Show>
+                  </div>
+                )}
+              </Show>
             </div>
           </div>
-        </ScrollView>
-      </div>
-    </Show>
+        </Show>
+        <Show when={scrollRoot()}>
+          {(root) => (
+            <Virtualizer
+              data={timelineRowKeys()}
+              cache={virtualCache()}
+              itemSize={virtualCache() ? undefined : timelineFallbackItemSize}
+              scrollRef={root()}
+              shift={props.historyShift}
+              keepMounted={keepMounted()}
+              startMargin={64}
+              ref={(handle) => {
+                if (!handle) {
+                  writeTimelineCache(virtualizerSessionKey, virtualizerRowKeys, virtualizer)
+                  virtualizer = undefined
+                  return
+                }
+                virtualizer = handle
+                virtualizerSessionKey = cacheSessionKey
+                virtualizerRowKeys = cacheRowKeys
+                maybeAnchorBottom()
+                scheduleContentRoot(root())
+              }}
+            >
+              {(key) => <TimelineRowView rowKey={key} />}
+            </Virtualizer>
+          )}
+        </Show>
+      </ScrollView>
+    </div>
   )
 }

+ 3 - 16
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<void>
-  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) {

+ 1 - 0
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",

+ 61 - 16
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) {
           </Switch>
         </div>
       </div>
-      <Show when={props.children && !props.hideDetails && !props.locked && !pending()}>
+      <Show when={hasChildren() && !props.hideDetails && !props.locked && !pending()}>
         <Collapsible.Arrow />
       </Show>
     </div>
@@ -219,7 +264,7 @@ export function BasicTool(props: BasicToolProps) {
           </Collapsible.Trigger>
         )}
       </Show>
-      <Show when={props.animated && props.children && !props.hideDetails}>
+      <Show when={props.animated && hasChildren() && !props.hideDetails}>
         <div
           ref={contentRef}
           data-slot="collapsible-content"
@@ -229,10 +274,10 @@ export function BasicTool(props: BasicToolProps) {
             overflow: initialOpen ? "visible" : "hidden",
           }}
         >
-          {props.children}
+          <Show when={!props.defer || ready()}>{props.children}</Show>
         </div>
       </Show>
-      <Show when={!props.animated && props.children && !props.hideDetails}>
+      <Show when={!props.animated && hasChildren() && !props.hideDetails}>
         <Collapsible.Content>
           <Show when={!props.defer || ready()}>{props.children}</Show>
         </Collapsible.Content>

+ 27 - 17
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
 }
@@ -486,12 +487,12 @@ function same<T>(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"
@@ -520,14 +521,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
 
@@ -575,7 +576,7 @@ function index<T extends { id: string }>(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"
@@ -591,7 +592,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)
 }
@@ -904,7 +905,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(
@@ -914,7 +915,13 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
   const summary = createMemo(() => contextToolSummary(props.parts))
 
   return (
-    <Collapsible open={open()} onOpenChange={setOpen} variant="ghost" class="tool-collapsible">
+    <Collapsible
+      open={open()}
+      onOpenChange={setOpen}
+      variant="ghost"
+      class="tool-collapsible"
+      data-timeline-part-ids={props.parts.map((part) => part.id).join(",")}
+    >
       <Collapsible.Trigger>
         <div data-component="context-tool-group-trigger">
           <span
@@ -1077,7 +1084,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
   }
 
   return (
-    <div data-component="user-message">
+    <div data-component="user-message" data-timeline-part-id={textPart()?.id}>
       <Show when={attachments().length > 0}>
         <div data-slot="user-message-attachments">
           <For each={attachments()}>
@@ -1228,6 +1235,7 @@ export function Part(props: MessagePartProps) {
         message={props.message}
         hideDetails={props.hideDetails}
         defaultOpen={props.defaultOpen}
+        deferToolContent={props.deferToolContent}
         showAssistantCopyPartID={props.showAssistantCopyPartID}
         turnDurationMs={props.turnDurationMs}
       />
@@ -1244,6 +1252,7 @@ export interface ToolProps {
   status?: string
   hideDetails?: boolean
   defaultOpen?: boolean
+  deferContent?: boolean
   forceOpen?: boolean
   locked?: boolean
 }
@@ -1344,7 +1353,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
 
   return (
     <Show when={!hideQuestion()}>
-      <div data-component="tool-part-wrapper">
+      <div data-component="tool-part-wrapper" data-timeline-part-id={part().id}>
         <Switch>
           <Match when={part().state.status === "error" && (part().state as any).error}>
             {(error) => {
@@ -1382,6 +1391,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
               status={part().state.status}
               hideDetails={props.hideDetails}
               defaultOpen={props.defaultOpen}
+              deferContent={props.deferToolContent}
             />
           </Match>
         </Switch>
@@ -1487,7 +1497,7 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
 
   return (
     <Show when={text()}>
-      <div data-component="text-part">
+      <div data-component="text-part" data-timeline-part-id={part().id}>
         <div data-slot="text-part-body">
           <Show when={streaming()} fallback={<Markdown text={text()} cacheKey={part().id} streaming={false} />}>
             <PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} />
@@ -1531,7 +1541,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
 
   return (
     <Show when={text()}>
-      <div data-component="reasoning-part">
+      <div data-component="reasoning-part" data-timeline-part-id={part().id}>
         <Show when={streaming()} fallback={<Markdown text={text()} cacheKey={part().id} streaming={false} />}>
           <PacedMarkdown text={text()} cacheKey={part().id} streaming={streaming()} />
         </Show>
@@ -1913,7 +1923,7 @@ ToolRegistry.register({
         <BasicTool
           {...props}
           icon="code-lines"
-          defer
+          defer={props.deferContent !== false}
           trigger={
             <div data-component="edit-trigger">
               <div data-slot="message-part-title-area">
@@ -1974,7 +1984,7 @@ ToolRegistry.register({
         <BasicTool
           {...props}
           icon="code-lines"
-          defer
+          defer={props.deferContent !== false}
           trigger={
             <div data-component="write-trigger">
               <div data-slot="message-part-title-area">
@@ -2056,7 +2066,7 @@ ToolRegistry.register({
             <BasicTool
               {...props}
               icon="code-lines"
-              defer
+              defer={props.deferContent !== false}
               trigger={{
                 title: i18n.t("ui.tool.patch"),
                 subtitle: subtitle(),
@@ -2128,7 +2138,7 @@ ToolRegistry.register({
                             </Accordion.Trigger>
                           </StickyAccordionHeader>
                           <Accordion.Content>
-                            <Show when={visible()}>
+                            <Show when={props.deferContent === false || visible()}>
                               <div data-component="apply-patch-file-diff">
                                 <Dynamic
                                   component={fileComponent}
@@ -2153,7 +2163,7 @@ ToolRegistry.register({
           <BasicTool
             {...props}
             icon="code-lines"
-            defer
+            defer={props.deferContent !== false}
             trigger={
               <div data-component="edit-trigger">
                 <div data-slot="message-part-title-area">