mirror of
https://github.com/NeuralNomadsAI/CodeNomad.git
synced 2026-10-22 14:22:22 +00:00
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:
parent
a7fd215c44
commit
3394fc7f2f
20 changed files with 2095 additions and 472 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 })) }}
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }))
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
30
packages/ui/tests/browser/fixtures/virtua-solid-counting.ts
Normal file
30
packages/ui/tests/browser/fixtures/virtua-solid-counting.ts
Normal 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)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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() }
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue