Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 57 additions & 7 deletions packages/app/src/pages/session/timeline/message-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { Dynamic } from "solid-js/web"
import { useNavigate } from "@solidjs/router"
import { useMutation } from "@tanstack/solid-query"
import { createVirtualizer, defaultRangeExtractor, elementScroll, type VirtualItem } from "@tanstack/solid-virtual"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Accordion } from "@opencode-ai/ui/accordion"
import { Button } from "@opencode-ai/ui/button"
import { Card } from "@opencode-ai/ui/card"
Expand Down Expand Up @@ -405,7 +406,13 @@ export function MessageTimeline(props: {
return timelineRows().length
},
getScrollElement: () => listRoot() ?? null,
initialOffset: () => (props.shouldAnchorBottom() ? Number.MAX_SAFE_INTEGER : 0),
// Do not seed with Number.MAX_SAFE_INTEGER. TanStack Virtual only syncs
// scrollOffset from DOM on scroll events; a sentinel that never lands in
// the DOM leaves getVirtualItems() empty while getTotalSize() is non-zero
// (blank transcript on cold direct navigation when messages arrive before
// the ScrollView viewport is measurable). Anchor via scrollToEnd() once
// the scroll element has a real height instead.
initialOffset: 0,
initialMeasurementsCache: initialMeasurements,
estimateSize: () => timelineFallbackItemSize,
scrollToFn: (offset, options, instance) => {
Expand Down Expand Up @@ -494,34 +501,75 @@ export function MessageTimeline(props: {
let bottomAnchorSessionKey = ""
let bottomAnchorFrame: number | undefined

const maybeAnchorBottom = () => {
const syncBottomAnchor = () => {
const key = sessionKey()
if (bottomAnchorSessionKey === key) return
if (timelineRows().length === 0) return
bottomAnchorSessionKey = key
if (!props.shouldAnchorBottom()) return
if (!props.shouldAnchorBottom()) {
bottomAnchorSessionKey = key
return
}
const root = listRoot()
// Wait until the ScrollView viewport is bound and measurable. Anchoring
// before that left cold deep-links permanently blank: scrollToEnd() no-ops
// with outerSize 0, and a one-shot key blocked retries.
if (!root || root.clientHeight <= 0) return
if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame)
if (resizePinFrame !== undefined) cancelAnimationFrame(resizePinFrame)
clearPrependAnchor()
if (prependAnchorFrame !== undefined) cancelAnimationFrame(prependAnchorFrame)
bottomAnchorFrame = requestAnimationFrame(() => {
bottomAnchorFrame = undefined
if (sessionKey() !== key) return
const el = listRoot()
if (!el || el.clientHeight <= 0) return
// Re-attach TanStack's scroll element observers if listRoot bound after the
// virtualizer's first _willUpdate (common on cold direct nav). Without this,
// outerSize stays 0 forever and getVirtualItems() stays empty while
// getTotalSize() is non-zero.
virtualizer._willUpdate()
// If observers first attached while the flex pane still had height 0, the
// ResizeObserver may never re-fire once layout settles. Force the measured
// rect from the live DOM so calculateRange can produce indexes.
if (virtualizer.getSize() === 0 && el.clientHeight > 0) {
virtualizer.scrollElement = null
virtualizer._willUpdate()
if (virtualizer.getSize() === 0) {
virtualizer.scrollRect = {
width: el.clientWidth,
height: el.clientHeight,
}
}
}
virtualizer.measure()
virtualizer.scrollToEnd()
bottomAnchorSessionKey = key
})
}

let measuredSessionKey = sessionKey()
createEffect(() => {
const key = sessionKey()
timelineRows().length
listRoot()
if (measuredSessionKey !== key) {
measuredSessionKey = key
bottomAnchorSessionKey = ""
virtualizer.measure()
}
maybeAnchorBottom()
syncBottomAnchor()
})

// clientHeight is not a Solid signal — without this, a viewport that binds at
// height 0 (common on cold direct nav) never re-triggers bottom anchoring.
createResizeObserver(
() => listRoot(),
() => {
if (bottomAnchorSessionKey === sessionKey() && virtualizer.getVirtualItems().length > 0) return
bottomAnchorSessionKey = ""
syncBottomAnchor()
},
)

onCleanup(() => {
clearPrependAnchor()
timelineCache.delete(ownerSessionKey)
Expand Down Expand Up @@ -552,8 +600,10 @@ export function MessageTimeline(props: {

const bindListRoot = (root: HTMLDivElement) => {
if (root === listRoot()) return
bottomAnchorSessionKey = ""
setListRoot(root)
props.setScrollRef(root)
syncBottomAnchor()
}

const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => {
Expand Down Expand Up @@ -1423,7 +1473,7 @@ export function MessageTimeline(props: {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void saveTitleEditor()
saveTitleEditor()
return
}
if (event.key === "Escape") {
Expand Down
13 changes: 8 additions & 5 deletions packages/ui/src/components/scroll-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,14 @@ export function ScrollView(props: ScrollViewProps) {
setState("thumbTop", boundedTop)
}

onMount(() => {
if (local.viewportRef) {
local.viewportRef(viewportRef)
}
const bindViewport = (el: HTMLDivElement) => {
viewportRef = el
// Bind synchronously so consumers (e.g. the session timeline virtualizer)
// see the scroll element during the same commit, not only after onMount.
local.viewportRef?.(el)
}

onMount(() => {
createResizeObserver([viewportRef, viewportRef.firstElementChild], updateThumb)

updateThumb()
Expand Down Expand Up @@ -206,7 +209,7 @@ export function ScrollView(props: ScrollViewProps) {
>
{/* Viewport */}
<div
ref={viewportRef}
ref={bindViewport}
class="scroll-view__viewport"
onScroll={(e) => {
updateThumb()
Expand Down
Loading