refactor(chat): finish transcript scroll migrations and fix known scroll follow-ups (#980)

Mission final workstream (scroll unification), after #971-#978.

**Migrations:** wheel intent goes through the gesture routing's single
nested-scroller decision (no second ancestor walk; SVG targets in nested
output count as consumed); touch/key intent reuse findNestedScroller;
the viewport observer uses trackHeightDelta; search reveal reuses
scrollAxes; contentRendered no longer reads layout per streamed chunk;
the constant sentinelMarginPx field is removed.

**Fixes (each red before, green after):** a hidden transcript under the
mission reader is no longer pinned to the bottom; an escaped reader
captured at the bottom (hold triggered there) keeps its anchor; a send
before hydration cancels the pending activation restore; a wheel no
longer releases Virtua's imperative operation (scrollBy(0) reasserted
the pre-gesture offset and undid the first notch that mounted rows).

**Documented, not fixed:** two effects of a still-live Virtua index jump
(session switch within its landing frame; frames at the message top
during a deep reveal).

Validation: typecheck; unit 68/68; after rebase on dev: message-scroll +
history-navigation + mission-reader-transcript + session-rendering
160/160; full run also session-search, search-highlight, history,
streaming-outline, project-tab-restore, interruption-selection,
render-cost 17/17.
This commit is contained in:
Pascal André 2026-10-17 10:05:05 +02:00 • committed by GitHub
parent a7fd215c44
commit 3394fc7f2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2095 additions and 472 deletions

View file

@ -113,11 +113,110 @@ also account for off-window messages, not just the local scroll offset.
`VirtualFollowList.writeScroll(reason, op)` is the only list-owned transcript position write.
Reasons come from the closed `SCROLL_REASONS` list in `virtual-scroll-trace.ts`; the
same call opens the programmatic window, records synchronous targets and keeps
imperative Virtua operations cancellable by the next gesture. Follow-mode changes go
through `syncControllerResult` with a reason as well. Outside it remain user gestures
same call opens the programmatic window and records synchronous targets. Every write
is a DOM write: the list never issues Virtua's imperative operations
(`scrollTo`/`scrollBy`/`scrollToIndex`). In Virtua 0.51 such an operation reapplies
its fixed target on every item or viewport measurement until 150 ms after the last
one; the rows a gesture mounts, or a streaming tail, keep re-arming it, and nothing
public cancels it, so it would undo any gesture or later write made meanwhile. Key
jumps (always to the row's top) and anchor restoration therefore write the offset
Virtua would target for the row (`writeIndex`, from its own item offsets) and let a
list stabilizer settle the row once measured, both cancelled by every gesture: a
jump's row is held like an in-place reveal (below), for the same bounded window, and
then released, so a later focus, Tab or `scrollIntoView` is never pulled back to it;
the restoration keeps its own loop. A restored anchor that has shrunk since its capture is
kept partly visible: Virtua mounts no overscan above the viewport after a downward
scroll, so a row held entirely above it would be unmounted and re-placed in a loop.
A rolling window (the store keeps the latest 200 messages, so each agent step in a
long session drops the oldest) never enters Virtua's shift mode, which compensates
every later measurement until scrolling idles and can only be left through an
imperative operation. Rows leaving the head are instead retired in place: they stay
in Virtua's data as an empty prefix (a key and a size, no content), so Virtua only
sees the data grow, measures them at zero, and its ordinary resize anchoring keeps
the reader in place. Callers never see them: `renderItem` receives live indexes, and
key lookups, the active row and anchors skip them; a remap never carries a retired
row's zero to the same message brought back by a page (live rows are remapped by
key, a retained retired prefix by position), and a retired key coming back without
a page resets the list. Virtua does not compensate every shrinking row (when idle or
scrolling up, only one entirely above the viewport; a retiring row across the top is
left uncompensated), so a roll records an escaped reader's place, its first visible
surviving row and offset. Layout notifications come from the list's row
ResizeObserver. ResizeObserver delivers a pass in observer creation order, so that
observer is recreated after each Virtua instance (every measurement reset remounts
one) and reports after Virtua's measurement, before paint; each roll re-observes a
rendered row, so the pass in which Virtua measures the retiring rows at zero reaches
it, with or without a net append. It puts the reader's row back and drops the record
once every retired row is measured at zero: no painted frame shows the retiring
row's height, and nothing outlives the measurement to pull back a later programmatic
scroll, a viewport resize (which stays bottom-anchored, clamping included) or a
middle-button autoscroll. A held native scrollbar thumb is corrected the same way:
Chromium continues a thumb drag from the current offset. Until that measurement
Virtua keeps every row where it was, so a write or reveal in the roll's own task,
whose scroll event arrives before the measurement, moves the recorded place with
it. A write from an animation-frame callback of the roll's frame is still undone:
its scroll event arrives only after the measurement, when the correction has
already run (list-owned reveals supersede the correction instead). A `beforeItems`
resize not yet corrected by its own observer is discounted from the place, since
that correction (before the measurement) restores the rows it displaces; only the
move the correction will actually make is discounted (its target, clamped like
the write).
The header correction keeps the reader's rows where they were. A header that
shrinks may first be clamped by the browser, which happens only for a reader that
was closer to the bottom than the shrink (by the recorded maximum), or that can no
longer scroll down (the viewport may have grown in the same task, unobserved). A
clamp only lowers the offset, by at most the shrink, so in that zone the
correction restores no more than that: it starts from the recorded offset, or from
the current offset plus the shrink if that is lower. A clamp against the maximum
of any intermediate layout (a roll appending rows in the same task), fractional
heights and a viewport resize in the same task are thus undone, while a downward
write that growth in the same task made possible, and the part of an upward move
beyond the shrink, are kept. Everywhere else the correction starts from the
current offset, so a write, wheel or PageUp in the resize's frame is kept whole.
The recorded offset and maximum are refreshed on every scroll event and row
resize, streaming growth included, while no header resize is pending. In the
shrink's frame, a reader in that zone loses at most the shrink of a move made in
that frame: all of an upward move smaller than the shrink, and of a downward move
(without growth in the same task) what the clamp took, since a clamp cannot be
told apart from a write landing on the new bottom; outside it nothing is lost.
Virtua computes a scroll's range from its start margin, and a downward scroll
keeps no overscan above, so a margin smaller than the laid-out header unmounts
rows that are still visible, including the read row; a margin change alone does
not make Virtua render again (nor does a scroll event at an unchanged offset).
Every scroll event therefore hands Virtua the larger of the header's laid-out and
observed heights: a grown header at once, a shrink only once its observer has run,
since the shrink may revert before then. A pending shrink's range may then end
short below the viewport, beyond the overscan, until the shrink's own correction,
whose write scrolls and re-renders in the same pass. After writing its correction
the header dispatches one scroll event, so Virtua renders the rows that correction
brings back in the same pass rather than one painted frame later. Header heights
are compared with scroll offsets: under CSS `zoom` (not used by the app, which
zooms natively through the device scale factor) the two are in different units,
and the correction is off by the shrink times (zoom − 1).
The place is kept from roll to roll while nothing else moves the reader by more
than 2 px, so Virtua's rounded compensations (a fraction of a pixel per roll, more
at fractional zoom) never accumulate; returning to follow forgets it.
Growth above an escaped reader is Virtua's own compensation, as without rolls, and
can still drift it by a few pixels over many updates. The row observer never watches
Virtua's container, which Virtua resizes during its own measurement: a shallower
observation changing mid-pass would be deferred to the next frame (a
`ResizeObserver loop completed with undelivered notifications` window error). Only
rows that Virtua itself mounts during that pass, after an uncompensated shrink such
as a retiring row across the viewport top, still raise that error; they lie in the
overscan and are measured on the next frame. Retired rows stay mounted only until
Virtua has measured them at zero (a watermark advanced on every items change and row
resize), so the DOM stays bounded; their keys and sizes accumulate until the next
reset (page navigation, a disjoint window, an emptied list or a session change), a
few bytes per step. In a hidden (`display:none`) list the retired rows are measured,
and compensated, when it is shown again. Follow-mode changes go through
`syncControllerResult` with a reason as well. Outside it remain user gestures
(including wheel re-emission and middle-button autoscroll), Virtua's own size
compensation, browser clamping and search-result reveals inside nested scrollers.
List positions are read from the DOM, not Virtua's `scrollOffset`, which lags a write
until its scroll event; a following list is captured at the bottom (without an
anchor) wherever its pin's latest write stands.
A search navigation reveals its occurrence once, after placing its message. Inside
nested code/tool scrollers the reveal writes them directly and emits
@ -136,10 +235,9 @@ is `write`, `mode` or `observe` JSON with session, offsets, scroll height and mo
`observe` move that no write claimed is `unowned`; `intent` distinguishes a user
gesture from Virtua/browser movement. A recent synchronous write claims only its
actual landing offset, not unrelated moves during its 120ms programmatic window
(`during` retains that recent reason); asynchronous Virtua/smooth operations are
attributed by that window and must be correlated with their preceding `write`.
Virtua releases are logged without taking over attribution. Synchronous no-op writes
are omitted; asynchronous requests report `now` and `target`, never a pre-landing `to`.
(`during` retains that recent reason); smooth scrolls are attributed by that window
and must be correlated with their preceding `write`. Synchronous no-op writes are
omitted; smooth requests report `now` and `target`, never a pre-landing `to`.
The trace is observational, not a proof of native/virtualizer ownership. While enabled,
`.message-stream` also carries `data-scroll-write` (last position-write reason), cleared
with its attribution state on the next list action/observation after disabling.
@ -158,11 +256,16 @@ before estimating lasts only until the first remount whose retained rows are all
measured. A new instance reads offset 0 until the next scroll event, so a non-zero
offset is seeded by one scroll event (the
remount has already recreated every row). The long-reply hold scrolls nothing; the
section persists its escaped position as a follow-mode change. Gestures and explicit navigation supersede
restoration, alignment and bottom pins through one function; deactivation, session
reset and unmount stop every list-owned positioning operation (including a
restoration in flight) through another, and deactivation also releases Virtua's
queued imperative scroll.
section persists its escaped position as a follow-mode change and leaves it unsettled,
so a state capture's flush records it again after growth below it. An escaped reader
keeps its anchor even when captured at the bottom (a hold triggered there), and
returns to that anchor rather than to a bottom that has grown while it was away; the
restore loads that anchor's window like any other anchor. Gestures and explicit
navigation supersede restoration, alignment and bottom pins through one function;
deactivation, session reset and unmount stop every list-owned positioning operation
(including a restoration in flight) through another. Middle-button autoscroll over a
nested output deliberately releases the transcript's follow as well: a transcript
still re-pinning its bottom would move that output under the held pointer.
One bottom loop (`bottomPin`) keeps the bottom, and only while following: leaving
follow ends it. Ordinary following re-pins for two frames after each bottom write;
@ -174,10 +277,13 @@ supersedes it (the pin settles `interrupted`), except that a restoration to the
bottom joins an active explicit pin and keeps its row count; it reports `onApplied`
only after its own landing, and `onCancelled` when superseded or when its pin loses
follow without a gesture. A new send intent also cancels MessageSection's pending
window paging, which would otherwise leave follow after the send.
window paging, which would otherwise leave follow after the send, and its pending
activation restore: a send before hydration keeps the bottom over a saved passage,
and does so even if the prompt is then rejected (the saved passage is not restored).
In-place reveals (`alignElement`) are reader-settlement holds on the revealed
element, with the former element alignment's lifetime: the element is measured and
element; key jumps hold their row the same way at offset 0. Both have the former
element alignment's lifetime and are released afterwards: the element is measured and
re-aligned on every frame of a fixed 30-frame window counted from the request, which
layout never extends. Within it, viewport and `beforeItems` resizes re-align the
element instead of dropping it, and its requested offset is kept and clamped into
@ -185,13 +291,25 @@ the current viewport only when applied. Gestures, navigation and focus moving to
another element inside the transcript scroller (Tab, assistive technology) end it at
once, and so does losing the element (an unmounted row); focus leaving the
transcript (e.g. to the composer) does not. Any other scroll during the window is
corrected like layout, as before. While a hold is active, a window shift retains the
hold rather than the visible top anchor, so no reader stays retained after it
expires. Settlement, alignment and anchor restoration share one anchor measurement.
Pre-existing: a Virtua index request still pending from an earlier key navigation
re-runs on every measurement while they keep coming (for instance during streaming)
and can re-place its row; a gesture, another index navigation, a restoration or
deactivation replaces it.
corrected like layout, as before. A hold whose row rolls out of the window ends;
nothing retains a reader after a hold expires. Settlement, alignment and anchor
restoration share one anchor measurement. The long-reply hold is decided before
each bottom write, against the offset that write would reach, so it engages with the
reply top still below its line: once that top is above the viewport, Virtua (still
scrolling down) would compensate the reply's next growth and push it further up.
Known follow-ups: a search reveal is aligned one frame after its row's highlights
are painted, so that frame can show the message top; and Virtua renders the rows of
a jump's new offset only at its next scroll event, so a session switch within that
frame finds no anchor to capture and keeps the position from before the jump.
A hold whose reply grows while its pane is hidden lands at the top only
in the switch fixture: native events for an inactive session invalidate its message
load, and that fixture's `message.list` omits the in-flight reply, so the reload drops
the hold's anchor and restoration waits `ANCHOR_RESTORE_MAX_FRAMES` before its pixel
fallback. With the reply listed, the hold returns exactly; but the latest button's
reload then re-engages the long-reply hold at once, a product decision left open.
A following list shown again after `display:none` is pinned before its first painted
frame: a gap read in the animation frame that shows it predates layout and the
ResizeObserver-driven pin, and a browser check measures 0 px once painted.
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,

View file

@ -471,8 +471,8 @@ export default function MessageSection(props: MessageSectionProps) {
}
setNavigation({ kind: "idle" })
// A committed page records its intended position in the store; record the reader's actual one.
// Known follow-up: on a session switch 0-1 frames after a jump lands, capture is not allowed and the
// last good position predates the jump (as before this navigation state).
// Known follow-up: Virtua renders a jump's rows only at its next scroll event; a view hidden
// before then finds no anchor to capture, so the last good position predates the jump.
persistMessageScrollSnapshot({ sessionId: paging.sessionId, allowCapture: paging.sessionId === props.sessionId, requireActive: false })
}
@ -530,11 +530,12 @@ export default function MessageSection(props: MessageSectionProps) {
})
// A send loads and pins the latest window itself: stale paging must not resume
// afterwards and leave follow, which would cancel the new send pin. The intent
// object is rebuilt on every count/streaming change; only a new token is a send.
// afterwards and leave follow, and a restore still pending must not run after
// it, either of which would cancel the new send pin. The intent object is
// rebuilt on every count/streaming change; only a new token is a send.
const sendIntentToken = createMemo(() => props.explicitBottomPinIntent?.token)
createEffect(on(sendIntentToken, (token) => {
if (token !== undefined) cancelWindowNavigation()
if (token !== undefined) cancelWindowNavigation(true)
}, { defer: true }))
function canCaptureScrollSnapshot(options?: { requireActive?: boolean }) {
@ -597,22 +598,14 @@ export default function MessageSection(props: MessageSectionProps) {
}
onCleanup(messageStoreBus.onScrollSnapshotFlush(persistSettledScroll))
// A pinned stream keeps scrolling, so it never settles: persist a follow-mode
// change rather than wait for the stream to pause. A gesture resumes follow
// before its scroll lands, so a resume persists once the reader is at the
// bottom; earlier, the snapshot would restore as escaped.
let followResumePending = false
function persistFollowResume() {
const snapshot = listApi()?.captureScrollSnapshot()
if (!snapshot?.atBottom) return
followResumePending = false
scrollUnsettled = false
persistMessageScrollSnapshot({ snapshot })
}
// change rather than wait for the stream to pause. The list captures a
// following reader at the bottom even before a resuming gesture lands.
createEffect(on(() => listState()?.autoScroll(), (following, previous) => {
if (following === undefined || previous === undefined) return
followResumePending = following
if (following) persistFollowResume()
else persistScroll()
persistScroll()
// Growth below an escaped reader (a long-reply hold) scrolls nothing, so
// nothing settles: a state capture's flush must still record it again.
if (!following) scrollUnsettled = true
}, { defer: true }))
// Persist scroll position when switching sessions. This effect's cleanup runs
@ -694,10 +687,12 @@ export default function MessageSection(props: MessageSectionProps) {
const restore = async () => {
setOlderMessageLoadFailed(false)
let restoredSnapshot = snapshot
const loadsAnchor = !snapshot.atBottom && snapshot.anchorKey && !visibleMessageIds().includes(snapshot.anchorKey) && (props.onLoadMessageAnchor || props.onLoadMoreMessages)
// An anchor identifies an escaped reader, even one captured at the bottom
// (a long-reply hold); only a following snapshot has none.
const loadsAnchor = snapshot.anchorKey && !visibleMessageIds().includes(snapshot.anchorKey) && (props.onLoadMessageAnchor || props.onLoadMoreMessages)
// The bottom of the latest page (or no position) reopens that page first
// when a cancelled jump left a historical window in its place.
const loadsLatest = snapshot.atBottom && stored?.windowIsLatest !== false && props.onLoadLatestMessages
const loadsLatest = snapshot.atBottom && !snapshot.anchorKey && stored?.windowIsLatest !== false && props.onLoadLatestMessages
&& !isLatestWindow(store().getMessageWindow(restoreSessionId))
if (loadsAnchor || loadsLatest) {
try {
@ -738,7 +733,6 @@ export default function MessageSection(props: MessageSectionProps) {
retryAnchorRestore = null
api.restoreScrollSnapshot(restoredSnapshot, {
behavior: "auto",
fallback: () => {
if (!isCurrentRestore()) return
api.setAutoScroll(true)
@ -830,7 +824,7 @@ export default function MessageSection(props: MessageSectionProps) {
}
function placeMessage(api: VirtualFollowListApi, messageId: string, searchMatchId?: string) {
api.scrollToKey(messageId, { block: "start" })
api.scrollToKey(messageId)
if (searchMatchId) setSearchReveal({ matchId: searchMatchId, claimed: false })
else claimSearchReveal()
}
@ -1289,7 +1283,6 @@ export default function MessageSection(props: MessageSectionProps) {
onScroll={() => {
clearQuoteSelection()
scrollUnsettled = true
if (followResumePending) persistFollowResume()
}}
onScrollEnd={persistSettledScroll}
onUserReachedTop={() => { void pageWindow("older", (api) => api.scrollToBottom({ immediate: true })) }}

View file

@ -1,3 +1,4 @@
import { scrollAxes } from "../lib/scroll-gesture-routing"
import type { SessionSearchMatch } from "../lib/session-search"
import { isScrollTraceEnabled, traceScroll } from "./virtual-scroll-trace"
@ -63,9 +64,9 @@ export type AlignInTranscript = (target: HTMLElement, viewportOffset: number) =>
* element at the top, possibly still out of view. The alignment re-measures
* that element for 30 frames, and a wheel consumed by a nested scroller is not
* a list gesture: scrolling such a scroller during that window moves the
* transcript along with the element. Known follow-up: Virtua's pending index
* scroll can paint one frame back at the message top before the alignment's
* next correction. Nested scrollers, and a
* transcript along with the element. Known follow-up: the reveal runs one frame
* after the row's highlights are painted, so that frame can show the message
* top. Nested scrollers, and a
* transcript outside a list, are written directly and traced as `search-reveal`.
*/
export function revealSearchRange(range: Range, align?: AlignInTranscript): void {
@ -74,11 +75,11 @@ export function revealSearchRange(range: Range, align?: AlignInTranscript): void
const document = node.ownerDocument!
const transcript = node.parentElement?.closest(".message-stream")
for (let parent = node.parentElement; parent; parent = parent === transcript ? null : parent.parentElement) {
const style = document.defaultView!.getComputedStyle(parent)
const axes = scrollAxes(parent)
const rect = range.getBoundingClientRect(), bounds = parent.getBoundingClientRect()
const top = bounds.top + parent.clientTop, left = bounds.left + parent.clientLeft
const vertical = parent.scrollHeight > parent.clientHeight && (/auto|scroll|overlay/.test(style.overflowY) || parent === document.scrollingElement)
const horizontal = parent.scrollWidth > parent.clientWidth && /auto|scroll|overlay/.test(style.overflowX)
const vertical = axes.y || (parent === document.scrollingElement && parent.scrollHeight > parent.clientHeight)
const horizontal = axes.x
if (vertical && (rect.top < top || rect.bottom > top + parent.clientHeight)) {
const delta = (rect.top + rect.bottom) / 2 - top - parent.clientHeight / 2
const target = node.parentElement!

View file

@ -35,8 +35,8 @@ import {
const userScroll = (direction: "up" | "down" | null, atBottom: boolean) =>
({ type: "user-scroll", direction, atBottom }) as const
function metrics(offset: number, scrollHeight = 3000, clientHeight = 600, sentinelMarginPx = BOTTOM_FOLLOW_EPSILON_PX): ScrollControllerMetrics {
return { offset, scrollHeight, clientHeight, sentinelMarginPx }
function metrics(offset: number, scrollHeight = 3000, clientHeight = 600): ScrollControllerMetrics {
return { offset, scrollHeight, clientHeight }
}
describe("virtual follow behavior", () => {
@ -174,8 +174,8 @@ describe("virtual follow behavior", () => {
})
it("key jumps always escape follow mode", () => {
const fromEscaped = transitionFollowMode({ type: "escaped" }, { type: "jump-key", key: "a", block: "start", smooth: false })
const fromFollowing = transitionFollowMode({ type: "following" }, { type: "jump-key", key: "b", block: "center", smooth: true })
const fromEscaped = transitionFollowMode({ type: "escaped" }, { type: "jump-key", key: "a" })
const fromFollowing = transitionFollowMode({ type: "following" }, { type: "jump-key", key: "b" })
assert.deepEqual(fromEscaped.mode, { type: "escaped" })
assert.deepEqual(fromFollowing.mode, { type: "escaped" })
@ -398,7 +398,7 @@ describe("virtual follow behavior", () => {
const controller = new VirtualScrollController(true)
controller.setRestoring(true)
const result = controller.contentRendered(metrics(2400), true)
const result = controller.contentRendered(true)
assert.deepEqual(result.state.mode, { type: "following" })
assert.deepEqual(result.effect, { type: "none" })

View file

@ -248,13 +248,13 @@ export type FollowEffect =
| { type: "none" }
| { type: "scroll-top"; immediate: boolean }
| { type: "scroll-bottom"; immediate: boolean }
| { type: "scroll-key"; key: string; block: ScrollLogicalPosition; smooth: boolean }
| { type: "scroll-key"; key: string }
export type FollowEvent =
| { type: "user-scroll"; direction: "up" | "down" | null; atBottom: boolean }
| { type: "jump-top"; immediate: boolean }
| { type: "jump-bottom"; immediate: boolean; explicit: boolean }
| { type: "jump-key"; key: string; block: ScrollLogicalPosition; smooth: boolean }
| { type: "jump-key"; key: string }
| { type: "content-grew"; canPinToBottom: boolean }
| { type: "set-follow"; enabled: boolean }
| { type: "reset"; follow: boolean }
@ -270,7 +270,6 @@ export interface ScrollControllerMetrics {
offset: number
scrollHeight: number
clientHeight: number
sentinelMarginPx: number
}
export interface ScrollControllerState {
@ -349,7 +348,7 @@ export function transitionFollowMode(mode: FollowMode, event: FollowEvent): Foll
case "jump-key":
return {
mode: { type: "escaped" },
effect: { type: "scroll-key", key: event.key, block: event.block, smooth: event.smooth },
effect: { type: "scroll-key", key: event.key },
}
case "content-grew":
@ -371,7 +370,7 @@ export function getDistanceFromBottom(metrics: ScrollControllerMetrics) {
}
export function isAtBottom(metrics: ScrollControllerMetrics) {
return getDistanceFromBottom(metrics) <= metrics.sentinelMarginPx
return getDistanceFromBottom(metrics) <= BOTTOM_FOLLOW_EPSILON_PX
}
export function hasReachedBottom(metrics: ScrollControllerMetrics) {
@ -472,8 +471,8 @@ export class VirtualScrollController {
return this.result(next.effect)
}
jumpKey(key: string, block: ScrollLogicalPosition, smooth: boolean): ScrollControllerResult {
const next = transitionFollowMode(this.state.mode, { type: "jump-key", key, block, smooth })
jumpKey(key: string): ScrollControllerResult {
const next = transitionFollowMode(this.state.mode, { type: "jump-key", key })
this.state.mode = next.mode
return this.result(next.effect)
}
@ -517,7 +516,7 @@ export class VirtualScrollController {
return this.result(next.effect)
}
contentRendered(_metrics: ScrollControllerMetrics, canPinToBottom: boolean): ScrollControllerResult {
contentRendered(canPinToBottom: boolean): ScrollControllerResult {
if (this.state.restoring) return this.result(noFollowEffect)
const next = transitionFollowMode(this.state.mode, { type: "content-grew", canPinToBottom })
this.state.mode = next.mode

File diff suppressed because it is too large Load diff

View file

@ -2,47 +2,10 @@ import assert from "node:assert/strict"
import { test } from "node:test"
import { createVirtualReaderSettlement } from "./virtual-reader-settlement.ts"
test("reader settlement sleeps between layouts and cannot resurrect cancelled ownership", () => {
test("a hold aligns at once, then on every frame of a window that nothing extends, then releases", () => {
const previousFrame = globalThis.requestAnimationFrame, previousCancel = globalThis.cancelAnimationFrame
const frames = new Map<number, FrameRequestCallback>()
let sequence = 0, enabled = true, aligned = 0
globalThis.requestAnimationFrame = fn => { frames.set(++sequence, fn); return sequence }
globalThis.cancelAnimationFrame = id => { frames.delete(id) }
const drain = () => {
let count = 0
while (frames.size) {
assert.ok(++count <= 12, "Each layout notification schedules bounded work")
const pending = [...frames.values()]; frames.clear()
pending.forEach(fn => fn(0))
}
}
try {
const reader = createVirtualReaderSettlement({
enabled: () => enabled,
getAnchor: () => ({ key: "reader", offset: -24 }),
align: anchor => { assert.deepEqual(anchor, { key: "reader", offset: -24 }); aligned++ },
})
const cancelled = reader.capture()
reader.cancel()
reader.settle(cancelled)
assert.equal(frames.size, 0, "A gesture before the deferred roll cannot be undone")
reader.settle(reader.capture())
drain(); assert.equal(aligned, 12)
assert.equal(frames.size, 0, "An idle retained reader does not keep running frames")
reader.notify(); drain(); assert.equal(aligned, 24, "Late layout changes retain the same reader")
reader.notify(); reader.cancel(); drain(); assert.equal(aligned, 24)
reader.notify(); assert.equal(frames.size, 0)
reader.settle(reader.capture()); enabled = false; drain(); assert.equal(aligned, 24)
} finally {
globalThis.requestAnimationFrame = previousFrame
globalThis.cancelAnimationFrame = previousCancel
}
})
test("an element hold aligns at once, then on every frame of a window layout never extends", () => {
const previousFrame = globalThis.requestAnimationFrame, previousCancel = globalThis.cancelAnimationFrame
const frames = new Map<number, FrameRequestCallback>()
let sequence = 0, aligned = 0, held = true
let sequence = 0, aligned = 0, held = true, enabled = true
globalThis.requestAnimationFrame = fn => { frames.set(++sequence, fn); return sequence }
globalThis.cancelAnimationFrame = id => { frames.delete(id) }
/** Runs `count` animation frames; returns how many ran before the queue emptied. */
@ -57,31 +20,33 @@ test("an element hold aligns at once, then on every frame of a window layout nev
}
try {
const reader = createVirtualReaderSettlement({
enabled: () => true,
getAnchor: () => ({ key: "visible", offset: 0 }),
enabled: () => enabled,
align: () => { aligned++; return held },
})
const element = {} as HTMLElement
reader.hold({ key: "revealed", offset: 8, element })
assert.equal(aligned, 1, "The first correction is synchronous")
step(5); assert.equal(aligned, 6, "Each frame of the window re-measures the element")
assert.equal(reader.capture().anchor?.key, "revealed", "A window shift retains the held anchor")
step(15); reader.notify()
// 1 + 20 frames so far: the window ends after 30, whatever layout or scrolling does.
assert.equal(step(100), 9, "Layout notifications never extend the window")
assert.equal(reader.held()?.key, "revealed")
// 1 + 5 frames so far: the window ends after 30.
assert.equal(step(100), 24)
assert.equal(aligned, 30)
assert.equal(reader.held(), undefined, "The hold ends with its window")
reader.notify(); assert.equal(frames.size, 0)
assert.equal(frames.size, 0, "A released anchor keeps no frame running")
// A jump's row is held the same way.
reader.hold({ key: "jumped", offset: 0 })
assert.equal(step(100), 29)
assert.equal(reader.held(), undefined, "A row hold ends with its window")
reader.hold({ key: "revealed", offset: 8, element })
held = false
reader.notify(); step(1)
step(1)
assert.equal(reader.held(), undefined, "A lost element releases the hold")
assert.equal(reader.capture().anchor?.key, "visible", "Then the visible reader is captured again")
held = true
reader.settle(reader.capture()); step(100)
const before = aligned
reader.notify(); assert.equal(frames.size, 1, "A retained reader re-arms on layout or scroll"); step(100)
assert.equal(aligned, before + 12)
reader.hold({ key: "jumped", offset: 0 })
reader.cancel(); assert.equal(frames.size, 0, "A gesture cancels the hold")
reader.hold({ key: "jumped", offset: 0 })
enabled = false; step(1)
assert.equal(reader.held(), undefined, "A disabled list releases the hold")
} finally {
globalThis.requestAnimationFrame = previousFrame
globalThis.cancelAnimationFrame = previousCancel

View file

@ -1,29 +1,25 @@
/** `element` holds a descendant of the keyed row (an in-place reveal) instead of the row itself. */
interface VirtualItemAnchor { key: string; offset: number; element?: HTMLElement }
const SETTLE_FRAMES = 12
// An in-place reveal is held for the former element-alignment window, counted
// from the alignment request: later layout never extends it.
// A hold lasts the former element-alignment window, counted from its request:
// later layout never extends it.
const HOLD_FRAMES = 30
/** A retained reader owns its anchor until a gesture/navigation changes it.
* Only layout notifications schedule its work; an idle reader has no running RAF.
* A held element (`hold`) is measured and re-aligned on every frame of its fixed
* window, like the former element alignment: layout lands after animation frames,
* so waiting for its notification would paint the displaced frame first. `align`
* returns false when an anchor can no longer be held.
/** A held anchor (a jump's row or a revealed element) is measured and
* re-aligned on every frame of its fixed window, like the former element
* alignment: layout lands after animation
* frames, so waiting for a layout notification would paint the displaced frame
* first. It is then released, so a later focus or reveal is never pulled back.
* `align` returns false when an anchor can no longer be held.
*/
export function createVirtualReaderSettlement(options: {
getAnchor: () => VirtualItemAnchor | undefined
align: (anchor: VirtualItemAnchor) => boolean | void
enabled: () => boolean
}) {
let anchor: VirtualItemAnchor | undefined
let frame: number | undefined
let remaining = 0
let generation = 0
const cancel = () => {
generation += 1
if (frame !== undefined) cancelAnimationFrame(frame)
frame = undefined
anchor = undefined
@ -32,25 +28,12 @@ export function createVirtualReaderSettlement(options: {
frame = undefined
if (!anchor || !options.enabled() || options.align(anchor) === false) return cancel()
if (--remaining > 0) frame = requestAnimationFrame(tick)
else if (anchor?.element) cancel()
}
/** Layout changed or a scroll was observed: re-arms a retained reader. A held element's window is fixed. */
const notify = () => {
if (!anchor || anchor.element) return
remaining = SETTLE_FRAMES
if (frame === undefined) frame = requestAnimationFrame(tick)
else cancel()
}
return {
cancel,
notify,
/** The held element anchor, if any. */
held: () => anchor?.element ? anchor : undefined,
capture: () => ({ anchor: anchor ?? options.getAnchor(), generation }),
settle: (next: { anchor: VirtualItemAnchor | undefined; generation: number } | undefined) => {
if (!next?.anchor || next.generation !== generation) return
anchor = next.anchor
notify()
},
/** The held anchor, if any. */
held: () => anchor,
/** Takes ownership of `next` at once: aligns it now, then on every frame of its window. */
hold: (next: VirtualItemAnchor) => {
cancel()

View file

@ -9,14 +9,12 @@ export const SCROLL_REASONS = [
"jump-top", "jump-bottom", "jump-key", "explicit-pin",
// Follow mode re-pinning after growth, or after an off-bottom observation.
"content-grew", "observe",
// Snapshot restoration: anchor index/correction, pixel fallback, final mode.
// Snapshot restoration: anchor row/correction, pixel fallback, final mode.
"restore-anchor", "restore-pixel", "restore-finish",
// Escaped-reader retention and in-place reveals.
// Escaped-reader retention (including a jump's landing) and in-place reveals.
"reader-settle", "align-element", "hold",
// Layout compensation.
"before-items", "viewport-resize",
// Releases of Virtua's pending imperative/shift operation (scrollBy(0)).
"cancel-virtua", "window-shift",
// Mode-only sources.
"user-gesture", "thumb-drag", "set-follow", "reset",
// External writer retained to preserve occurrence-level reveal behavior.

View file

@ -1,5 +1,5 @@
import { createEffect, createSignal, onCleanup, type Accessor, type JSXElement } from "solid-js"
import { BOTTOM_FOLLOW_EPSILON_PX, SCROLL_INTENT_KEYS, USER_SCROLL_INTENT_WINDOW_MS, VirtualScrollController, describeKeyTarget, getKeyboardScrollIntent, isAutoFollowing, isMiddleButtonScrollIntent, type ScrollControllerMetrics } from "../components/virtual-follow-behavior"
import { SCROLL_INTENT_KEYS, USER_SCROLL_INTENT_WINDOW_MS, VirtualScrollController, describeKeyTarget, getKeyboardScrollIntent, isAutoFollowing, isMiddleButtonScrollIntent, type ScrollControllerMetrics } from "../components/virtual-follow-behavior"
import { getElementZoom, wheelDeltaUnit } from "./scroll-gesture-routing"
interface FollowScrollOptions {
@ -135,7 +135,6 @@ export function createFollowScroll(options: FollowScrollOptions): FollowScrollHe
offset: container.scrollTop,
scrollHeight: container.scrollHeight,
clientHeight: container.clientHeight,
sentinelMarginPx: BOTTOM_FOLLOW_EPSILON_PX,
}
}

View file

@ -27,9 +27,13 @@ test("height changes coalesce into one per-frame delta and zero heights only reb
element.clientHeight = 650; tracker.update(); flush()
assert.deepEqual(deltas, [80, -50])
element.clientHeight = 600; tracker.update()
element.clientHeight = 550; tracker.update(false); flush()
assert.deepEqual(deltas, [80, -50, 0], "An unanchored change drops the pending delta and only rebases")
element.clientHeight = 600; tracker.update(); tracker.dispose(); flush()
element.clientHeight = 500; tracker.update(); flush()
assert.deepEqual(deltas, [80, -50], "Disposal drops the pending delta and ignores later changes")
assert.deepEqual(deltas, [80, -50, 0], "Disposal drops the pending delta and ignores later changes")
assert.equal(frames.size, 0)
} finally {
globalThis.requestAnimationFrame = previousFrame

View file

@ -9,11 +9,15 @@ export function trackHeightDelta(element: HTMLElement, onDelta: (delta: number)
let frame: number | null = null
let disposed = false
return {
/** Call from the owner's ResizeObserver. */
update() {
/**
* Call from the owner's ResizeObserver. A change while `anchored` is false
* only rebases and drops the pending delta.
*/
update(anchored = true) {
const next = element.clientHeight
if (disposed || next === previous) return
if (previous > 0 && next > 0) {
if (!anchored) pending = 0
else if (previous > 0 && next > 0) {
pending += previous - next
frame ??= requestAnimationFrame(() => {
frame = null

View file

@ -6,7 +6,8 @@ export const POINTER_CONTROL_SELECTOR = "a[href],button,input,textarea,select,[c
const WHEEL_GESTURE_IDLE_MS = 180
const MIDDLE_DEAD_ZONE_PX = 8
function scrollAxes(node: HTMLElement) {
/** The axes along which `node` is a scroll container with overflowing content. */
export function scrollAxes(node: HTMLElement) {
const style = getComputedStyle(node)
const scrolls = (overflow: string) => /^(auto|scroll|overlay)$/.test(overflow)
return {
@ -51,24 +52,26 @@ function middleTargets(root: HTMLElement, target: EventTarget | null) {
}
/** Keep an in-progress gesture on its starting scroller, not whatever row
* virtualization or pointer movement happens to place under the cursor next. */
export function attachScrollGestureRouting(root: HTMLElement): () => void {
* virtualization or pointer movement happens to place under the cursor next.
* `onRootWheel` receives each wheel event that `root` itself scrolls, so its
* owner needs no second nested-scroller walk. */
export function attachScrollGestureRouting(root: HTMLElement, onRootWheel?: (event: WheelEvent) => void): () => void {
let wheelOwner: HTMLElement | undefined
let wheelAt = -Infinity
let middle: { owners: ReturnType<typeof middleTargets>; pointerId: number; x: number; y: number; dx: number; dy: number; time: number } | undefined
let frame: number | undefined
const wheel = (event: WheelEvent) => {
// Redispatched intent below is only for the existing follow controllers.
if (!event.isTrusted) return
// Leave zoom, native horizontal/Shift-wheel and uncancellable input alone.
if (event.ctrlKey || event.shiftKey || event.deltaX !== 0 || !event.deltaY || !event.cancelable || event.defaultPrevented) {
wheelOwner = undefined
const direction = event.deltaY < 0 ? "up" : event.deltaY > 0 ? "down" : null
const target = direction ? findNestedScroller(root, event.target, direction) ?? root : root
// Leave zoom, native horizontal/Shift-wheel, uncancellable input and
// synthetic intent (including the redispatch below, already routed) alone.
if (!event.isTrusted || !direction || event.ctrlKey || event.shiftKey || event.deltaX !== 0 || !event.cancelable || event.defaultPrevented) {
if (event.isTrusted) wheelOwner = undefined
if (target === root) onRootWheel?.(event)
return
}
const now = performance.now()
const direction = event.deltaY < 0 ? "up" : "down"
const target = findNestedScroller(root, event.target, direction) ?? root
// At an edge, chain only to an ancestor of the owner, never a new shell
// passing under the cursor during the same gesture.
wheelOwner = !wheelOwner?.isConnected || !root.contains(wheelOwner) || now - wheelAt > WHEEL_GESTURE_IDLE_MS
@ -76,7 +79,11 @@ export function attachScrollGestureRouting(root: HTMLElement): () => void {
: findNestedScroller(root, wheelOwner, direction) ?? root
wheelAt = now
const owner = wheelOwner
if (owner === target) return
if (owner === target) {
if (owner === root) onRootWheel?.(event)
return
}
// The redispatch reports a transcript owner itself.
event.preventDefault()
event.stopPropagation()
owner.dispatchEvent(new WheelEvent("wheel", { deltaX: event.deltaX, deltaY: event.deltaY, deltaMode: event.deltaMode, bubbles: true }))

View file

@ -24,6 +24,8 @@ const makeMessage = location.search.includes("mixed") ? mixedNavigationMessage
const model = { providerID: "fixture", id: "fixture" }
const assistantId = "msg_streaming"
let nativeLists = 0, time = 10000, live = "", streaming = false
let steps = 0
const prompts: string[] = []
let listHold: Promise<void> | undefined, releaseListHold: (() => void) | undefined, listPending = false
let releaseMessages!: () => void
const messagesGate = new Promise<void>(resolve => { releaseMessages = resolve })
@ -39,6 +41,8 @@ const client: any = {
session: { active: async () => ({}), inbox: { list: async () => ({ data: [] }) },
get: async () => ({ id: sessionId, title: "Long navigation", location: { directory: "/fixture" }, time: { created: 1, updated: 1 } }),
instructions: { entry: { remove: async () => {}, put: async () => {} } },
switchAgent: async () => ({}), switchModel: async () => ({}),
prompt: async ({ id }: any) => { prompts.push(id); return {} },
},
model: { default: async () => ({ data: model }) },
message: { list: async ({ cursor, limit = 200, order }: any = {}) => {
@ -133,6 +137,25 @@ render(() => <ConfigProvider><I18nProvider><ThemeProvider><Show when={visible()}
live += delta
emit("session.text.delta", { assistantMessageID: assistantId, ordinal: 0, delta })
},
/**
* Runs `count` agent steps, each a new assistant message rolling the oldest
* one out of the 200-message window. A `text` step answers before it ends;
* an `empty` step ends at once, which hides its message.
*/
steps: (count: number, kind: "text" | "empty" = "text") => {
for (let index = 0; index < count; index++) {
const assistantMessageID = `msg_step_${String(++steps).padStart(4, "0")}`
emit("session.step.started", { assistantMessageID, agent: "build", model })
if (kind === "text") {
const text = `Step ${steps}\n\nA short agent step.`
emit("session.text.started", { assistantMessageID })
emit("session.text.delta", { assistantMessageID, ordinal: 0, delta: text })
emit("session.text.ended", { assistantMessageID, ordinal: 0, text })
}
emit("session.step.ended", { assistantMessageID, finish: "stop" })
}
},
prompts: () => prompts,
snapshot: () => ({ nativeLists, ids: store.getSessionMessageIds(sessionId), window: store.getMessageWindow(sessionId),
scroll: store.getScrollSnapshot(sessionId, "message-stream"), model: sessions().get(instanceId)?.get(sessionId)?.model }),
}

View file

@ -3,9 +3,19 @@ import { missionStore } from "../../../src/stores/missions"
import { updateMissionProjectView } from "../../../src/stores/mission-view-state"
import { showSessionChatFor } from "../../../src/stores/session-previews"
import { openFilePreview } from "../../../src/stores/files-preview"
import { sseManager } from "../../../src/lib/sse-manager"
Object.assign((window as any).fixture, {
const fixture = (window as any).fixture
Object.assign(fixture, {
// A running execution with an open text part, as message-scroll-switch.tsx starts it.
startStream: () => {
;(sseManager as any).handleEvent("browser-instance", { id: `mission-ev_${Date.now()}`, type: "session.execution.started", created: Date.now(),
location: { directory: "/fixture" }, data: { sessionID: "browser-session" } })
fixture.start()
},
readFile: () => openFilePreview("browser-instance", { kind: "workspace", sessionId: "browser-session", slug: "fixture", directory: "/fixture", path: "README.md" }),
// Loads the Mission map, so a later readMission opens its reader synchronously.
loadMissions: () => missionStore.ensure("browser-instance"),
readMission: async () => {
await missionStore.ensure("browser-instance")
showSessionChatFor("browser-session", "/fixture")

View file

@ -12,10 +12,12 @@ const [tailHeight, setTailHeight] = createSignal(48)
const [headHeight, setHeadHeight] = createSignal(48)
const [rowHeights, setRowHeights] = createSignal<Record<string, number>>({})
const [pinIntent, setPinIntent] = createSignal<VirtualExplicitBottomPinIntent | null>(null)
const [beforeHeight, setBeforeHeight] = createSignal(0)
let api: VirtualFollowListApi | undefined
let snapshot: VirtualFollowScrollSnapshot | undefined
let reachedTop = 0
let pinCancelled = 0
let steps = 0
const rowHeight = (item: string) => rowHeights()[item] ?? (item === "streaming-tail" ? tailHeight() : item === "row-1" ? headHeight() : 48)
render(() => <Show when={visible()}><VirtualFollowList items={items} getKey={item => item}
initialAutoScroll={() => false} isActive={active}
@ -26,12 +28,13 @@ render(() => <Show when={visible()}><VirtualFollowList items={items} getKey={ite
if (snapshot) api.restoreScrollSnapshot(snapshot)
}}
renderItem={item => <div style={{ height: `${rowHeight(item)}px` }}>{item}</div>}
renderBeforeItems={() => <Show when={beforeHeight()}><div style={{ height: `${beforeHeight()}px` }} /></Show>}
/></Show>, document.getElementById("root")!)
;(window as any).fixture = {
bottom: () => api?.scrollToBottom({ immediate: true }),
follow: () => { api?.scrollToBottom({ immediate: true }); api?.setAutoScroll(true) },
middle: () => api?.scrollToKey("row-100", { block: "start" }),
nearTail: () => api?.scrollToKey("row-180", { block: "start" }),
middle: () => api?.scrollToKey("row-100"),
nearTail: () => api?.scrollToKey("row-180"),
align: (key = "row-180", offset = 8) => {
const row = api?.getScrollElement()?.querySelector<HTMLElement>(`[data-virtual-follow-key="${key}"]`)
if (row) api?.alignElement(row, offset)
@ -41,8 +44,22 @@ render(() => <Show when={visible()}><VirtualFollowList items={items} getKey={ite
// Metadata/inbox reprojection may publish the same IDs before settlement.
queueMicrotask(() => setItems(current => current.slice()))
},
// An agent step: a new reply joins the tail, the oldest row leaves the window.
step: () => {
const key = `step-${++steps}`
setItems(current => [...current.slice(1), key])
return key
},
// An empty agent step: its reply is hidden again once the step ends, a second
// (non-rolling) window change right after the roll.
emptyStep: () => {
const key = (window as any).fixture.step()
queueMicrotask(() => setItems(current => current.filter(item => item !== key)))
},
growTail: setTailHeight,
resizeHead: setHeadHeight,
// Content above the rows (SessionView's header and older-history controls).
resizeBefore: setBeforeHeight,
resizeRow: (key: string, height: number) => setRowHeights(current => ({ ...current, [key]: height })),
reorder: () => {
setItems(current => [current[1], current[0], ...current.slice(2), "prompt"])
@ -54,6 +71,8 @@ render(() => <Show when={visible()}><VirtualFollowList items={items} getKey={ite
itemCount: () => items().length,
switchAway: () => { snapshot = api?.captureScrollSnapshot(); setVisible(false) },
return: () => setVisible(true),
// Hides the mounted list (display:none), unlike switchAway which unmounts it.
setHidden: (hidden: boolean) => { document.getElementById("root")!.style.display = hidden ? "none" : "flex" },
deactivateDuringRestore: (target: VirtualFollowScrollSnapshot) => { api?.restoreScrollSnapshot(target); setActive(false) },
activate: () => setActive(true),
autoScroll: () => api?.getAutoScroll(),

View file

@ -0,0 +1,30 @@
import { createComponent, mergeProps } from "solid-js"
// Aliased by message-scroll.test.ts to Virtua's own Solid build, so importing
// it here never loops back onto the `virtua/solid` alias of this module.
import { Virtualizer as NativeVirtualizer, type VirtualizerHandle, type VirtualizerProps } from "virtua-solid-native"
export * from "virtua-solid-native"
const COUNTED = ["scrollTo", "scrollBy", "scrollToIndex"] as const
/** Virtua's Virtualizer, recording the scroll operations callers issue through
* its handle in `window.virtuaHandleCalls`. Virtua's own edge compensation
* writes the DOM directly and is not recorded. Otherwise transparent. */
export function Virtualizer<T>(props: VirtualizerProps<T>) {
return createComponent(NativeVirtualizer, mergeProps(props, {
ref: (handle?: VirtualizerHandle) => {
const w = window as any
if (handle) {
w.virtuaHandles = (w.virtuaHandles ?? 0) + 1
for (const method of COUNTED) {
const native = handle[method] as (...args: unknown[]) => void
;(handle as any)[method] = (...args: unknown[]) => {
;(w.virtuaHandleCalls ??= []).push({ method, args })
return native(...args)
}
}
}
props.ref?.(handle as VirtualizerHandle)
},
}))
}

View file

@ -455,6 +455,219 @@ for (const saved of ["passage", "latest bottom", "none"] as const) test(`a jump
} finally { await f.close() }
})
// A send pins the latest bottom itself: sent before hydration, it supersedes the
// activation restore still pending for a saved passage, which must not run later.
test("a message sent before hydration stays at the bottom over a saved passage", async () => {
const f = await fixture(false, false, true)
try {
await seedSavedPosition(f.page, navigationMessageId(1450))
await f.page.locator(".prompt-input-container textarea").first().fill("Sent before hydration")
await f.page.locator(".send-button").click()
await f.page.waitForFunction(() => (window as any).fixture.prompts().length === 1)
await f.page.evaluate(() => (window as any).fixture.releaseMessages())
await f.page.waitForFunction(() => (window as any).fixture.snapshot().ids.includes("msg_01499"))
await waitFrames(f.page, 60)
const sentId = await f.page.evaluate(() => (window as any).fixture.prompts()[0])
const state = await f.page.evaluate(`(() => {
const stream = document.querySelector('.message-stream'), bounds = stream.getBoundingClientRect()
const row = stream.querySelector('[data-virtual-follow-key="${sentId}"]')?.getBoundingClientRect()
return { row: row ? { top: row.top - bounds.top, bottom: row.bottom - bounds.top } : null, viewport: stream.clientHeight,
atBottom: stream.scrollHeight - stream.clientHeight - stream.scrollTop <= 1, anchorKey: window.fixture.snapshot().scroll?.anchorKey }
})()`) as { row: { top: number; bottom: number } | null; viewport: number; atBottom: boolean; anchorKey?: string }
assert.ok(state.row && state.row.top >= 0 && state.row.bottom <= state.viewport + 1 && state.atBottom,
`the sent message must stay visible at the bottom: ${JSON.stringify(state)}`)
assert.deepEqual(f.errors, [])
} finally { await f.close() }
})
const bottomState = (page: Page) => page.evaluate(`(() => {
const stream = document.querySelector('.message-stream')
return { mode: stream.getAttribute('data-follow-mode'), gap: stream.scrollHeight - stream.clientHeight - stream.scrollTop }
})()`) as Promise<{ mode: string | null; gap: number }>
// A send pins the bottom until its exchange settles. The click projects the
// sent row synchronously, after the pin's first write; a switch before the
// pin's next frame must still capture following at the bottom, not escaped.
test("switching sessions right after a send returns following at the bottom", async () => {
const f = await fixture()
try {
await waitFrames(f.page, 30)
assert.deepEqual(await bottomState(f.page), { mode: "following", gap: 0 }, "the hydrated session starts following at the bottom")
await f.page.locator(".prompt-input-container textarea").first().fill("Sent before switching")
const switched = await f.page.evaluate(`(() => {
document.querySelector('.send-button').click()
const stream = document.querySelector('.message-stream')
const state = { gap: stream.scrollHeight - stream.clientHeight - stream.scrollTop,
sent: window.fixture.snapshot().ids.filter(id => !/^msg_\\d{5}$/.test(id)).length }
window.fixture.switchAway()
return state
})()`) as { gap: number; sent: number }
assert.equal(switched.sent, 1, `the send (and its pin) must be under way at the switch: ${JSON.stringify(switched)}`)
await f.page.evaluate(() => (window as any).fixture.return())
await f.page.waitForFunction(() => document.querySelector(".message-stream [data-virtual-follow-key]"))
await waitFrames(f.page, 60)
const returned = await bottomState(f.page)
assert.ok(returned.mode === "following" && returned.gap <= 1,
`the returned session must follow at the bottom: ${JSON.stringify({ switched, returned, scroll: (await snapshot(f.page)).scroll })}`)
await f.page.evaluate(() => (window as any).fixture.stream("A reply that grows after the return. ".repeat(80)))
await waitFrames(f.page, 30)
const grown = await bottomState(f.page)
assert.ok(grown.mode === "following" && grown.gap <= 1, `later growth must be followed: ${JSON.stringify(grown)}`)
assert.deepEqual(f.errors, [])
} finally { await f.close() }
})
/** Samples a row's viewport offset on each frame from now, while `gesture` runs. */
async function sampleRowOffsets(page: Page, index: number, frames: number, gesture: () => Promise<void>) {
await page.evaluate(`(() => {
const stream = document.querySelector('.message-stream'), samples = window.rowSamples = []
let count = ${frames}
const next = () => {
const row = stream.querySelector('[data-virtual-follow-key="${navigationMessageId(index)}"]')
samples.push(row ? row.getBoundingClientRect().top - stream.getBoundingClientRect().top : null)
if (--count) requestAnimationFrame(next)
}
requestAnimationFrame(next)
})()`)
await gesture()
await page.waitForFunction(frames => (window as any).rowSamples.length >= frames, frames)
return page.evaluate(() => (window as any).rowSamples) as Promise<Array<number | null>>
}
/**
* Records every `data-scroll-write` value of the transcript from now on into
* `window.scrollWrites`, so a short-lived write reason cannot be missed.
*/
const recordScrollWrites = (page: Page) => page.evaluate(() => {
const element = document.querySelector(".message-stream")!, seen: Array<string | null> = (window as any).scrollWrites = []
new MutationObserver(records => {
// Each record's new value is the next one's old value, the last is current.
for (const [index, record] of records.entries()) if (index) seen.push(record.oldValue)
seen.push(element.getAttribute("data-scroll-write"))
}).observe(element, { attributeFilter: ["data-scroll-write"], attributeOldValue: true })
})
const sawScrollWrite = (page: Page, reason: string) =>
page.waitForFunction(reason => (window as any).scrollWrites.includes(reason), reason)
// A resident jump writes its row's offset to the DOM, then holds that row while
// rows keep mounting. A gesture right after the click, before the jump settles,
// must win at once and for good.
for (const gesture of ["wheel", "PageUp"] as const) test(`a ${gesture} right after a resident timeline jump wins over the jump`, async () => {
const f = await fixture()
try {
await waitFrames(f.page, 30)
await f.page.evaluate(() => (window as any).codenomadLogger.enableLogger("scroll"))
const stream = f.page.locator(".message-stream[tabindex]")
const box = (await stream.boundingBox())!
const amplitude = gesture === "wheel" ? 300 : await stream.evaluate(element => element.clientHeight * 0.875)
await recordScrollWrites(f.page)
await clickTimeline(f.page, 1350)
// The click has written its jump; its landing has not settled.
await sawScrollWrite(f.page, "jump-key")
await waitFrames(f.page, 2)
const samples = await sampleRowOffsets(f.page, 1350, 90, async () => {
if (gesture === "wheel") {
await f.page.mouse.move(box.x + 60, box.y + box.height / 2)
await f.page.mouse.wheel(0, -300)
} else {
await stream.evaluate(element => element.focus({ preventScroll: true }))
await f.page.keyboard.press("PageUp")
}
})
// The jump's target puts the row at the top (offset 0); the gesture moves it down.
const won = samples.findIndex(offset => offset !== null && offset >= amplitude / 2)
const detail = JSON.stringify({ amplitude, samples: samples.map(offset => offset === null ? null : Math.round(offset)) })
assert.ok(won >= 0 && won < 30, `the ${gesture} must win within 30 frames: ${detail}`)
const reverted = samples.slice(won).findIndex(offset => offset !== null && offset < amplitude / 4)
assert.equal(reverted, -1, `the jump must not reclaim its target after the ${gesture}: ${detail}`)
await waitFrames(f.page, 30)
const settled = await rowOffset(f.page, 1350)
assert.ok(settled === null || settled >= amplitude / 2, `the ${gesture} must hold: ${settled} ${detail}`)
assert.deepEqual(f.errors, [])
} finally { await f.close() }
})
/** Grows the streaming reply by a paragraph on every frame until `window.tailStreaming` is cleared. */
const streamTail = (page: Page) => page.evaluate(`(() => {
window.tailStreaming = true
const next = () => {
if (!window.tailStreaming) return
window.fixture.stream("\\n\\nA streamed paragraph that grows the live reply.")
requestAnimationFrame(next)
}
requestAnimationFrame(next)
})()`)
// During streaming a Virtua operation would live on every measurement and
// reassert its target over a wheel; a resident jump's landing must leave none,
// so a wheel right after it wins for good while the tail keeps growing.
test("a wheel right after a resident timeline jump during streaming wins for good", async () => {
const f = await fixture()
try {
await waitFrames(f.page, 30)
await f.page.evaluate(() => (window as any).codenomadLogger.enableLogger("scroll"))
await f.page.evaluate(() => (window as any).fixture.status("working"))
await streamTail(f.page)
await waitFrames(f.page, 30)
const rail = f.page.locator(".message-timeline")
await rail.hover()
await rail.evaluate(element => { element.scrollTop = element.scrollHeight * 1350 / 1500 })
await f.page.locator(`.message-timeline-segment[data-message-id="${navigationMessageId(1350)}"]`).waitFor()
const box = (await f.page.locator(".message-stream[tabindex]").boundingBox())!
await f.page.mouse.move(box.x + 60, box.y + box.height / 2)
await recordScrollWrites(f.page)
// Pause the stream 100 ms before the jump, so the jump's write outlives the
// last measurement by that much.
await f.page.evaluate(`new Promise(resolve => {
window.tailStreaming = false
setTimeout(() => { document.querySelector('.message-timeline-segment[data-message-id="${navigationMessageId(1350)}"]').click(); resolve() }, 100)
})`)
await sawScrollWrite(f.page, "jump-key")
// The wheel comes more than 150 ms after that measurement, within 150 ms of
// the jump; the stream resumes at once.
await f.page.waitForTimeout(80)
const samples = await sampleRowOffsets(f.page, 1350, 90, async () => {
await f.page.mouse.wheel(0, -300)
await streamTail(f.page)
})
const detail = JSON.stringify({ samples: samples.map(offset => offset === null ? null : Math.round(offset)) })
const won = samples.findIndex(offset => offset !== null && offset >= 150)
assert.ok(won >= 0 && won < 30, `the wheel must win within 30 frames: ${detail}`)
const reverted = samples.slice(won).findIndex(offset => offset !== null && offset < 150)
assert.equal(reverted, -1, `the stream must not restore the jump's offset after the wheel: ${detail}`)
assert.ok((await snapshot(f.page)).ids.includes("msg_streaming"), "the reply streamed throughout")
assert.deepEqual(f.errors, [])
} finally {
await f.page.evaluate(() => { (window as any).tailStreaming = false }).catch(() => {})
await f.close()
}
})
// A settled jump leaves the reader in charge: a later programmatic scroll or
// a focus move (keyboard, assistive technology) must keep its new position.
for (const move of ["scrollIntoView", "focus"] as const) test(`a ${move} after a settled resident timeline jump keeps its position`, async () => {
const f = await fixture()
try {
await clickTimeline(f.page, 1350)
await assertPassageAtTop(f.page, 1350)
await waitFrames(f.page, 90)
// Row 1356 lies just below the viewport, mounted in the overscan.
const moved = await f.page.evaluate(`(() => {
const stream = document.querySelector('.message-stream'), row = stream.querySelector('[data-virtual-follow-key="${navigationMessageId(1356)}"]')
const before = stream.scrollTop
if (${JSON.stringify(move)} === 'scrollIntoView') row.scrollIntoView()
else row.querySelector('button').focus()
return { delta: stream.scrollTop - before, offset: row.getBoundingClientRect().top - stream.getBoundingClientRect().top }
})()`) as { delta: number; offset: number }
assert.ok(moved.delta > 0, `the ${move} must scroll the transcript: ${JSON.stringify(moved)}`)
await waitFrames(f.page, 60)
const offsets = { moved, row1356: await rowOffset(f.page, 1356), row1350: await rowOffset(f.page, 1350) }
assert.ok(offsets.row1356 !== null && Math.abs(offsets.row1356 - moved.offset) < 2,
`the jump's anchor must not pull back the ${move}: ${JSON.stringify(offsets)}`)
assert.deepEqual(f.errors, [])
} finally { await f.close() }
})
// A restore loading its saved anchor positions the list itself: reaching the
// bottom meanwhile must not page beyond the window that restore brings in.
test("reaching the bottom while a restore loads its anchor does not page newer", async () => {
@ -1115,3 +1328,167 @@ test("current-session search clicks and keyboard next use direct distant windows
assert.deepEqual(f.errors, [])
} finally { await f.close() }
})
/** Runs `count` agent steps, one per frame, each rolling the window's oldest message out. */
async function rollSteps(page: Page, count: number, kind: "text" | "empty" = "text") {
const first = (await snapshot(page)).ids[0] as string
await page.evaluate(`new Promise(resolve => {
let count = ${count}
const next = () => {
window.fixture.steps(1, ${JSON.stringify(kind)})
if (--count) requestAnimationFrame(next); else resolve()
}
requestAnimationFrame(next)
})`)
const rolled = navigationMessageId(Number(first.slice(4)) + count)
await page.waitForFunction(id => (window as any).fixture.snapshot().ids[0] === id, rolled)
}
/** `clickTimeline` for a rail that has grown past 1500 segments. */
async function clickGrownTimeline(page: Page, index: number) {
const rail = page.locator(".message-timeline")
await rail.hover()
await rail.evaluate((element, index) => {
element.scrollTop = element.scrollHeight * index / Number(element.getAttribute("data-segment-count"))
}, index)
await page.locator(`.message-timeline-segment[data-message-id="${navigationMessageId(index)}"]`).click()
}
/** Waits (bounded) for `index` to reach the top and asserts it stays there once settled. */
async function assertLandsAtTop(page: Page, index: number) {
const reached = 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), { timeout: 5000 }).then(() => true, () => false)
const detail = async () => JSON.stringify({ offset: await rowOffset(page, index), resident: (await snapshot(page)).ids.includes(navigationMessageId(index)),
scrollTop: await page.locator(".message-stream").evaluate(element => element.scrollTop) })
assert.ok(reached, `passage ${index} never reached the top: ${await detail()}`)
await waitFrames(page, 30)
const offset = await rowOffset(page, index)
assert.ok(offset !== null && Math.abs(offset) <= 2, `passage ${index} did not stay at the top: ${await detail()}`)
}
// Messages rolled out of the window's head stay in the list as retired rows
// measured at zero. A jump bringing one back must measure it again, not reuse
// that zero for the live row. 1345 is still resident: the control.
for (const index of [1305, 1335, 1345]) test(`a timeline jump to a message rolled out of the window lands (${index})`, async () => {
const f = await fixture()
try {
await waitFrames(f.page, 30)
await rollSteps(f.page, 40)
await waitFrames(f.page, 30)
await clickGrownTimeline(f.page, index)
await f.page.waitForFunction(id => (window as any).fixture.snapshot().ids.includes(id), navigationMessageId(index))
await assertLandsAtTop(f.page, index)
assert.deepEqual(f.errors, [])
} finally { await f.close() }
})
test("a search result for a message rolled out of the window lands", async () => {
const f = await fixture()
try {
await f.page.route("**/session-history/query", route => route.fulfill({ contentType: "application/json", body: JSON.stringify({
status: "page", scanned: 1500, tools: 0, reasoning: 0, skipped: 0, candidates: [], cursor: null,
hits: [{ sessionID: "s", messageID: navigationMessageId(1305), role: "user", partIndex: 0, kind: "text", excerpt: "Passage 1305" }],
}) }))
await waitFrames(f.page, 30)
await rollSteps(f.page, 40)
await waitFrames(f.page, 30)
await f.page.evaluate(() => (window as any).fixture.openSearch())
await f.page.getByRole("searchbox").fill("Passage")
await f.page.locator(".history-search-result").first().click()
await f.page.waitForFunction(id => (window as any).fixture.snapshot().ids.includes(id), navigationMessageId(1305))
await assertLandsAtTop(f.page, 1305)
assert.deepEqual(f.errors, [])
} finally { await f.close() }
})
// After a rolling step, an escaped reader is the list's anchor only until the
// step's retired rows are measured: a focus move or programmatic scroll
// (keyboard, assistive technology, find-in-page) a few frames later must keep
// the position it reaches. Without a step: the control.
for (const [rolled, wait, move] of [
[true, 60, "focus"], [true, 60, "scrollIntoView"], [true, 3, "write"],
[false, 60, "focus"], [false, 60, "scrollIntoView"], [false, 60, "write"],
] as const) test(`a ${move} ${rolled ? `${wait} frames after a rolling step` : "without a rolling step"} keeps an escaped reader's new position`, async () => {
const f = await fixture()
try {
await waitFrames(f.page, 30)
const box = (await f.page.locator(".message-stream").boundingBox())!
await f.page.mouse.move(box.x + 60, box.y + box.height / 2)
await f.page.mouse.wheel(0, -1200)
await waitFrames(f.page, 30)
assert.equal(await f.page.locator(".message-stream").getAttribute("data-follow-mode"), "escaped")
if (rolled) await rollSteps(f.page, 1)
await waitFrames(f.page, wait)
// The first mounted row lying wholly below the viewport, in the overscan;
// a small programmatic write (60 px) follows the first visible row instead.
const moved = await f.page.evaluate(`(() => {
const stream = document.querySelector('.message-stream'), bounds = stream.getBoundingClientRect()
const rows = [...stream.querySelectorAll('[data-virtual-follow-key]')]
const row = ${JSON.stringify(move)} === 'write'
? rows.find(row => row.getBoundingClientRect().top >= bounds.top)
: rows.find(row => row.getBoundingClientRect().top >= bounds.bottom && row.querySelector('button'))
const before = stream.scrollTop
if (${JSON.stringify(move)} === 'scrollIntoView') row.scrollIntoView()
else if (${JSON.stringify(move)} === 'write') stream.scrollTop += 60
else row.querySelector('button').focus()
return { key: row.dataset.virtualFollowKey, delta: stream.scrollTop - before, offset: row.getBoundingClientRect().top - bounds.top, scrollTop: stream.scrollTop }
})()`) as { key: string; delta: number; offset: number; scrollTop: number }
assert.ok(moved.delta > 0, `the ${move} must scroll the transcript: ${JSON.stringify(moved)}`)
// Sampled every 10 frames over 60: the row's offset and the scroll position.
const samples: Array<{ offset: number | null; scrollTop: number }> = []
for (let frame = 0; frame < 6; frame++) {
await waitFrames(f.page, 10)
samples.push(await f.page.evaluate(`(() => {
const stream = document.querySelector('.message-stream'), row = stream.querySelector('[data-virtual-follow-key="${moved.key}"]')
return { offset: row ? row.getBoundingClientRect().top - stream.getBoundingClientRect().top : null, scrollTop: stream.scrollTop }
})()`) as { offset: number | null; scrollTop: number })
}
assert.ok(samples.every(sample => sample.offset !== null && Math.abs(sample.offset - moved.offset) < 2),
`the rolled reader's anchor must not pull back the ${move}: ${JSON.stringify({ moved, samples })}`)
assert.deepEqual(f.errors, [])
} finally { await f.close() }
})
// Each roll corrects an escaped reader to its own place before paint: thirty
// steps leave the read row where it was (sub-pixel rounding included).
test("thirty rolling steps leave an escaped reader in place", async () => {
const f = await fixture()
try {
await waitFrames(f.page, 30)
const box = (await f.page.locator(".message-stream").boundingBox())!
await f.page.mouse.move(box.x + 60, box.y + box.height / 2)
await f.page.mouse.wheel(0, -1200)
await waitFrames(f.page, 30)
const read = () => f.page.evaluate(`(() => {
const stream = document.querySelector('.message-stream'), bounds = stream.getBoundingClientRect()
const row = [...stream.querySelectorAll('[data-virtual-follow-key]')].find(row => row.getBoundingClientRect().bottom > bounds.top + 1)
return { key: row.dataset.virtualFollowKey, offset: row.getBoundingClientRect().top - bounds.top }
})()`) as Promise<{ key: string; offset: number }>
const before = await read()
for (let i = 0; i < 30; i++) await rollSteps(f.page, 1)
await waitFrames(f.page, 10)
const after = await f.page.evaluate(`(() => {
const stream = document.querySelector('.message-stream'), row = stream.querySelector('[data-virtual-follow-key="${before.key}"]')
return row ? row.getBoundingClientRect().top - stream.getBoundingClientRect().top : null
})()`) as number | null
assert.ok(after !== null && Math.abs(after - before.offset) <= 1, `the escaped reader drifted: ${JSON.stringify({ before, after })}`)
assert.deepEqual(f.errors, [])
} finally { await f.close() }
})
// Retired rows stay mounted only until measured at zero. Steps arriving at
// frame cadence, each also changing the tail, must not keep them mounted.
test("sixty empty steps at frame cadence leave no retired row mounted once idle", async () => {
const f = await fixture()
try {
await waitFrames(f.page, 30)
await rollSteps(f.page, 60, "empty")
await waitFrames(f.page, 10)
const retired = await f.page.locator(".message-stream [data-virtual-follow-retired]").count()
assert.equal(retired, 0, `retired rows still mounted 10 frames after the steps: ${retired}`)
assert.deepEqual(f.errors, [])
} finally { await f.close() }
})

File diff suppressed because it is too large Load diff

View file

@ -71,6 +71,9 @@ test("history loaded beneath a reader is followed to its latest message once the
await page.locator(".mission-reader").getByText("Report opening paragraph.", { exact: true }).waitFor()
await page.evaluate(() => (window as any).fixture.seedHistory())
assert.equal(await page.locator(".mission-transcript-content").getAttribute("inert"), "")
// The hidden transcript stays put; it is followed only once it can be read.
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(resolve)))))
assert.equal(await page.locator(".message-stream").evaluate(el => el.scrollTop), 0)
await page.getByRole("button", { name: "Back to chat", exact: true }).click()
await latestHistoryPainted(page)
} finally { await page.close() }
@ -100,3 +103,57 @@ test("a session reactivated beneath a reader is restored to its latest message o
})
} finally { await page.close() }
})
/** Streams `count` inline tokens, one per frame; the reply stays shorter than a held one. */
const streamDeltas = (page: Page, from: number, count: number) => page.evaluate(async ({ from, count }) => {
for (let i = from; i < from + count; i++) {
(window as any).fixture.delta(`token ${i} `)
await new Promise(resolve => requestAnimationFrame(resolve))
}
}, { from, count })
const waitFrames = (page: Page, count: number) => page.evaluate(async count => {
for (let i = 0; i < count; i++) await new Promise(resolve => requestAnimationFrame(resolve))
}, count)
const streamPosition = (page: Page) => page.locator(".message-stream").evaluate(el => ({
mode: el.getAttribute("data-follow-mode"), gap: el.scrollHeight - el.scrollTop - el.clientHeight,
}))
test("a reader opened while a following transcript streams resumes following once it closes", async () => {
const page = await open([readerMission])
try {
await page.evaluate(() => (window as any).fixture.loadMissions())
await page.evaluate(() => (window as any).fixture.seedHistory())
await latestHistoryPainted(page)
// The opening restore has settled and recorded a following reader.
await page.waitForFunction(() => (window as any).fixture.snapshot().scroll?.followModeType === "following")
await page.evaluate(() => (window as any).fixture.startStream())
await streamDeltas(page, 0, 10)
await page.waitForFunction(() => {
const el = document.querySelector(".message-stream")!
return el.getAttribute("data-follow-mode") === "following" && el.scrollHeight - el.scrollTop - el.clientHeight <= 1
})
// A multi-paragraph delta grows the reply beyond the follow tolerance. Open
// the reader after its frame pins the bottom in the DOM, before the scroll
// event updates Virtua's offset: the deactivation capture must still record
// a reader at the bottom.
await page.evaluate(async () => {
(window as any).fixture.delta("\n\nA streamed paragraph.".repeat(4) + "\n\n")
await new Promise(resolve => requestAnimationFrame(resolve))
await new Promise(resolve => setTimeout(resolve))
await (window as any).fixture.readMission()
})
await page.locator(".mission-reader").getByText("Report opening paragraph.", { exact: true }).waitFor()
await streamDeltas(page, 11, 15)
await page.getByRole("button", { name: "Back to chat", exact: true }).click()
await waitFrames(page, 30)
const restored = await streamPosition(page)
const persisted = await page.evaluate(() => JSON.stringify((window as any).fixture.snapshot().scroll))
assert.equal(restored.mode, "following", `restored from ${persisted}`)
assert.ok(restored.gap <= 1, `restored ${restored.gap}px above the bottom`)
await streamDeltas(page, 26, 5)
await waitFrames(page, 5)
const followed = await streamPosition(page)
assert.equal(followed.mode, "following")
assert.ok(followed.gap <= 1, `followed ${followed.gap}px above the bottom`)
} finally { await page.close() }
})