fix(history): stabilize native scrollbars and timeline navigation

Replace estimated timeline row measurements with an exact virtual layout derived from the marker and group-gap primitives. Hidden tools no longer contribute estimated heights, and the rail uses the same standard native scrollbar as the transcript: only marker width changes, never icons or vertical spacing. Manual rail browsing suppresses active-marker reveals until a transcript gesture, and intermediate window positions cannot reclaim it.

Keep native transcript thumb drags escaped beyond the ordinary gesture timeout, delay adjacent-window paging until release, and recognize wheel gestures at an already-clamped boundary. Publish follow state before handing boundary callbacks control. Preserve access to first/latest commands at local window edges and default history search to excluding tools and reasoning.

Exercise the real SessionView with mixed long replies, short replies, tools and idle records. Browser regressions verify exact scroll extent, native held-thumb dragging, streaming anchors, distant landing positions, adjacent windows, global endpoints, keyboard ownership, RTL and fractional zoom. The held-thumb regression is red with native drag ownership disabled and green with it; transcript rendering and nested-scroll regressions and UI typecheck pass.
This commit is contained in:
Pascal André 2026-09-20 13:47:00 +02:00
parent b390646e0b
commit a4e1eecaa9
No known key found for this signature in database
12 changed files with 438 additions and 117 deletions

View file

@ -80,7 +80,7 @@ Behavior for agents:
- Use the `write` tool only when creating new files from scratch.
- Browser rendering regressions live in `packages/ui/tests/browser/`, with deterministic HTTP fixtures beside them in `fixtures/`. Exercise the real Solid components and native event dispatcher rather than reimplementing rendering logic.
- Transcript rows retain pointer hit testing during virtualizer scrolling (`styles/messaging/virtual-follow-list.css`), so nested code/tool scrollers and message controls receive gestures at their visible target.
- Keep the global scrollbar-width default at zero specificity (`:where(...)` in `src/index.css`) so component visibility rules win. The timeline reserves a discreet stable gutter in `styles/messaging/timeline-scrollbar.css`: only marker rectangles narrow, icons and vertical spacing never shrink. The message transcript uses the shared thin scrollbar and nested code/tool outputs retain their own scrollbars. Validate with the full app stylesheet loaded.
- Keep the global scrollbar-width default at zero specificity (`:where(...)` in `src/index.css`) so component visibility rules win. The timeline and transcript use the same standard native scrollbar: only marker rectangles narrow, icons and vertical spacing never shrink. `components/timeline-virtual-list.tsx` computes the timeline's exact extent from measured marker/gap primitives; never estimate hidden/offscreen rows. Manual rail browsing owns its position until an explicit transcript gesture. Native thumb drags retain ownership beyond the gesture timeout and defer window paging until release. Validate with the full stylesheet and real thumb gestures on mixed-height transcripts.
- Run them with `npm run test:browser --workspace @codenomad/ui` after `npx playwright install chromium`. `CODENOMAD_BROWSER_PATH` optionally selects an existing Chromium executable; it does not target the installed application or user sessions.
## V2 Runtime Launch

View file

@ -51,11 +51,25 @@ hidden views cancel work. A refresh retains the last successful snapshot and
errors provide an explicit retry. The initial outline scan is proportional to the
session size; a subsequent jump loads only its bounded destination window.
The timeline reserves a narrow gutter in `timeline-scrollbar.css`. Hover/focus
changes contrast only. Marker rectangles give up width to the gutter; fixed-size
icons and vertical geometry never scale with available width. Browser checks load
the complete stylesheet and cover overflow, hover, keyboard focus, RTL and 125%
zoom.
The timeline uses the same standard native scrollbar as the transcript, with one
ordinary gutter. Marker rectangles give up width to it; fixed-size icons and
vertical geometry never scale with available width. `timeline-virtual-list.tsx`
measures the marker height and gap primitives, computes exact offsets (including
group spacing), and mounts only the visible range. Hidden tools are excluded from
the layout. Browsing offscreen rows cannot change the scrollbar extent.
Manual rail browsing suppresses active-marker reveals until an explicit transcript
gesture. Reveals wait one layout frame and are cancelled by a newer selection or
manual rail interaction. Intermediate transcript positions during window loading
cannot move the rail. Native transcript scrollbar drags escape following at press,
retain ownership beyond the ordinary wheel/key deadline, and postpone boundary
paging until release. A wheel gesture at an already-clamped boundary can request
the adjacent window even when no DOM scroll event is emitted. First/latest controls
also account for off-window messages, not just the local scroll offset.
Browser checks load the complete stylesheet and cover overflow, hover, keyboard
focus, RTL and 125% zoom, mixed text/tool/idle records, actual native thumb dragging,
and viewport anchor stability during streaming and distant jumps.
## Validation

View file

@ -285,7 +285,7 @@ export default function MessageSection(props: MessageSectionProps) {
const [searchRetryGeneration, setSearchRetryGeneration] = createSignal(0)
const [searchMatches, setSearchMatches] = createSignal<Array<SessionSearchMatch & { sessionId: string }>>([])
const [searchWorkspace, setSearchWorkspace] = createSignal(false)
const [includeTechnical, setIncludeTechnical] = createSignal(true)
const [includeTechnical, setIncludeTechnical] = createSignal(false)
const [searchPageCursor, setSearchPageCursor] = createSignal<string>()
const [nextSearchCursor, setNextSearchCursor] = createSignal<string | null>(null)
const [skippedSearchMessages, setSkippedSearchMessages] = createSignal(0)
@ -383,11 +383,14 @@ export default function MessageSection(props: MessageSectionProps) {
})
const [activeSegmentId, setActiveSegmentId] = createSignal<string | null>(null)
const [revealActiveToken, setRevealActiveToken] = createSignal(0)
const isActive = createMemo(() => props.isActive !== false)
const [listApi, setListApi] = createSignal<VirtualFollowListApi | null>(null)
const [listState, setListState] = createSignal<VirtualFollowListState | null>(null)
const scrollButtonsCount = createMemo(() => listState()?.scrollButtonsCount() ?? 0)
const showFirstButton = () => Boolean(props.hasMoreMessages || listState()?.showScrollTopButton())
const showLatestButton = () => !isLatestWindow(store().getMessageWindow(props.sessionId)) || Boolean(listState()?.showScrollBottomButton())
const scrollButtonsCount = createMemo(() => Number(showFirstButton()) + Number(showLatestButton()))
const [streamElement, setStreamElement] = createSignal<HTMLDivElement | undefined>()
const [streamShellElement, setStreamShellElement] = createSignal<HTMLDivElement | undefined>()
@ -931,6 +934,10 @@ export default function MessageSection(props: MessageSectionProps) {
pagingWindowController = null
pagingWindow = false
setNavigationPending(false)
const activeMessageId = api.captureScrollSnapshot()?.anchorKey ?? listState()?.activeKey()
const segment = timelineSegments().find(segment => segment.messageId === activeMessageId)
if (segment) setActiveSegmentId(segment.id)
if (direction === "latest" || direction === "oldest") setRevealActiveToken(value => value + 1)
persistMessageScrollSnapshot({ snapshot: api.captureScrollSnapshot() })
} catch (error) {
if (isCurrent()) {
@ -1189,13 +1196,16 @@ export default function MessageSection(props: MessageSectionProps) {
persistMessageScrollSnapshot({ snapshot })
}}
onUserReachedTop={() => { void pageWindow("older", (api) => api.scrollToBottom({ immediate: true })) }}
onScrollIntent={() => { if (navigationPending()) cancelWindowNavigation() }}
onScrollIntent={() => {
setRevealActiveToken(value => value + 1)
if (navigationPending()) cancelWindowNavigation()
}}
onUserReachedBottom={() => { void pageWindow("newer", (api) => api.scrollToTop({ immediate: true })) }}
onJumpTop={() => { void pageWindow("oldest", (api) => api.scrollToTop({ immediate: true })) }}
onJumpBottom={() => { void pageWindow("latest", (api) => api.scrollToBottom({ immediate: true })) }}
onMouseUp={() => handleStreamMouseUp()}
onActiveKeyChange={(messageId) => {
if (!messageId) return
if (!messageId || pagingWindow) return
const firstSeg = timelineSegments().find((s) => s.messageId === messageId)
if (firstSeg) {
setActiveSegmentId((current) => (current === firstSeg.id ? current : firstSeg.id))
@ -1213,9 +1223,9 @@ export default function MessageSection(props: MessageSectionProps) {
scrollToBottomAriaLabel={() => t("messageSection.scroll.toLatestAriaLabel")}
registerApi={registerListApi}
registerState={(state) => setListState(state)}
renderControls={(state) => (
renderControls={() => (
<div class="message-scroll-controls">
<Show when={state.showScrollTopButton()}>
<Show when={showFirstButton()}>
<button
type="button"
class="message-scroll-button"
@ -1226,7 +1236,7 @@ export default function MessageSection(props: MessageSectionProps) {
<ArrowUp class="message-scroll-icon w-4 h-4" aria-hidden="true" />
</button>
</Show>
<Show when={state.showScrollBottomButton()}>
<Show when={showLatestButton()}>
<button
type="button"
class="message-scroll-button"
@ -1511,6 +1521,7 @@ export default function MessageSection(props: MessageSectionProps) {
onSegmentClick={handleTimelineSegmentClick}
expandedMessageIds={expandedMessageIds}
activeSegmentId={activeSegmentId()}
revealActiveToken={revealActiveToken()}
instanceId={props.instanceId}
sessionId={props.sessionId}
showToolSegments={showTimelineToolsPreference()}

View file

@ -1,5 +1,5 @@
import { Show, createEffect, createMemo, createSignal, onCleanup, on, type Component, type Accessor } from "solid-js"
import { Virtualizer, type VirtualizerHandle } from "virtua/solid"
import TimelineVirtualList, { type TimelineListHandle } from "./timeline-virtual-list"
import { Dynamic, Portal } from "solid-js/web"
import MessagePreview from "./message-preview"
import { messageStoreBus } from "../stores/message-v2/bus"
@ -39,10 +39,10 @@ interface MessageTimelineProps {
showToolSegments?: boolean
searchMatchedSegmentIds?: Accessor<Set<string>>
activeSearchSegmentId?: Accessor<string | null>
revealActiveToken?: number
}
const MAX_TOOLTIP_LENGTH = 220
const TIMELINE_VIRTUALIZER_BUFFER_PX = 240
type ToolCallPart = Extract<ClientPart, { type: "tool" }>
@ -382,29 +382,27 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
})
const [scrollElement, setScrollElement] = createSignal<HTMLDivElement | undefined>()
const [virtualizerHandle, setVirtualizerHandle] = createSignal<VirtualizerHandle | undefined>()
let revealTimer: ReturnType<typeof setTimeout> | undefined
const cancelReveal = () => {
if (revealTimer !== undefined) clearTimeout(revealTimer)
revealTimer = undefined
}
const [virtualizerHandle, setVirtualizerHandle] = createSignal<TimelineListHandle | undefined>()
let browsingRail = false
const cancelReveal = () => { browsingRail = true }
const handleScroll = () => {
cancelReveal()
if (hoveredSegment()) clearHoverPreview()
}
createEffect(on(() => props.activeSegmentId, (activeId) => {
if (!activeId) return
revealTimer = setTimeout(() => {
revealTimer = undefined
const index = segmentIndexById().get(activeId)
// The rail is globally virtualized: an animated reveal can keep moving
// under the pointer while the reader chooses another distant destination.
if (index !== undefined) virtualizerHandle()?.scrollToIndex(index, { align: "nearest" })
}, 120)
onCleanup(cancelReveal)
}))
createEffect(on(() => props.revealActiveToken, () => { browsingRail = false }))
createEffect(() => {
props.revealActiveToken
const activeId = props.activeSegmentId
const index = activeId ? segmentIndexById().get(activeId) : undefined
const handle = virtualizerHandle()
// Wait for the exact extent to reach layout, but never queue a reveal past
// a manual rail gesture or a newer active marker.
const frame = requestAnimationFrame(() => {
if (!browsingRail && index !== undefined) handle?.scrollToIndex(index)
})
onCleanup(() => cancelAnimationFrame(frame))
})
createEffect(() => {
const element = tooltipElement()
@ -438,12 +436,10 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
if (pendingFrame !== null) return
pendingFrame = requestAnimationFrame(() => {
pendingFrame = null
const handle = virtualizerHandle()
const offset = getBottomAnchoredViewportOffset(handle?.scrollOffset ?? element.scrollTop, pendingHeightDelta)
const offset = getBottomAnchoredViewportOffset(element.scrollTop, pendingHeightDelta)
pendingHeightDelta = 0
const maxOffset = Math.max((handle?.scrollSize ?? element.scrollHeight) - (handle?.viewportSize ?? element.clientHeight), 0)
if (handle) handle.scrollTo(Math.min(offset, maxOffset))
else element.scrollTop = Math.min(offset, maxOffset)
const maxOffset = Math.max(element.scrollHeight - element.clientHeight, 0)
element.scrollTop = Math.min(offset, maxOffset)
})
})
observer.observe(element)
@ -470,12 +466,6 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
return set
})
const segmentIndexById = createMemo(() => {
const map = new Map<string, number>()
for (let i = 0; i < props.segments.length; i++) map.set(props.segments[i].id, i)
return map
})
const segmentStates = createMemo(() => {
const expandedMessages = props.expandedMessageIds?.()
const resolvedStore = store()
@ -517,9 +507,11 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
}
}
const visibleSegments = createMemo(() => props.segments.filter(segment => !segmentStateFor(segment.id).hidden))
const segmentIndexById = createMemo(() => new Map(visibleSegments().map((segment, index) => [segment.id, index])))
const segmentSpacerHeights = createMemo(() => {
const states = segmentStates()
const result = new Map<string, string>()
const result = new Map<string, number>()
let previousVisible: TimelineSegment | null = null
for (let index = 0; index < props.segments.length; index += 1) {
@ -527,12 +519,12 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
const state = states.get(segment.id)
if (state?.hidden) {
result.set(segment.id, "0")
result.set(segment.id, 0)
continue
}
if (!previousVisible) {
result.set(segment.id, "0")
result.set(segment.id, 0)
previousVisible = segment
continue
}
@ -550,12 +542,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
&& messagesWithTools().has(previousVisible.messageId)
const gapUnits = 1 + (startsVisibleToolGroup || startsCollapsedToolGroup || followsVisibleGroupParent ? 1 : 0)
result.set(
segment.id,
gapUnits === 1
? "var(--message-timeline-segment-gap)"
: "calc(var(--message-timeline-segment-gap) * 2)",
)
result.set(segment.id, gapUnits)
previousVisible = segment
}
@ -572,13 +559,17 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
class="message-timeline"
data-segment-count={props.segments.length}
role="navigation"
tabIndex={0}
aria-label={t("messageTimeline.ariaLabel")}
onScroll={handleScroll}
onWheel={cancelReveal}
onPointerEnter={cancelReveal}
onPointerDown={cancelReveal}
onFocusIn={cancelReveal}
onTouchStart={cancelReveal}
onKeyDown={cancelReveal}
>
<Virtualizer ref={setVirtualizerHandle} data={props.segments} scrollRef={scrollElement()} bufferSize={TIMELINE_VIRTUALIZER_BUFFER_PX}>
<TimelineVirtualList register={setVirtualizerHandle} items={visibleSegments()} scrollElement={scrollElement()} gap={segment => segmentSpacerHeights().get(segment.id) ?? 0}>
{(segment) => {
const isActive = () => props.activeSegmentId === segment.id
const isSearchMatch = () => props.searchMatchedSegmentIds?.().has(segment.id) ?? false
@ -610,7 +601,6 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
return (
<div class="message-timeline-item">
<div aria-hidden="true" class="message-timeline-item-spacer" style={{ height: segmentSpacerHeights().get(segment.id) ?? "0" }} />
<button
type="button"
data-variant={segment.variant}
@ -619,7 +609,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
aria-label={segment.tooltip || segment.label}
data-message-id={segment.messageId}
aria-hidden={isHidden() ? "true" : undefined}
onClick={() => props.onSegmentClick?.(segment)}
onClick={() => { clearHoverPreview(); props.onSegmentClick?.(segment) }}
onMouseEnter={(event) => handleMouseEnter(segment, event)}
onMouseLeave={handleMouseLeave}
>
@ -629,7 +619,7 @@ const MessageTimeline: Component<MessageTimelineProps> = (props) => {
</div>
)
}}
</Virtualizer>
</TimelineVirtualList>
<Show when={previewData()}>
{(data) => {
onCleanup(() => setTooltipElement(null))

View file

@ -0,0 +1,83 @@
import { For, createEffect, createMemo, createSignal, onCleanup, type JSX } from "solid-js"
export interface TimelineListHandle {
scrollToIndex: (index: number) => void
}
/** Timeline markers have known geometry, unlike transcript messages. Measure
* the two CSS primitives once and give the native scrollbar the exact extent,
* including offscreen group gaps. No estimated or zero-height hidden rows. */
export default function TimelineVirtualList<T>(props: {
items: T[]
gap: (item: T) => number
scrollElement?: HTMLDivElement
register: (handle: TimelineListHandle) => void
children: (item: T) => JSX.Element
}) {
const [size, setSize] = createSignal({ marker: 20, gap: 5.6 })
const [viewport, setViewport] = createSignal({ top: 0, height: 0 })
let marker!: HTMLButtonElement
let gap!: HTMLDivElement
const layout = createMemo(() => {
const geometry = size()
let extent = 0
const rows = props.items.map(item => {
const space = props.gap(item) * geometry.gap
const top = extent
extent += geometry.marker + space
return { item, top, space, height: geometry.marker + space }
})
return { rows, extent }
})
const visible = createMemo(() => {
const { rows } = layout(), { top, height } = viewport()
let low = 0, high = rows.length
while (low < high) {
const middle = (low + high) >>> 1
if (rows[middle].top + rows[middle].height < top - 240) low = middle + 1
else high = middle
}
const start = low
while (low < rows.length && rows[low].top < top + height + 240) low++
return rows.slice(start, low).map(row => row.item)
})
const byItem = createMemo(() => new Map(layout().rows.map(row => [row.item, row])))
createEffect(() => {
const element = props.scrollElement
if (!element) return
const readViewport = () => setViewport({ top: element.scrollTop, height: element.clientHeight })
const measure = () => {
const next = { marker: parseFloat(getComputedStyle(marker).height), gap: parseFloat(getComputedStyle(gap).height) }
if (next.marker > 0 && (next.marker !== size().marker || next.gap !== size().gap)) setSize(next)
readViewport()
}
const observer = new ResizeObserver(measure)
observer.observe(element)
observer.observe(marker)
observer.observe(gap)
element.addEventListener("scroll", readViewport, { passive: true })
measure()
onCleanup(() => { observer.disconnect(); element.removeEventListener("scroll", readViewport) })
})
props.register({ scrollToIndex: index => {
const element = props.scrollElement, row = layout().rows[index]
if (!element || !row) return
if (row.top < element.scrollTop) element.scrollTop = row.top
else if (row.top + row.height > element.scrollTop + element.clientHeight) {
element.scrollTop = row.top + row.height - element.clientHeight
}
setViewport({ top: element.scrollTop, height: element.clientHeight })
} })
return <>
<div class="timeline-measure" aria-hidden="true" inert>
<button ref={marker} class="message-timeline-segment" tabIndex={-1} />
<div ref={gap} style={{ height: "var(--message-timeline-segment-gap)" }} />
</div>
<div class="timeline-virtual-content" style={{ height: `${layout().extent}px` }}>
<For each={visible()}>{item => <div class="timeline-virtual-row" style={{
top: `${byItem().get(item)!.top}px`, height: `${byItem().get(item)!.height}px`,
"padding-top": `${byItem().get(item)!.space}px`,
}}>{props.children(item)}</div>}</For>
</div>
</>
}

View file

@ -165,6 +165,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
let windowShiftGeneration = 0
let virtualContentResizeObserver: ResizeObserver | null = null
let observedVirtualContent: HTMLElement | null = null
let nativeScrollbarDragging = false
function invalidateScrollRestore() {
restoreToken.invalidate()
@ -222,6 +223,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
})
function markUserScrollIntent(direction: "up" | "down" | null) {
programmaticScrollUntil = 0
props.onScrollIntent?.(direction)
cancelActiveScrollRestore()
scrollController.setUserIntent(direction, performance.now() + USER_SCROLL_INTENT_WINDOW_MS)
@ -239,6 +241,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
element.dispatchEvent(new Event("scroll"))
virtuaHandle()?.scrollBy(0)
}
// At a clamped edge a wheel/key produces no scroll event. The gesture
// itself must be able to request the next historical window.
if (direction) updateScrollStateFromDom()
}
function markProgrammaticScroll() {
@ -364,18 +369,25 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
setShowScrollTopButton(hasItems && !atTop)
const now = performance.now()
if (nativeScrollbarDragging) {
const previous = scrollController.snapshot()
scrollController.setUserIntent(offset < previous.lastObservedOffset ? "up" : offset > previous.lastObservedOffset ? "down" : previous.userIntentDirection, now + USER_SCROLL_INTENT_WINDOW_MS)
}
const programmatic = hasProgrammaticScrollIntent()
const result = scrollController.observeViewport(metrics, now, programmatic)
const observed = scrollController.observeViewport(metrics, now, programmatic)
const result = nativeScrollbarDragging ? scrollController.setFollow(false) : observed
const restoring = result.state.restoring
const intent = result.state.userIntentDirection
const hasFreshIntent = now <= result.state.userIntentUntil
if (shouldNavigateAtBoundary({ atBoundary: atTop, restoring, programmatic, hasFreshIntent, intent, direction: "up" })) {
// A boundary callback may synchronously start a historical load and escape
// follow mode. Publish this observation before handing it that authority.
syncControllerResult(result)
if (!nativeScrollbarDragging && shouldNavigateAtBoundary({ atBoundary: atTop, restoring, programmatic, hasFreshIntent, intent, direction: "up" })) {
props.onUserReachedTop?.()
}
if (shouldNavigateAtBoundary({ atBoundary: atBottom, restoring, programmatic, hasFreshIntent, intent, direction: "down" })) {
if (!nativeScrollbarDragging && shouldNavigateAtBoundary({ atBoundary: atBottom, restoring, programmatic, hasFreshIntent, intent, direction: "down" })) {
props.onUserReachedBottom?.()
}
syncControllerResult(result)
}
function handleScroll() {
@ -774,6 +786,18 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
}
if (event.button === 0) lastPrimaryPointerY = event.clientY
if (event.target !== element) return
const rect = element.getBoundingClientRect()
const scale = rect.width / element.offsetWidth
const contentLeft = rect.left + element.clientLeft * scale
const contentRight = contentLeft + element.clientWidth * scale
const overlayEdge = element.offsetWidth === element.clientWidth && (
getComputedStyle(element).direction === "rtl" ? event.clientX < rect.left + 12 * scale : event.clientX >= rect.right - 12 * scale
)
if (event.button === 0 && element.scrollHeight > element.clientHeight
&& (event.clientX < contentLeft || event.clientX >= contentRight || overlayEdge)) {
nativeScrollbarDragging = true
dispatchFollowEvent({ type: "set-follow", enabled: false })
}
markUserScrollIntent(null)
}
const handlePointerMove = (event: PointerEvent) => {
@ -785,7 +809,13 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
if ((event.buttons & 1) === 0) lastPrimaryPointerY = null
}
const handlePointerEnd = () => {
const wasDragging = nativeScrollbarDragging
nativeScrollbarDragging = false
lastPrimaryPointerY = null
if (wasDragging) {
programmaticScrollUntil = 0
updateScrollStateFromDom()
}
}
let lastTouchY: number | null = null
const handleTouchStart = (event: TouchEvent) => {
@ -838,6 +868,9 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
element.addEventListener("pointermove", handlePointerMove)
element.addEventListener("pointerup", handlePointerEnd)
element.addEventListener("pointercancel", handlePointerEnd)
window.addEventListener("pointerup", handlePointerEnd)
window.addEventListener("mouseup", handlePointerEnd)
window.addEventListener("blur", handlePointerEnd)
element.addEventListener("touchstart", handleTouchStart, { passive: true })
element.addEventListener("touchmove", handleTouchMove, { passive: true })
element.addEventListener("touchend", handleTouchEnd, { passive: true })
@ -850,6 +883,10 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
element.removeEventListener("pointermove", handlePointerMove)
element.removeEventListener("pointerup", handlePointerEnd)
element.removeEventListener("pointercancel", handlePointerEnd)
window.removeEventListener("pointerup", handlePointerEnd)
window.removeEventListener("mouseup", handlePointerEnd)
window.removeEventListener("blur", handlePointerEnd)
nativeScrollbarDragging = false
element.removeEventListener("touchstart", handleTouchStart)
element.removeEventListener("touchmove", handleTouchMove)
element.removeEventListener("touchend", handleTouchEnd)

View file

@ -11,3 +11,4 @@
@import "./messaging/session-timeline-rail.css";
@import "./messaging/tool-call.css";
@import "./messaging/log-view.css";
@import './messaging/timeline-virtual-list.css';

View file

@ -1,40 +1,6 @@
/* Reserve the same space before overflow and during hover/focus. Symmetric
gutters keep the segment axis centered; only thumb contrast changes. */
:root body .message-timeline {
scrollbar-gutter: stable both-edges;
/* Use the application's standard native scrollbar. Its single gutter only
consumes marker width; icon size and vertical rhythm are independent. */
.message-timeline {
scrollbar-gutter: auto;
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thumb) transparent !important;
}
:root body .message-timeline:hover,
:root body .message-timeline:focus-within {
scrollbar-color: var(--scrollbar-thumb-hover) transparent !important;
}
@supports selector(::-webkit-scrollbar) {
:root body .message-timeline,
:root body .message-timeline:hover,
:root body .message-timeline:focus-within {
scrollbar-width: auto;
scrollbar-color: auto !important;
}
:root body .message-timeline::-webkit-scrollbar {
width: calc(var(--scrollbar-size) / 2);
}
:root body .message-timeline::-webkit-scrollbar-track {
background: transparent;
}
:root body .message-timeline::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb);
border: 0;
border-radius: 0;
}
:root body .message-timeline:hover::-webkit-scrollbar-thumb,
:root body .message-timeline:focus-within::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-thumb-hover);
}
}

View file

@ -0,0 +1,18 @@
.timeline-virtual-content {
flex: none;
position: relative;
width: 100%;
overflow-anchor: none;
}
.timeline-virtual-row {
position: absolute;
inset-inline: 0;
}
.timeline-measure {
position: absolute;
width: 1px;
visibility: hidden;
pointer-events: none;
}

View file

@ -1,3 +1,18 @@
export const navigationMessageId = (index: number) => `msg_${String(index).padStart(5, "0")}`
export const navigationMessage = (index: number) => ({ id: navigationMessageId(index), type: "user" as const,
time: { created: index + 1 }, text: `Passage ${index}\n\n${"Navigation history content. ".repeat(5 + index % 7)}` })
export const mixedNavigationMessage = (index: number): any => {
const id = navigationMessageId(index)
if (index % 5 === 0) return navigationMessage(index)
if (index % 5 === 4) return { id, type: "idle", outcome: "completed", time: { created: index + 1 } }
return { id, type: "assistant", agent: "build", model: { providerID: "fixture", id: "fixture" },
time: { created: index + 1, completed: index + 2 }, content: [
{ type: "reasoning", text: "Considering the next operation. ".repeat(5) },
...(index % 5 === 1 ? [{ type: "tool", id: `${id}-tool`, name: "shell", time: { created: index + 1 },
state: { status: "completed", input: { command: "fixture" }, content: [{ type: "text", text: "Output line\n".repeat(80) }] } }] : []),
{ type: "text", text: `Passage ${index}\n\n` + (index % 5 === 2
? "Long paragraph with **formatting** and a [link](https://example.org).\n\n".repeat(40)
: "Short response.") },
] }
}

View file

@ -12,10 +12,11 @@ import { sessions, setSessions, setActiveSession, setProviders } from "../../../
import { messageStoreBus } from "../../../src/stores/message-v2/bus"
import { sseManager } from "../../../src/lib/sse-manager"
import { loadMessages, loadLatestMessageWindow } from "../../../src/stores/session-api"
import { navigationMessage, navigationMessageId } from "./history-navigation-data"
import { navigationMessage, navigationMessageId, mixedNavigationMessage } from "./history-navigation-data"
import "../../../src/index.css"
const instanceId = "navigation", sessionId = "s", count = 1500
const makeMessage = location.search.includes("mixed") ? mixedNavigationMessage : navigationMessage
const model = { providerID: "fixture", id: "fixture" }
const assistantId = "msg_streaming"
let nativeLists = 0, time = 10000, live = "", streaming = false
@ -32,11 +33,17 @@ const client: any = {
instructions: { entry: { remove: async () => {}, put: async () => {} } },
},
model: { default: async () => ({ data: model }) },
message: { list: async ({ cursor, limit = 200 }: any = {}) => {
message: { list: async ({ cursor, limit = 200, order }: any = {}) => {
nativeLists++
if (order === 'asc' || cursor?.startsWith('asc:')) {
const start = cursor ? Number(cursor.slice(4)) : 0
const end = Math.min(count, start + limit)
return { data: Array.from({ length: end - start }, (_, index) => makeMessage(start + index)),
cursor: end < count ? { next: `asc:${end}` } : {} }
}
const end = cursor ? Number(cursor) : count
const start = Math.max(0, end - limit + (streaming && !cursor ? 1 : 0))
const data: any[] = Array.from({ length: end - start }, (_, index) => navigationMessage(start + index))
const data: any[] = Array.from({ length: end - start }, (_, index) => makeMessage(start + index))
if (streaming && !cursor) data.push({ id: assistantId, type: "assistant", agent: "build", model, time: { created: 10000 }, content: [{ type: "text", text: live }] })
return { data: data.reverse(), cursor: start ? { next: String(start) } : {} }
} },
@ -57,6 +64,7 @@ render(() => <ConfigProvider><I18nProvider><ThemeProvider><Show when={visible()}
</Show></ThemeProvider></I18nProvider></ConfigProvider>, document.getElementById("root")!)
;(window as any).fixture = {
id: navigationMessageId,
tools: (showTimelineTools: boolean) => updatePreferences({ showTimelineTools }),
openSearch: () => { updatePreferences({ locale: "en" }); setSessionSearchOpen(instanceId, sessionId, true) },
reload: () => loadMessages(instanceId, sessionId, { force: true }),
latest: () => loadLatestMessageWindow(instanceId, sessionId),

View file

@ -9,7 +9,7 @@ import { chromium, type Browser, type Page } from "playwright"
import { createServer, type ViteDevServer } from "vite"
import solid from "vite-plugin-solid"
import { readNavigationWindow, readSessionOutline } from "../../../server/src/opencode/session-pruning/navigation-store"
import { navigationMessage, navigationMessageId } from "./fixtures/history-navigation-data"
import { navigationMessage, navigationMessageId, mixedNavigationMessage } from "./fixtures/history-navigation-data"
let server: ViteDevServer, browser: Browser, url: string
before(async () => {
@ -22,21 +22,22 @@ before(async () => {
} }], resolve: { dedupe: ["solid-js"] }, optimizeDeps: { exclude: ["lucide-solid"] }, server: { host: "127.0.0.1", port: 0, hmr: false, watch: null } })
await server.listen()
url = `http://127.0.0.1:${(server.httpServer!.address() as { port: number }).port}/fixture`
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined })
browser = await chromium.launch({ executablePath: process.env.CODENOMAD_BROWSER_PATH || undefined,
ignoreDefaultArgs: ["--hide-scrollbars"], args: ["--disable-features=OverlayScrollbar"] })
})
after(async () => { await browser?.close(); await server?.close() })
async function fixture() {
async function fixture(mixed = false) {
const page = await browser.newPage({ viewport: { width: 1200, height: 800 } })
const db = new DatabaseSync(":memory:")
db.exec(`CREATE TABLE session_v2(id TEXT,directory TEXT,project_id TEXT,workspace_id TEXT,revert TEXT);
CREATE TABLE session_message(id TEXT PRIMARY KEY,session_id TEXT,type TEXT,seq INTEGER,data TEXT);
CREATE UNIQUE INDEX seq_idx ON session_message(session_id,seq);
INSERT INTO session_v2 VALUES ('s','/fixture','p',NULL,NULL);`)
const insert = db.prepare("INSERT INTO session_message VALUES (?,'s','user',?,?)")
const insert = db.prepare("INSERT INTO session_message VALUES (?,'s',?,?,?)")
for (let index = 0; index < 1500; index++) {
const { id, type: _type, ...data } = navigationMessage(index)
insert.run(id, index, JSON.stringify(data))
const { id, type, ...data } = (mixed ? mixedNavigationMessage : navigationMessage)(index)
insert.run(id, type, index, JSON.stringify(data))
}
const errors: string[] = [], windows: any[] = []
const reads = new Set<Promise<unknown>>()
@ -60,7 +61,7 @@ async function fixture() {
}
await route.fulfill({ contentType: "application/json", body: JSON.stringify(response) }).catch(() => {})
})
await page.goto(url)
await page.goto(url + (mixed ? "?mixed" : ""))
await page.waitForFunction(() => Boolean((window as any).fixture))
await page.locator('.message-timeline[data-segment-count="1500"]').waitFor()
return { page, windows, errors,
@ -75,6 +76,7 @@ async function fixture() {
}
async function clickTimeline(page: Page, index: number, checkReaderPosition = false) {
await page.locator('.message-timeline').hover()
await page.locator(".message-timeline").evaluate((element, index) => {
element.scrollTop = element.scrollHeight * index / 1500
}, index)
@ -90,6 +92,18 @@ async function clickTimeline(page: Page, index: number, checkReaderPosition = fa
}
const snapshot = (page: Page) => page.evaluate(() => (window as any).fixture.snapshot())
async function assertPassageAtTop(page: Page, index: number) {
await page.waitForFunction(id => {
const stream = document.querySelector('.message-stream')!
const row = stream.querySelector(`[data-virtual-follow-key="${id}"]`)
return row && Math.abs(row.getBoundingClientRect().top - stream.getBoundingClientRect().top) < 2
}, navigationMessageId(index))
await page.waitForTimeout(500)
const offset = await page.locator(`[data-virtual-follow-key="${navigationMessageId(index)}"]`).evaluate(row =>
row.getBoundingClientRect().top - document.querySelector('.message-stream')!.getBoundingClientRect().top)
assert(Math.abs(offset) < 2, `selected passage ${index} moved by ${offset}px`)
}
test("global timeline jumps over 1200 messages in one bounded window and restores the selected passage", async () => {
const f = await fixture()
try {
@ -99,6 +113,7 @@ test("global timeline jumps over 1200 messages in one bounded window and restore
await clickTimeline(f.page, 250)
await f.page.waitForFunction(() => (window as any).fixture.snapshot().ids.includes("msg_00250"))
await f.page.locator('.message-stream [data-message-id="msg_00250"]').first().waitFor()
await assertPassageAtTop(f.page, 250)
const jumped = await snapshot(f.page)
assert.equal(f.windows.length, 1)
assert.equal(jumped.nativeLists, before.nativeLists, "jump does not replay native pages")
@ -106,6 +121,7 @@ test("global timeline jumps over 1200 messages in one bounded window and restore
assert.equal(jumped.window.kind, "history")
assert.deepEqual(jumped.model, before.model)
await f.page.evaluate(() => (window as any).fixture.reload())
await assertPassageAtTop(f.page, 250)
const restored = await snapshot(f.page)
assert.deepEqual(restored.ids, jumped.ids)
assert.equal(f.windows.length, 2)
@ -117,6 +133,7 @@ test("global timeline jumps over 1200 messages in one bounded window and restore
await f.page.evaluate(() => (window as any).fixture.switchAway())
await f.page.evaluate(() => (window as any).fixture.return())
await f.page.locator(".message-stream").waitFor()
await assertPassageAtTop(f.page, 250)
assert((await snapshot(f.page)).ids.includes(navigationMessageId(250)))
assert.deepEqual(f.errors, [])
const captures = path.join(os.tmpdir(), "opencode"); await mkdir(captures, { recursive: true })
@ -157,7 +174,146 @@ test("resident streaming keeps a distant timeline marker mounted", async () => {
} finally { await f.close() }
})
test("timeline gutter stays fixed across overflow, hover, focus, RTL and fractional zoom", async () => {
test("mixed timeline keeps an exact scrollbar extent throughout manual browsing and hidden tools", async () => {
const f = await fixture(true)
try {
const rail = f.page.locator('.message-timeline')
for (const tools of [true, false]) {
await f.page.evaluate(tools => (window as any).fixture.tools(tools), tools)
await rail.hover()
await f.page.waitForTimeout(400)
const heights: number[] = []
for (const fraction of [0, 0.25, 0.75, 0.5, 1, 0]) {
await rail.evaluate((element, fraction) => { element.scrollTop = (element.scrollHeight - element.clientHeight) * fraction }, fraction)
await f.page.waitForTimeout(200)
heights.push(await rail.evaluate(element => element.scrollHeight))
}
assert(Math.max(...heights) - Math.min(...heights) <= 1, `scrollbar extent changes while browsing (tools=${tools}): ${heights}`)
}
assert.deepEqual(f.errors, [])
} finally { await f.close() }
})
test("mixed transcript lands on the clicked message after distant jumps and retains it during streaming", async () => {
const f = await fixture(true)
try {
for (const index of [250, 900, 100, 1450]) {
await clickTimeline(f.page, index)
await assertPassageAtTop(f.page, index)
await f.page.evaluate(() => (window as any).fixture.stream('Another token. '))
await assertPassageAtTop(f.page, index)
}
assert.deepEqual(f.errors, [])
} finally { await f.close() }
})
async function dragNativeThumb(page: Page, selector: string, fraction: number) {
await page.locator(selector).hover()
await page.evaluate(() => new Promise(requestAnimationFrame))
const geometry = await page.locator(selector).evaluate(element => {
const rect = element.getBoundingClientRect()
const inset = element.offsetWidth - element.clientWidth
const track = element.clientHeight - inset * 2
const height = Math.max(18, track * element.clientHeight / element.scrollHeight)
const travel = track - height
const ratio = element.scrollTop / (element.scrollHeight - element.clientHeight)
return { x: rect.right - inset / 2, top: rect.top + inset, height, travel,
y: ratio > 0.99 ? rect.bottom - 16 : ratio < 0.01 ? rect.top + 16 : rect.top + inset + height / 2 + travel * ratio }
})
await page.mouse.move(geometry.x, geometry.y)
await page.mouse.down()
await page.waitForTimeout(750)
await page.mouse.move(geometry.x, geometry.top + geometry.height / 2 + geometry.travel * fraction, { steps: 12 })
await page.mouse.up()
}
test("native transcript thumb remains under reader control after a held press", async () => {
const f = await fixture(true)
try {
await f.page.waitForTimeout(500)
await dragNativeThumb(f.page, '.message-stream', 0.5)
await f.page.waitForTimeout(500)
const ratio = await f.page.locator('.message-stream').evaluate(element => element.scrollTop / (element.scrollHeight - element.clientHeight))
assert(ratio > 0.2 && ratio < 0.8, `native thumb snapped away from middle: ${ratio}`)
const before = await f.page.locator('.message-stream').evaluate(element => {
const top = element.getBoundingClientRect().top
const row = Array.from(element.querySelectorAll<HTMLElement>('[data-virtual-follow-key]')).find(row => {
const rect = row.getBoundingClientRect()
return rect.top <= top && rect.bottom > top
})!
return { key: row.dataset.virtualFollowKey, offset: row.getBoundingClientRect().top - top }
})
await f.page.evaluate(() => (window as any).fixture.stream('Streaming during manual scrollbar reading.'))
await f.page.waitForTimeout(400)
const after = await f.page.locator(`[data-virtual-follow-key="${before.key}"]`).evaluate(element =>
element.getBoundingClientRect().top - document.querySelector('.message-stream')!.getBoundingClientRect().top)
assert(Math.abs(before.offset - after) < 2, `streaming displaced visible passage: ${before.offset} -> ${after}`)
} finally { await f.close() }
})
test("wheel at a stationary historical boundary opens the adjacent window", async () => {
const f = await fixture(true)
try {
await clickTimeline(f.page, 250)
await assertPassageAtTop(f.page, 250)
const before = await snapshot(f.page)
await f.page.locator('.message-stream[tabindex]').evaluate(element => { element.scrollTop = 0 })
await f.page.waitForTimeout(500)
const stream = await f.page.locator('.message-stream[tabindex]').boundingBox()
assert(stream)
await f.page.mouse.move(stream.x + 25, stream.y + 25)
await f.page.mouse.wheel(0, -200)
await f.page.waitForFunction(id => (window as any).fixture.snapshot().ids[0] !== id, before.ids[0], { timeout: 4000 })
await assertPassageAtTop(f.page, 170)
const older = await snapshot(f.page)
await f.page.locator('.message-stream[tabindex]').evaluate(element => { element.scrollTop = element.scrollHeight })
await f.page.waitForTimeout(500)
await f.page.mouse.wheel(0, 200)
await f.page.waitForFunction(id => (window as any).fixture.snapshot().ids.at(-1) !== id, older.ids.at(-1), { timeout: 4000 })
assert((await snapshot(f.page)).ids.includes(older.ids.at(-2)), 'newer window retains the boundary passage')
assert.deepEqual(f.errors, [])
} finally { await f.close() }
})
test("first/latest controls remain available at local boundaries and land at actual global ends", async () => {
const f = await fixture(true)
try {
await clickTimeline(f.page, 250)
await assertPassageAtTop(f.page, 250)
const stream = f.page.locator('.message-stream[tabindex]')
await stream.evaluate(element => { element.scrollTop = element.scrollHeight })
await f.page.waitForTimeout(500)
await f.page.getByRole('button', { name: /Scroll to latest message|Aller au dernier message/ }).click()
await f.page.waitForFunction(() => (window as any).fixture.snapshot().window.kind === 'latest')
await f.page.waitForTimeout(400)
assert(await stream.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop < 2))
await f.page.getByRole('button', { name: /Scroll to first message|Aller au premier message/ }).click()
await f.page.waitForFunction(() => (window as any).fixture.snapshot().ids[0] === 'msg_00000')
await assertPassageAtTop(f.page, 0)
const selected = f.page.locator('.message-timeline [aria-current="true"]')
await selected.waitFor()
assert.equal(await selected.getAttribute('data-message-id'), 'msg_00000')
assert.deepEqual(f.errors, [])
} finally { await f.close() }
})
test("keyboard scrolling owns the timeline without paging the transcript", async () => {
const f = await fixture(true)
try {
const rail = f.page.locator('.message-timeline')
await rail.focus()
await rail.press('Home')
await f.page.waitForTimeout(400)
assert.equal(await rail.evaluate(element => element.scrollTop), 0)
await rail.press('End')
await f.page.waitForTimeout(400)
assert(await rail.evaluate(element => element.scrollHeight - element.clientHeight - element.scrollTop < 2))
assert.equal(f.windows.length, 0)
assert.deepEqual(f.errors, [])
} finally { await f.close() }
})
test("timeline uses the standard scrollbar and only rectangle width changes in RTL and fractional zoom", async () => {
const f = await fixture()
try {
const rail = f.page.locator(".message-timeline")
@ -166,24 +322,25 @@ test("timeline gutter stays fixed across overflow, hover, focus, RTL and fractio
const measure = () => rail.evaluate(element => {
const style = getComputedStyle(element)
return { width: element.clientWidth, gutter: element.offsetWidth - element.clientWidth, scrollbar: style.scrollbarWidth,
segment: element.querySelector(".message-timeline-segment")!.getBoundingClientRect().width,
height: element.querySelector(".message-timeline-segment")!.getBoundingClientRect().height,
segment: element.querySelector(".message-timeline-segment[data-message-id]")!.getBoundingClientRect().width,
height: element.querySelector(".message-timeline-segment[data-message-id]")!.getBoundingClientRect().height,
icon: element.querySelector(".message-timeline-icon")!.getBoundingClientRect().width }
})
const normal = await measure()
assert.notEqual(normal.scrollbar, "none")
assert(normal.gutter > 0 && normal.gutter <= 10, JSON.stringify({ direction, zoom, normal }))
const standard = await f.page.locator('.message-stream[tabindex]').evaluate(element => ({
gutter: element.offsetWidth - element.clientWidth, scrollbar: getComputedStyle(element).scrollbarWidth,
}))
assert.equal(normal.gutter, standard.gutter)
assert.equal(normal.scrollbar, standard.scrollbar)
await rail.hover()
const hover = await measure()
assert.equal(hover.width, normal.width)
assert.equal(hover.segment, normal.segment)
assert.equal(hover.height, normal.height)
assert.equal(hover.icon, normal.icon)
await rail.locator("button").first().focus()
await rail.locator("button[data-message-id]").first().focus()
assert.equal((await measure()).width, normal.width)
await rail.evaluate(element => { element.style.overflowY = "hidden" })
assert.equal((await measure()).width, normal.width, "reserved gutter remains without overflow")
await rail.evaluate(element => { element.style.overflowY = "auto" })
await rail.evaluate(element => { element.style.scrollbarGutter = "auto"; element.style.setProperty("scrollbar-width", "none", "important") })
const withoutGutter = await measure()
assert(withoutGutter.segment > normal.segment, "only the surrounding rectangle gives up width to the gutter")
@ -194,6 +351,27 @@ test("timeline gutter stays fixed across overflow, hover, focus, RTL and fractio
} finally { await f.close() }
})
test("native timeline thumb follows the pointer without moving the transcript or snapping back during streaming", async () => {
const f = await fixture(true)
try {
const before = await snapshot(f.page)
await dragNativeThumb(f.page, '.message-timeline', 0.5)
await f.page.waitForTimeout(400)
const read = () => f.page.locator('.message-timeline').evaluate(element => ({
top: element.scrollTop, extent: element.scrollHeight,
ratio: element.scrollTop / (element.scrollHeight - element.clientHeight),
}))
const middle = await read()
assert(Math.abs(middle.ratio - 0.5) < 0.02, JSON.stringify(middle))
await f.page.evaluate(() => (window as any).fixture.stream('Live token while browsing the rail.'))
await f.page.waitForTimeout(400)
assert(Math.abs((await read()).top - middle.top) < 2, 'new transcript content must not reclaim the rail')
assert.equal((await snapshot(f.page)).window.kind, before.window.kind)
assert.equal(f.windows.length, 0, 'dragging the rail must not page the transcript')
assert.deepEqual(f.errors, [])
} finally { await f.close() }
})
test("a newer distant destination wins and a hidden session rejects a pending jump", async () => {
const f = await fixture()
try {