diff --git a/src/web-ui/src/flow_chat/components/modern/AGENTS.md b/src/web-ui/src/flow_chat/components/modern/AGENTS.md
index 34f1588bb9..66d729dbdb 100644
--- a/src/web-ui/src/flow_chat/components/modern/AGENTS.md
+++ b/src/web-ui/src/flow_chat/components/modern/AGENTS.md
@@ -11,7 +11,7 @@ Also follow the repository and Web UI instructions in the parent guides.
| Changing | Read |
|---|---|
-| the tail spacer, the follow target, pinning, holding, resizing, the footer, the reveal | `FLOWCHAT_SCROLL_STABILITY.md` |
+| the tail spacer, the follow target, new-Turn revealing, holding, resizing, the footer | `FLOWCHAT_SCROLL_STABILITY.md` |
| history paging, the prepend, the viewport anchor, history presentation | `FLOWCHAT_HISTORY_PAGING.md` |
| anything that writes `scrollTop`, one-shot navigation, the diagnostic trail | `FLOWCHAT_VIEWPORT_REGISTER.md` |
| the virtualizer, item measurement, item keys, anything a row renders | `FLOWCHAT_VIRTUALIZATION.md` |
@@ -21,8 +21,8 @@ before reporting a defect as new.
## Reservation and Follow
-- FlowChat reserves a resident tail spacer of about one viewport, sized from
- `scroller.clientHeight` and nothing else.
+- FlowChat caps the input footer plus resident tail spacer at three quarters of
+ `scroller.clientHeight`; the spacer also depends on the current footer inset.
- Static reservation is allowed; reactive compensation is not. Do not derive any
reserved height from a measured content height, a collapse delta, an animation
duration, or a streaming rate.
@@ -31,7 +31,11 @@ before reporting a defect as new.
- The follow target lives in `flowChatTailFollow.ts` as pure functions over
geometry. Keep it free of timers and mutation observers.
- `scheduleFollowToLatest` must not force the content end — the hold rule is
- what keeps a collapse from moving the viewport.
+ what keeps a collapse from moving the viewport. During `revealing-tail` it
+ samples the blank crossing and performs no viewport write.
+- A new Turn gets one physical-bottom placement after it enters the live-tail
+ projection. Streaming consumes the exposed spacer at fixed `scrollTop`, then
+ hands off to `hold-tail` when the blank closes.
- `useFlowChatFollowOutput` is the only continuous outer viewport writer.
- The follow's **write** may be eased; its **target** may not. Everything that
reads the follow — the settle budget and the at-tail band — reads
@@ -161,10 +165,10 @@ before reporting a defect as new.
Virtualizer-specific compensation stays in `VirtualMessageList`.
- "A new Turn" is `activeSession.dialogTurns.at(-1)`, never the end of the
projection. Do not qualify that identity by whether the Turn is on screen —
- that belongs to the response, which defers until the Turn can be aligned.
+ that belongs to the response, which defers until the Turn can be revealed.
- Detecting one means the ledger **grew**, not that the identity changed. A
rollback truncates `dialogTurns` and moves that identity backwards onto a Turn
- that was always there; read as an arrival it pins the survivor to the top.
+ that was always there; read as an arrival it reveals the survivor as new.
- An action that rewrites `dialogTurns` and wants the viewport moved announces
it — `FLOWCHAT_MESSAGE_SUBMITTED_EVENT` for giving up a navigated history
window, `FLOWCHAT_TURNS_ROLLED_BACK_EVENT` for settling on a new tail. The
diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_HISTORY_PAGING.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_HISTORY_PAGING.md
index cf808fb2f8..09beb0027b 100644
--- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_HISTORY_PAGING.md
+++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_HISTORY_PAGING.md
@@ -526,8 +526,9 @@ mistake this separates.
**A tail-anchored window must grow with the session.** It stops at the newest
Turn that existed when it was cut, and nothing moves its end afterwards, so an
appended Turn is simply not rendered. That is worse than it sounds: `latestTurnId`
-is read off the rendered items, so follow-output never learns the Turn exists —
-no pin, no follow, and nothing to scroll to. `resolveTailWindowGrowth` is
+comes from the ledger, so follow-output learns the Turn exists but cannot reveal
+it from a projection that has no matching item; the arrival remains pending with
+nothing to scroll to. `resolveTailWindowGrowth` is
level-triggered for that reason. An edge — "it reached the tail last render and
does not now" — is consumed whether or not the extension succeeded, stranding
the window permanently on one failure; the current state stays `'extend'` until
@@ -544,7 +545,7 @@ fallback, but it is the only branch that always shows the message just sent.
*ends*, and a history window re-cut moves that to a Turn which has existed for
hours: measured, navigating to Turn 2 landed correctly and was then overwritten
twice, because each window loaded on the way ended somewhere new and each of
-those read as a submission, pinning the window's last Turn to the top.
+those read as a submission, moving the window's last Turn as though it were new.
**Whether the Turn can be acted on is a second question, and it does not belong
in the identity.** Qualifying `latestTurnId` by "and it is on screen" makes a
@@ -555,7 +556,7 @@ and it is how navigating to Turn 29 ended on Turn 38.
to *detect* one, and the detector asked whether `latestTurnId` differed from
last render. A rollback truncates `dialogTurns`, which moves that identity
backwards onto a Turn that has been there all along — so undoing a message
-pinned the Turn *before* it to the viewport top. `dialogTurnCount` separates the
+moved the Turn *before* it as though it had just arrived. `dialogTurnCount` separates the
two: an arrival grows the ledger, and nothing else that rewrites `dialogTurns`
— a history page merging in above, a window re-cut, a hydration — moves the
last Turn at all, so requiring growth costs nothing and excludes every
@@ -566,8 +567,7 @@ truncation.
two dozen call sites write that array; inferring an action from its size is the
same mistake as inferring intent from `scrollTop`. So the rollback announces
itself through `FLOWCHAT_TURNS_ROLLED_BACK_EVENT`, exactly as a submission does,
-and the transcript settles on the new tail — the Turn it was pinning is one of
-the ones that stopped existing.
+and the transcript settles on the new tail.
It takes the viewport whether or not follow owned it. A rollback at Turn N
removes N *and everything after
@@ -588,13 +588,13 @@ already been committed to. Edit-and-rerun does not announce: its truncation is
followed by a rerun whose Turn really is new, and announcing would spend a
visible movement on the way to it.
-So the response carries it instead. A new Turn is answered by pinning it to the
-viewport top; until it is in the transcript on screen there is nothing to align,
-and the fallback — the end of real content — is not a stand-in, because it
-would leave the Turn unpinned or pull a reader out of a history window. The
-answer is therefore **deferred, not dropped**: held in `pendingNewTurnIdRef` and
-retried when the transcript next changes, which is exactly when the presentation
-is restored to the live tail.
+So the response carries it instead. A new Turn is answered by revealing the
+resident tail blank with one physical-bottom placement; until the Turn is in the
+live-tail projection there is nothing to reveal, and the old content end is not
+a stand-in because it can pull a reader out of a history window before the new
+Turn exists there. The answer is therefore **deferred, not dropped**: held in
+`pendingNewTurnIdRef` and retried when the transcript next changes, which is
+exactly when the presentation is restored to the live tail.
**Submitting is what gives up a navigated window.** `resolveTailWindowGrowth`
leaves such a window alone as the session grows, and that is right — a Turn
diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md
index 02df97a4f9..d09b12d7fe 100644
--- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md
+++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md
@@ -2,8 +2,8 @@
FlowChat reserves a resident tail spacer below the transcript, and pairs it with
a follow target that does not move backwards for free. Together these give a
-newly submitted Turn a top-aligned position and keep a tool-card collapse from
-dragging earlier content down.
+newly submitted Turn a one-shot reveal with room below it and keep a tool-card
+collapse from dragging earlier content down.
That is this document. Four siblings carry the rest.
@@ -11,7 +11,7 @@ That is this document. Four siblings carry the rest.
| Changing | Read |
|---|---|
-| the tail spacer, the follow target, pinning, holding, resizing, the footer | this file |
+| the tail spacer, the follow target, new-Turn reveal, holding, resizing, the footer | this file |
| history paging, the prepend, the viewport anchor, history presentation | `FLOWCHAT_HISTORY_PAGING.md` |
| anything that writes `scrollTop`, one-shot navigation, the diagnostic trail | `FLOWCHAT_VIEWPORT_REGISTER.md` |
| the virtualizer, item measurement, item keys, anything a row renders | `FLOWCHAT_VIRTUALIZATION.md` |
@@ -33,30 +33,22 @@ that under a new name.
## How Much To Reserve
-The spacer keeps two offsets inside the scroll range, and is the larger of what
-they need. Both are bounds, not estimates: reserving more than the larger one is
-pure blank at the end of the scroll range, and reserving less than either is a
-clamp.
-
-- **A pinned Turn.** Worst case its user message is the newest item with nothing
- answering it yet, so the message, the input inset and the spacer are all that
- lie below the message top. `clientHeight - bottomInsetPx -
- PINNED_TURN_MIN_ITEM_HEIGHT_PX` is exactly enough to put it on the top edge.
-- **A held collapse gap.** `hold-tail` parks up to `tailHoldMaxGapPx` past the
- content end, and an offset the browser clamps is one the hold rule does not
- actually get to hold.
-
-`PINNED_TURN_MIN_ITEM_HEIGHT_PX` must stay an **under**estimate of a
-user-message item. Too low costs a few spare pixels of blank; too high puts the
-pinned offset past the end of the scroll range, and the Turn is clamped back
-down from the viewport top while the follow loop rewrites the clamped offset
-every frame.
-
-While the pin reserve is the binding bound, the spacer and the footer sum to a
-constant: growing the composer moves the content end without moving the end of
-the scroll range. Under the hold-gap floor the spacer stops tracking the inset
-and the range grows with the composer, exactly as it did when the spacer was a
-flat viewport.
+The input-stack footer and spacer together occupy at most three quarters of the
+viewport:
+
+```text
+tailSpacerPx = max(0, round(clientHeight * 0.75 - bottomInsetPx))
+```
+
+At the physical bottom this leaves at least one quarter of the viewport showing
+transcript content. For an 800px viewport and a 168px footer, the spacer is
+432px and 200px of transcript remains visible. An expanded composer consumes
+the reservation first; the spacer never becomes negative.
+
+`hold-tail` still needs its collapse allowance to exist inside the physical
+scroll range, so its effective maximum is the smaller of 60% of the viewport
+and the rendered spacer. This makes the reservation the hard bound rather than
+silently asking the browser to hold an offset it must clamp.
## Why Both Halves Are Required
@@ -65,17 +57,13 @@ clamp when content shrinks, which is *permission* to hold position. A follow
target that re-aligns the content end to the viewport bottom every frame will
still drag earlier content down by the collapse delta, spacer or not.
-`flowChatTailFollow.ts` supplies the second half:
-
-- `pin-turn-top` holds a freshly submitted Turn's user message at the viewport
- top while its answer is shorter than one viewport, then hands off at the
- crossover. The blank below a pinned Turn is the mode, not a defect.
-- `hold-tail` keeps its previous offset when content shrinks, and gives ground
+`flowChatTailFollow.ts` supplies the second half: `hold-tail` keeps its previous
+offset when content shrinks, and gives ground
only once the blank below the live output exceeds `tailHoldMaxGapPx`
- (a share of the viewport, not a measured delta).
+ (a share of the viewport capped by the physical spacer, not a measured delta).
-Both are pure functions over geometry. They hold no timers and observe no
-mutation.
+These rules are pure functions over geometry. They hold no timers and observe
+no mutation.
`useFlowChatFollowOutput` is the only continuous outer viewport writer. Three
things about how it runs are load-bearing:
@@ -83,11 +71,11 @@ things about how it runs are load-bearing:
- **`scheduleFollowToLatest` re-asserts ownership after a layout change but does
not force the content end.** A collapse resizes content too, and the hold rule
is what keeps that from moving the viewport.
-- **The pinned Turn's offset is re-resolved from live layout every frame.**
- Items above it are estimates until they are measured, so a cached absolute
- offset would drift.
+- **A new Turn reveal runs no follow frame.** It places the viewport at the
+ physical bottom once and samples only whether streamed output has consumed
+ the blank.
- **When streaming stops, `hold-tail` settles any remaining blank with one
- smooth scroll.** A pinned Turn does not settle.
+ smooth scroll.** A short new-Turn reveal remains at its reveal position.
`tailHoldMaxGapPx` is a **streaming allowance**. Blank below the live output is
tolerable only because more output is about to fill it. Do not reuse it to
@@ -131,40 +119,53 @@ stop changing. Before the virtualizer renders anything, `scrollHeight` and the
end sit unchanged at their unmeasured values, which is indistinguishable from
having finished; a stability test reveals on frame 3 and shows the whole settle.
+## Revealing a New Turn
+
+A newly submitted Turn does not enter continuous follow immediately. Once that
+Turn exists in the live-tail projection, follow-output performs one placement at
+the **physical bottom** (`scrollHeight - clientHeight`). That exposes the whole
+resident spacer at once. The reveal then keeps viewport ownership, but runs no
+RAF scroll writer:
+
+```text
+idle -> revealing-tail -> following-tail
+```
+
+As output streams, `scrollTop` stays fixed while `contentEndScrollTop` rises, so
+the visible blank shrinks naturally. The transcript history therefore stays
+still; content consumes the blank instead of a follow write moving history up.
+When `blankPx = scrollTop - contentEndScrollTop` crosses from positive to zero,
+the existing `content-caught-up` path enters ordinary `hold-tail` without a
+one-shot correction. Subsequent growth follows normally.
+
+A short response may never consume the blank. That is intentional: the reveal
+position remains, and stopping streaming does not settle it. A user gesture
+releases reveal ownership and opens the ordinary user-departure watch. A delayed
+`jump-to-latest` from presentation restoration is ignored while reveal is
+active, because the reveal already is the latest placement.
+
+The arrival can precede the live-tail projection that renders it, especially
+when submission starts from a history window. `pendingNewTurnIdRef` preserves
+that arrival and retries the same one-shot placement when the presentation next
+changes; it does not substitute the old content end.
+
## User-Controlled Reserved Blank
-The spacer is a full viewport the user can scroll into. Once a reader exits
-follow-output, their own scroll position is preserved even when it lands in the
-blank. There is no `scrollend` correction or quiet-period fallback that takes
-the viewport back. Explicit actions such as opening a session, submitting a new
-Turn, navigating a Turn, rolling back, or choosing jump-to-latest remain the
-ways to re-enter follow-output.
+The spacer is bounded by the three-quarter reservation the user can scroll into.
+Once a reader exits follow-output, their own scroll position is preserved even
+when it lands in the blank. There is no `scrollend` correction or quiet-period
+fallback that takes the viewport back. Explicit actions such as opening a
+session, submitting a new Turn, navigating a Turn, rolling back, or choosing
+jump-to-latest remain the ways to re-enter follow-output.
The remaining follow rules carry the live-tail design:
-**The target is the follow target, never the content end.** A short new Turn is
-pinned above the content end, so aiming at the content end would scroll *up*
-and shove the message the user just sent into the middle of the viewport. A
-held collapse gap is likewise a legitimate offset up to `tailHoldMaxGapPx` past
-the content end; judged against the content end it would read as an overshoot
-and fight the hold rule on every collapse. `memorylessFollowState` computes the
-target from live geometry with no remembered offset, because the offset the hold
-rule was protecting stopped being meaningful the moment the user took over.
-
-The pin's *identity* therefore outlives a user takeover; only its *activity*
-stops. Three things retire a pin: the crossover to `hold-tail`, a newer Turn,
-and a session change. The crossover has to be one-way — a collapse can pull
-content back under one viewport, and re-pinning there would jump the viewport
-backwards. Since nothing re-pins a Turn whose identity was dropped, that is
-automatic.
-
-**Where a jump to latest lands.** Every entry into follow-output resumes at the
-end of real content, with one exception: a jump to latest while the **newest**
-Turn is still pinned returns to the pin. That mode only holds while the Turn's
-answer is shorter than one viewport, so everything it has produced is already on
-screen, and aiming at the content end would scroll *up* and shove the message
-the user just sent into the middle. The exemption therefore outlives the Turn:
-a short Turn stays pinned until a newer one replaces it.
+**The target is the follow target, never unconditionally the content end.** A
+held collapse gap is a legitimate offset up to `tailHoldMaxGapPx` past the
+content end; judged against the content end it would read as an overshoot and
+fight the hold rule on every collapse. Once a user takes over, ordinary resume
+starts from live geometry because the offset the hold rule protected no longer
+belongs to follow-output.
## Output Catching Up With a Reader in the Blank
@@ -173,10 +174,10 @@ looking at reserved blank, and whom output then overtakes.
Scrolling up gives the follow away permanently, and that is right only for a
reader who left the live region. A small scroll up may not have. The blank is up
-to `tailHoldMaxGapPx` under `hold-tail` and the whole gap under a pinned Turn,
-so the reader can be a few hundred pixels off the tail with nothing hidden from
-them at all — until output grows past the bottom edge, and they silently stop
-seeing it with no affordance saying so.
+to `tailHoldMaxGapPx` under `hold-tail` and starts at the whole spacer during a
+new-Turn reveal, so the reader can be a few hundred pixels off the tail with
+nothing hidden from them at all — until output grows past the bottom edge, and
+they silently stop seeing it with no affordance saying so.
So a watch runs for as long as the reader holds the viewport — from the scroll
that took it to whatever hands it back. `scrollTop > contentEnd` is the
@@ -232,9 +233,8 @@ the reader was standing before it.
Resuming does not scroll. The blank closing *is* the two offsets meeting, so
what remains is one sample of growth, and the follow loop's ease covers it; a
one-shot scroll here would be a snap the reader can see for a correction they
-cannot. It also retires the pin, even when the departure happened under one: the
-pin's reservation is the blank the reader just scrolled out of, and restoring it
-would pull them back down to the offset they left.
+cannot. A reveal uses the same crossing before any gesture, while a gesture
+replaces it with the reader-owned form of the watch.
The whole watch is a ref. `followOutput.tailWatch` and
`followOutput.tailWatchEnded` bracket it — the second carrying `crossings`, so a
@@ -298,16 +298,15 @@ against a nominal 0.25, which is also the evidence that nothing else was
writing the viewport in between.
"At bottom" is a band, not a point: from the end of real content down to
-whatever the follow rule owns. A pinned Turn and a held collapse gap are both
-inside it, so neither raises the jump-to-latest affordance; the reserved blank
-is outside it, so parking there does. No virtualizer-reported "at bottom" can
-express this: the end of the scroll range is the bottom of the reserved blank,
-not the end of content.
+whatever the follow rule owns. A reveal position and a held collapse gap are
+inside it while follow-output owns them, so neither raises the jump-to-latest
+affordance; reader-owned reserved blank is outside it. No virtualizer-reported
+"at bottom" can express this: the end of the scroll range is the bottom of the
+reserved blank, not the end of content.
The band is recomputed on scroll, on resize, **and when follow ownership
changes** — its lower edge is the follow target, which can move while the
-viewport is perfectly still. A jump to latest that lands on a pin the viewport
-already sits on writes
+viewport is perfectly still. A delayed jump to latest during a reveal writes
nothing at all. Driving the band from scroll events alone left the affordance
visible over a viewport that was at the tail, and clicking it then had nothing
to do — an inert button is worse than a missing one.
@@ -402,7 +401,7 @@ through more of an animation they cannot read.
The other `'smooth'` request in `useFlowChatFollowOutput`, the post-streaming
settle, needs no such test: the gap it closes is bounded by `tailHoldMaxGapPx`,
-which is 60% of one viewport.
+which is at most 60% of one viewport and never larger than the spacer.
`followOutput.jumpBehavior` records the decision and the distance in viewports.
It is also how the constant is checked: if `followOutput.animatedScrollEnded`
diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md
index 52e04949ba..0698a2e2c6 100644
--- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md
+++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md
@@ -16,9 +16,9 @@ each missing what the other had.
| Test | Contract it holds |
|---|---|
-| `flowChatTailFollow.test.ts` | the follow target, `pin-turn-top`, `hold-tail` |
+| `flowChatTailFollow.test.ts` | the three-quarter reservation and `hold-tail` geometry |
| `flowChatCollapseMotion.test.ts` | collapse does not move earlier content |
-| `useFlowChatFollowOutput.test.tsx` | the frame loop, user-controlled blank, resize realign |
+| `useFlowChatFollowOutput.test.tsx` | one-shot new-Turn reveal, frame loop, blank crossing, resize realign |
| `../../tool-cards/useToolCardHeightContract.test.tsx` | tool cards reflow rather than compensate |
| `flowChatHistoryBoundary.test.ts` | the screenful lead, and the latch's own predicate |
| `flowChatLiveTailWindow.test.ts` | "does the transcript still reach the newest Turn" |
@@ -52,35 +52,34 @@ group does not renumber the others.
most visible symptom if the estimate ever regresses.
4. Session switching and history paging do not restore stale footer height.
-### Submitting and pinning
+### Submitting and revealing
-1. A newly submitted Turn opens at the viewport top with room below it.
-2. Send a one-line message and let it pin. It must come to rest at the top with
- the same small gap above it as the very first Turn of the session, and the
- pin must hold steady rather than creeping down — a pinned offset past the
- end of the scroll range is clamped, and the follow loop will rewrite it
- every frame.
-3. Scroll down into the blank and let go right after submitting a short Turn:
- it returns that Turn to the viewport top, not to the content end.
+1. A newly submitted Turn performs one physical-bottom placement, exposing the
+ full resident spacer while leaving at least one quarter of the transcript
+ visible above the input footer.
+2. Send a one-line message and leave the short response alone. The viewport must
+ remain at that reveal position; no frame loop may creep toward the content end.
+3. While the answer grows but has not filled the blank, history stays visually
+ fixed and the blank shrinks. When output reaches the viewport bottom, follow
+ starts without a snap and subsequent growth follows normally.
4. Send a message from the live tail, and again while parked deep in history.
- Both must end with the new Turn at the viewport top; the second also has to
+ Both must reveal the new live tail; the second also has to
leave the history window to get there.
-5. Send a message, let it pin, then roll it back from its own message actions.
+5. Send a message, let it reveal, then roll it back from its own message actions.
The transcript must come to rest with the surviving last Turn at the
- *bottom* — not with it pinned to the top, which is what reading the
- truncation as an arrival used to do.
+ *content end*, not at the old reveal position.
6. Roll a Turn back from further up a transcript, having scrolled to reach it.
The surviving last Turn must end at the bottom here too — scrolling to reach
the button hands the viewport to the reader, and the answer has to run
anyway. Leaving it to the anchor is what showed Turns 2..6 of an 8-Turn
session with the new last Turn's answer below the fold.
7. Edit a message and rerun it. There must be one movement, not two — the
- truncation is silent and the rerun's Turn pins as usual.
+ truncation is silent and the rerun's Turn reveals as usual.
### Streaming and follow
-1. Streaming follows the tail until the user scrolls, and the pinned Turn hands
- off once its answer overflows the viewport.
+1. A new-Turn reveal stays fixed until output consumes its blank, then hands off
+ to ordinary tail following.
2. With output streaming, scroll up and hold still. Follow must not write while
the gesture is recent, and must resume once it goes quiet.
3. Jump to latest from a screen or two up is animated rather than an instant
@@ -121,12 +120,13 @@ group does not renumber the others.
opens.
4. Wheel down into the reserved blank and stop. The transcript must remain
still and the jump-to-latest affordance must stay available.
-5. With a short Turn pinned, scroll up and jump to latest. The explicit jump
- must still return to the pin rather than the physical bottom of the spacer.
+5. Trigger the delayed jump-to-latest that accompanies live-tail restoration
+ while a short Turn reveal is active. It must be a no-op and must not replace
+ the reveal with a content-end scroll.
### Output catching up with a reader in the blank
-1. Send a message so a short Turn pins with blank below it, then wheel up a
+1. Send a message so its one-shot reveal has blank below it, then wheel up a
little — far enough to leave the tail, not far enough to push the blank off
screen — and take your hand off. As the answer grows past the bottom edge the
transcript must resume following, easing rather than snapping, and the
@@ -147,8 +147,8 @@ group does not renumber the others.
1. Drag the scrollbar to the very bottom. The screen must not be entirely
blank: the last Turn and the input clearance stay visible above the
- reservation. Repeat with the composer expanded, which is where the reserve
- falls back to the hold-gap floor.
+ reservation. Repeat with the composer expanded, which consumes the spacer
+ before the three-quarter cap can be exceeded.
2. Drag the scrollbar, without touching the wheel first, down into the reserved
blank and let go: it must stay there. Then drag it while output streams: the
transcript must follow the thumb without the frame loop fighting it. A press
@@ -168,7 +168,7 @@ group does not renumber the others.
1. Open a session long enough to be `isPartial` — the loaded tail is shorter
than the viewport, so it pages older Turns in on its own. No jump-to-latest
bar should appear, and streaming output should be followed. Then send a
- message: it must appear immediately and pin to the viewport top, with the
+ message: it must appear immediately in the one-shot tail reveal, with the
history above neither moving nor reloading.
2. Scroll up to a junction. **One** page loads, the Turn under the cursor stays
where it is, and paging stops until the head is reached again. Then keep
diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIEWPORT_REGISTER.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIEWPORT_REGISTER.md
index f254bcddcf..8268d5d695 100644
--- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIEWPORT_REGISTER.md
+++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIEWPORT_REGISTER.md
@@ -106,8 +106,8 @@ but not a second visible movement.
**Turn navigation never scrolls into the reserved blank to top-align a Turn.** A
Turn whose top lies past the content end is stopped at the content end instead,
-which is where the tail rests. The blank belongs to follow-output — `pin-turn-top`
-holds it for output that is arriving, and nothing arrives under a Turn the user
+which is where the tail rests. The blank belongs to follow-output's passive
+new-Turn reveal, and nothing arrives under a Turn the user
navigated to. There is no "is this the last Turn" test and no measurement of
what lies below it: a Turn with a viewport of content under it has its top above
the content end already, so the clamp does not bind and the final Turns of a
@@ -119,8 +119,8 @@ message**, not at the message itself. The first Turn already sits below that gap
for free, because `.message-list-header` occupies it at the head of the scroll
content; every other Turn used to land flat on the top edge, and the two read as
different alignments. The header renders at the same constant so they cannot
-drift. Both the one-shot scroll and the offset the follow loop re-asserts every
-frame carry it — if only one did, they would fight.
+drift. This remains a Turn-navigation contract; new-Turn reveal no longer asks
+for or re-asserts a Turn-top offset.
**A gesture preempts a navigation still in flight, and ends it.** The library's
re-aim keeps computing for up to 5s after the aim that started it, recomputing
diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx
index b4770f829b..ad05f72f5c 100644
--- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx
+++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx
@@ -31,6 +31,7 @@ const mocks = vi.hoisted(() => ({
isFollowingOutput: false,
followsNow: false,
scheduleFollowToLatest: vi.fn(),
+ revealNewTurnTail: null as null | ((turnId: string) => boolean),
/**
* The register the list built, reached through the hook it hands it to.
*
@@ -172,8 +173,10 @@ vi.mock('../../store/chatInputStateStore', () => ({
vi.mock('./useFlowChatFollowOutput', () => ({
useFlowChatFollowOutput: (options: {
viewportOwner: { claim: (owner: string) => boolean };
+ revealNewTurnTail: (turnId: string) => boolean;
}) => {
mocks.viewportOwner = options.viewportOwner;
+ mocks.revealNewTurnTail = options.revealNewTurnTail;
return {
isFollowingOutput: mocks.isFollowingOutput,
enterFollowOutput: mocks.enterFollowOutput,
@@ -315,6 +318,7 @@ describe('VirtualMessageList natural scroll contract', () => {
mocks.handleUserScrollIntent.mockReset();
mocks.handleFollowScroll.mockReset();
mocks.scheduleFollowToLatest.mockReset();
+ mocks.revealNewTurnTail = null;
mocks.viewportOwner = null;
mocks.setVisibleTurnInfo.mockReset();
mocks.renderItemMetadata = true;
@@ -342,7 +346,7 @@ describe('VirtualMessageList natural scroll contract', () => {
expect(footer?.style.minHeight).toBe('168px');
});
- it('reserves a tail spacer sized from the viewport and nothing else', () => {
+ it('reserves a tail spacer from the viewport and input-stack inset', () => {
// The session opens on the end of *real content*, which is above this
// reservation. Nothing aligns to the last item any more: the end of the
// scroll range is reserved blank, and opening there is opening on nothing.
@@ -382,6 +386,25 @@ describe('VirtualMessageList natural scroll contract', () => {
}
});
+ it('reveals a rendered new Turn at the physical bottom through the viewport register', () => {
+ const restoreLayout = fakeLayout({
+ clientHeight: 600,
+ scrollHeight: 1400,
+ turnTopFromScrollerTop: 500,
+ });
+ try {
+ act(() => root.render());
+ const scroller = container.querySelector('[data-flowchat-scroller]')!;
+ scroller.scrollTop = 100;
+
+ expect(mocks.revealNewTurnTail?.('turn-2')).toBe(true);
+ expect(scroller.scrollTop).toBe(800);
+ expect(mocks.scrollToOffset).not.toHaveBeenCalled();
+ } finally {
+ restoreLayout();
+ }
+ });
+
it('suspends viewport writers until the frame after a minimized zero-size sample resumes', () => {
const layout = {
clientWidth: 1000,
@@ -708,7 +731,10 @@ describe('VirtualMessageList natural scroll contract', () => {
* where the reserved blank begins — which, since
* paging happens only while scrolling up, it then does every time.
*/
- const contentEndPx = 1000 - tailSpacerPxForViewport(600, BOTTOM_INSET) - 600;
+ const contentEndPx = Math.min(
+ 100,
+ 1000 - tailSpacerPxForViewport(600, BOTTOM_INSET) - 600,
+ );
withGrowingRange({ scrollHeightPx: 900, growthPx: 100, scrollTopPx: 0 }, scroller => {
prependOlderTurns(3);
expect(scroller.scrollTop).toBe(contentEndPx);
diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx
index 951635315b..39a9dbe444 100644
--- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx
+++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx
@@ -606,19 +606,26 @@ const VirtualMessageListSession = forwardRef {
+ const revealNewTurnTail = useCallback((turnId: string) => {
const targetIndex = virtualItems.findIndex(item => (
item.turnId === turnId && item.type === 'user-message'
));
if (targetIndex < 0) return false;
- // Follow-output's answer to a new Turn, not a navigation: it is the same
- // continuous writer, and a pin it cannot hold is not a pin.
- virtualizer.scrollItemIntoView(targetIndex, { align: 'start', owner: 'follow-output' });
- return true;
- }, [virtualItems, virtualizer]);
+ const scroller = scrollerElementRef.current;
+ if (!scroller) return false;
+
+ // This placement is intentionally one-shot. Streaming growth then consumes
+ // the resident blank without a re-aim moving historical content upward.
+ virtualizer.measureRenderedItems();
+ return viewportOwner.write({
+ topPx: Math.max(0, scroller.scrollHeight - scroller.clientHeight),
+ behavior: 'auto',
+ owner: 'follow-output',
+ });
+ }, [viewportOwner, virtualItems, virtualizer]);
- // Must agree with `scrollTurnToTop` down to the pixel: this is the offset the
- // follow loop re-asserts every frame, so a disagreement is a fight.
+ // Turn navigation still needs the rendered top offset independently of the
+ // new-Turn reveal, which targets the physical bottom only once.
const resolveTurnTopScrollTop = useCallback((turnId: string) => {
const scroller = scrollerElementRef.current;
const element = getRenderedUserMessageElement(turnId);
@@ -655,8 +662,7 @@ const VirtualMessageListSession = forwardRef {
- it('reserves exactly enough to put a bare new Turn on the top edge', () => {
- // Worst case for the pin: the user message is the newest item and nothing
- // has answered it, so the message, the input inset and the spacer are all
- // that lie below its top. One viewport of them is what the pin needs.
+ it('caps the footer and spacer at three quarters of the viewport', () => {
const spacer = tailSpacerPxForViewport(VIEWPORT, BOTTOM_INSET);
- const smallestUserMessagePx = VIEWPORT - BOTTOM_INSET - spacer;
- expect(smallestUserMessagePx).toBeGreaterThan(0);
- expect(smallestUserMessagePx).toBeLessThan(64);
+ expect(BOTTOM_INSET + spacer).toBe(Math.round(VIEWPORT * 0.75));
+ expect(VIEWPORT - BOTTOM_INSET - spacer).toBe(200);
});
- it('never reserves less than the gap the hold rule may be holding', () => {
- // A composer expanded most of the way up the viewport leaves the pin almost
- // nothing to reserve, but `hold-tail` still parks up to `tailHoldMaxGapPx`
- // past the content end — and an offset the browser clamps is an offset the
- // hold rule does not get to hold.
- expect(tailSpacerPxForViewport(VIEWPORT, VIEWPORT - 80))
- .toBe(tailHoldMaxGapPx(VIEWPORT));
+ it('lets an expanded footer consume the reservation without making spacer negative', () => {
+ expect(tailSpacerPxForViewport(VIEWPORT, VIEWPORT - 80)).toBe(0);
});
it('stays well under a full viewport, so the scroll range does not end in blank', () => {
@@ -57,6 +43,11 @@ describe('tailSpacerPxForViewport', () => {
it('reserves nothing before the scroller has been measured', () => {
expect(tailSpacerPxForViewport(0, BOTTOM_INSET)).toBe(0);
});
+
+ it('never lets the hold gap exceed the physical spacer', () => {
+ expect(tailHoldMaxGapPx(VIEWPORT, 120)).toBe(120);
+ expect(tailHoldMaxGapPx(VIEWPORT, SPACER)).toBe(SPACER);
+ });
});
describe('turnTopAlignmentEntersReservedBlank', () => {
@@ -115,10 +106,9 @@ describe('nextTailFollowState hold-tail', () => {
it('follows content growth', () => {
const next = nextTailFollowState(holding(4000), {
desiredScrollTop: 4200,
- pinScrollTop: null,
maxGapPx: MAX_GAP,
});
- expect(next).toEqual({ mode: 'hold-tail', target: 4200 });
+ expect(next).toEqual({ target: 4200 });
});
it('holds its offset when a collapse fits the tolerated gap', () => {
@@ -126,7 +116,6 @@ describe('nextTailFollowState hold-tail', () => {
// viewport must not move or earlier content would visually drop by 300px.
const next = nextTailFollowState(holding(4000), {
desiredScrollTop: 3700,
- pinScrollTop: null,
maxGapPx: MAX_GAP,
});
expect(next.target).toBe(4000);
@@ -135,7 +124,6 @@ describe('nextTailFollowState hold-tail', () => {
it('gives ground only past the tolerated gap, and only by the excess', () => {
const next = nextTailFollowState(holding(4000), {
desiredScrollTop: 1000,
- pinScrollTop: null,
maxGapPx: MAX_GAP,
});
expect(next.target).toBe(1000 + MAX_GAP);
@@ -144,112 +132,12 @@ describe('nextTailFollowState hold-tail', () => {
it('never drops below the content-end target', () => {
const next = nextTailFollowState(holding(100), {
desiredScrollTop: 900,
- pinScrollTop: null,
maxGapPx: MAX_GAP,
});
expect(next.target).toBe(900);
});
});
-describe('nextTailFollowState pin-turn-top', () => {
- it('holds a new Turn at the viewport top while its answer is short', () => {
- const next = nextTailFollowState(pinning(0), {
- desiredScrollTop: 4300,
- pinScrollTop: 5000,
- maxGapPx: MAX_GAP,
- });
- expect(next).toEqual({ mode: 'pin-turn-top', target: 5000 });
- });
-
- it('ignores the gap tolerance while pinned', () => {
- // The blank below a freshly submitted Turn is the point of the mode.
- const next = nextTailFollowState(pinning(5000), {
- desiredScrollTop: 4300,
- pinScrollTop: 5000,
- maxGapPx: 10,
- });
- expect(next.target).toBe(5000);
- });
-
- it('stays put while a collapse shrinks content under the pin', () => {
- const first = nextTailFollowState(pinning(0), {
- desiredScrollTop: 4400,
- pinScrollTop: 5000,
- maxGapPx: MAX_GAP,
- });
- const afterCollapse = nextTailFollowState(first, {
- desiredScrollTop: 4100,
- pinScrollTop: 5000,
- maxGapPx: MAX_GAP,
- });
- expect(afterCollapse.target).toBe(5000);
- });
-
- it('hands off to hold-tail once the answer overflows the viewport', () => {
- const next = nextTailFollowState(pinning(5000), {
- desiredScrollTop: 5000,
- pinScrollTop: 5000,
- maxGapPx: MAX_GAP,
- });
- expect(next).toEqual({ mode: 'hold-tail', target: 5000 });
- });
-
- it('does not regress after handing off', () => {
- const handoff = nextTailFollowState(pinning(5000), {
- desiredScrollTop: 5200,
- pinScrollTop: 5000,
- maxGapPx: MAX_GAP,
- });
- expect(handoff.mode).toBe('hold-tail');
- const afterCollapse = nextTailFollowState(handoff, {
- desiredScrollTop: 5000,
- pinScrollTop: 5000,
- maxGapPx: MAX_GAP,
- });
- expect(afterCollapse.target).toBe(5200);
- });
-
- it('falls back to the tail target until the Turn can be measured', () => {
- const next = nextTailFollowState(pinning(0), {
- desiredScrollTop: 4300,
- pinScrollTop: null,
- maxGapPx: MAX_GAP,
- });
- expect(next).toEqual({ mode: 'pin-turn-top', target: 4300 });
- });
-});
-
-describe('memorylessFollowState', () => {
- it('drops a held collapse gap the user has already scrolled away from', () => {
- // The hold rule's refusal to move backwards protects a viewport it has been
- // holding continuously. After a takeover there is nothing left to protect,
- // and reinstating the old offset would land on a position nobody chose.
- expect(memorylessFollowState('hold-tail', {
- desiredScrollTop: 3700,
- pinScrollTop: null,
- maxGapPx: MAX_GAP,
- }).target).toBe(3700);
- });
-
- it('still prefers a pinned Turn over the content end', () => {
- expect(memorylessFollowState('pin-turn-top', {
- desiredScrollTop: 4300,
- pinScrollTop: 5000,
- maxGapPx: MAX_GAP,
- })).toEqual({ mode: 'pin-turn-top', target: 5000 });
- });
-
- it('reports the crossover so a suspended pin can be retired', () => {
- // No frame loop runs while the user owns the viewport, so this is the only
- // place a pin whose answer outgrew the viewport can be noticed.
- expect(memorylessFollowState('pin-turn-top', {
- desiredScrollTop: 5200,
- pinScrollTop: 5000,
- maxGapPx: MAX_GAP,
- })).toEqual({ mode: 'hold-tail', target: 5200 });
- });
-});
-
describe('isViewportAtTail', () => {
const contentEnd = 4000;
@@ -262,7 +150,7 @@ describe('isViewportAtTail', () => {
})).toBe(true);
});
- it('counts a pinned Turn, which is at the tail by its own rule', () => {
+ it('counts a reveal position owned by follow-output', () => {
expect(isViewportAtTail({
scrollTop: 5000,
contentEndScrollTop: contentEnd,
@@ -303,9 +191,7 @@ describe('resolveAnimatedJumpBehavior', () => {
})).toBe('smooth');
});
- it('animates a pinned Turn coming back into place, which is under a screen', () => {
- // The `pin-turn-top` branch of a jump to latest, where the newest Turn's
- // answer is shorter than the viewport by construction.
+ it('animates a nearby follow target coming back into place', () => {
expect(resolveAnimatedJumpBehavior({
fromPx: 12_000,
targetPx: 12_180,
@@ -356,8 +242,7 @@ describe('resolveAnimatedJumpBehavior', () => {
});
it('judges the distance travelled, whichever way it goes', () => {
- // A jump to latest normally scrolls down, but `pin-turn-top` can aim above
- // the viewport when a restored tail presentation arrives under a pin.
+ // A follow target can be above the viewport. Distance is absolute either way.
expect(resolveAnimatedJumpBehavior({
fromPx: 9000,
targetPx: 9000 - budget - 1,
diff --git a/src/web-ui/src/flow_chat/components/modern/flowChatTailFollow.ts b/src/web-ui/src/flow_chat/components/modern/flowChatTailFollow.ts
index 599035c9a8..fba393a727 100644
--- a/src/web-ui/src/flow_chat/components/modern/flowChatTailFollow.ts
+++ b/src/web-ui/src/flow_chat/components/modern/flowChatTailFollow.ts
@@ -15,6 +15,9 @@
/** Blank tail tolerated below the live output, as a share of the viewport. */
export const FLOWCHAT_TAIL_HOLD_GAP_RATIO = 0.6;
+/** Maximum combined height of the input footer and resident tail spacer. */
+export const FLOWCHAT_TAIL_RESERVATION_RATIO = 0.75;
+
/**
* Distance from an owned offset still treated as being on it.
*
@@ -25,10 +28,7 @@ export const FLOWCHAT_TAIL_HOLD_GAP_RATIO = 0.6;
*/
export const FLOWCHAT_AT_CONTENT_END_THRESHOLD_PX = 50;
-export type TailFollowMode = 'pin-turn-top' | 'hold-tail';
-
export interface TailFollowState {
- mode: TailFollowMode;
/** Scroll offset the viewport currently owns. */
target: number;
}
@@ -42,8 +42,6 @@ export interface TailFollowGeometry {
export interface TailFollowInput {
/** Offset placing the end of real content at the viewport bottom. */
desiredScrollTop: number;
- /** Offset placing the pinned Turn's user message at the viewport top. */
- pinScrollTop: number | null;
/** Largest blank tail the hold rule accepts before it gives ground. */
maxGapPx: number;
}
@@ -61,46 +59,23 @@ export interface TailFollowInput {
*/
export const FLOWCHAT_TURN_TOP_GAP_PX = 8;
-/**
- * Lower bound on the rendered height of a user-message item.
- *
- * This sizes the pin reserve below, so it must be an *under*estimate. Too low
- * only leaves the pin a few spare pixels; too high makes the pinned offset
- * exceed the scroll range, and the browser clamps the Turn back down from the
- * viewport top with the follow loop rewriting the clamped offset every frame.
- * A single line with no timestamp row is comfortably above this at any
- * supported font size, and the item is free to be taller.
- */
-const PINNED_TURN_MIN_ITEM_HEIGHT_PX = 40;
-
/**
* Height of the resident tail spacer.
*
- * The spacer exists to keep two offsets inside the scroll range, and it is
- * sized to the larger of what they need — one viewport was simply the cheapest
- * bound that covered both, and it costs a full screen of blank at the end of
- * the scroll range.
- *
- * - **A pinned Turn.** Worst case its user message is the newest item and
- * nothing has answered it yet, so everything below the message top is the
- * message, the input-stack inset, and this spacer. Reserving
- * `clientHeight - bottomInsetPx - PINNED_TURN_MIN_ITEM_HEIGHT_PX` is exactly
- * enough to put that message on the top edge.
- * - **A held collapse gap.** `hold-tail` keeps an offset up to
- * `tailHoldMaxGapPx` past the content end, and an offset the browser clamps
- * is an offset the hold rule does not actually get to hold.
- *
- * `bottomInsetPx` is the input-stack footer, which grows as the composer does.
- * That is a layout input, not a content measurement — and while the pin reserve
- * is the binding bound the two sum to a constant, so growing the composer moves
- * the content end without moving the end of the scroll range.
+ * A new Turn is revealed by scrolling to the physical bottom once, then
+ * allowing streamed output to consume this blank without further writes. The
+ * footer and spacer together are capped at three quarters of the viewport, so
+ * the reveal always leaves at least one quarter of the transcript visible.
+ * `bottomInsetPx` is layout input only; no content measurement feeds this size.
*/
export function tailSpacerPxForViewport(
clientHeight: number,
bottomInsetPx: number,
): number {
- const pinnedTurnReservePx = clientHeight - bottomInsetPx - PINNED_TURN_MIN_ITEM_HEIGHT_PX;
- return Math.max(0, Math.round(Math.max(pinnedTurnReservePx, tailHoldMaxGapPx(clientHeight))));
+ return Math.max(
+ 0,
+ Math.round(clientHeight * FLOWCHAT_TAIL_RESERVATION_RATIO - bottomInsetPx),
+ );
}
/**
@@ -114,8 +89,11 @@ export function contentEndScrollTop(geometry: TailFollowGeometry): number {
);
}
-export function tailHoldMaxGapPx(clientHeight: number): number {
- return Math.max(0, Math.round(clientHeight * FLOWCHAT_TAIL_HOLD_GAP_RATIO));
+export function tailHoldMaxGapPx(clientHeight: number, tailSpacerPx: number): number {
+ return Math.max(
+ 0,
+ Math.min(Math.round(clientHeight * FLOWCHAT_TAIL_HOLD_GAP_RATIO), tailSpacerPx),
+ );
}
export interface TurnTopAlignmentInput {
@@ -127,8 +105,8 @@ export interface TurnTopAlignmentInput {
/**
* Whether top-aligning a Turn would park the viewport in the reserved blank.
*
- * The blank belongs to follow-output. `pin-turn-top` holds it for output that
- * is on its way, and nothing is on its way under a Turn the user navigated to
+ * The blank belongs to follow-output's new-Turn reveal, and nothing is on its
+ * way under a Turn the user navigated to
* — so a navigation that lands there shows a screen of nothing, and the snap
* back then reclaims it as a second, visible movement.
*
@@ -148,11 +126,6 @@ export function turnTopAlignmentEntersReservedBlank(
/**
* Resolve the next follow target.
*
- * `pin-turn-top` holds a freshly submitted Turn at the viewport top while its
- * answer is still shorter than one viewport, then hands off to `hold-tail` at
- * the crossover. The pinned phase ignores `maxGapPx`: the blank below a new
- * Turn is the point of the mode, not a failure of it.
- *
* `hold-tail` never moves backwards for free. It keeps its previous offset when
* content shrinks, which is what leaves earlier content visually still, and
* only gives ground once the blank below the live output exceeds `maxGapPx`.
@@ -161,20 +134,7 @@ export function nextTailFollowState(
previous: TailFollowState,
input: TailFollowInput,
): TailFollowState {
- if (previous.mode === 'pin-turn-top') {
- if (input.pinScrollTop === null) {
- // The Turn is not measurable yet; behave like a plain tail follow so the
- // viewport is never stranded, and pin once the element resolves.
- return { mode: 'pin-turn-top', target: input.desiredScrollTop };
- }
- if (input.desiredScrollTop < input.pinScrollTop) {
- return { mode: 'pin-turn-top', target: input.pinScrollTop };
- }
- return { mode: 'hold-tail', target: input.desiredScrollTop };
- }
-
return {
- mode: 'hold-tail',
target: Math.max(
input.desiredScrollTop,
Math.min(previous.target, input.desiredScrollTop + input.maxGapPx),
@@ -182,28 +142,6 @@ export function nextTailFollowState(
};
}
-/**
- * The state the follow rule would hold right now, judged from live geometry
- * alone.
- *
- * `hold-tail` normally refuses to move backwards, and that refusal is what
- * keeps a collapse from dragging earlier content down. The memory it refuses
- * with belongs to a viewport the follow rule has been holding continuously;
- * once the user has taken over and come to rest somewhere else there is nothing
- * left to preserve, and carrying the stale offset forward would land them on a
- * position neither side chose.
- *
- * The returned mode still matters: a pinned Turn whose answer has outgrown the
- * viewport reports `hold-tail`, which is how a caller learns the pin has
- * crossed over even though no follow loop was running to notice.
- */
-export function memorylessFollowState(
- mode: TailFollowMode,
- input: TailFollowInput,
-): TailFollowState {
- return nextTailFollowState({ mode, target: input.desiredScrollTop }, input);
-}
-
export type TailDepartureCrossing =
/** The blank is still on screen; nothing has been crossed. */
| 'watching'
@@ -218,12 +156,12 @@ export type TailDepartureCrossing =
*
* Losing the follow permanently is right only for a reader who left the live
* region, and `scrollTop > contentEnd` says they have not. The reserved blank
- * is up to `tailHoldMaxGapPx` under `hold-tail` and the whole gap under a
- * pinned Turn, so a small scroll up can leave the reader looking at empty space
- * below the newest output — nothing hidden from them yet — until output grows
- * past the bottom edge and they silently stop seeing it.
+ * is up to `tailHoldMaxGapPx` under `hold-tail`, while a new-Turn reveal starts
+ * at the physical bottom. A small scroll up can therefore leave the reader
+ * looking at empty space below the newest output — nothing hidden from them yet
+ * — until output grows past the bottom edge and they silently stop seeing it.
*
- * The follow target may sit inside the blank while a new Turn is pinned, but
+ * The reveal position sits inside the blank after a new Turn arrives, but
* this watch deliberately uses the real content end: it only observes output
* catching up with a reader, never a user's resting position.
*
@@ -328,7 +266,7 @@ export interface ViewportAtTailInput {
* Whether the viewport counts as being at the end of the transcript.
*
* The band runs from the content end down to whatever the follow rule owns, so
- * a pinned Turn and a held collapse gap both sit inside it — neither is a
+ * a new-Turn reveal and a held collapse gap both sit inside it — neither is a
* reason to offer a jump to the latest output. Past the lower bound is reserved
* blank, which is.
*/
diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx
index 0dcacb698f..9a321025e4 100644
--- a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx
+++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx
@@ -27,8 +27,8 @@ const BOTTOM_INSET = 100;
/** The spacer the component would render for a given viewport. */
const spacerFor = (clientHeight: number) => tailSpacerPxForViewport(clientHeight, BOTTOM_INSET);
const TAIL_SPACER = spacerFor(VIEWPORT);
-/** Matches `tailHoldMaxGapPx(VIEWPORT)`. */
-const MAX_GAP = 300;
+/** The 60% hold gap is capped by the smaller physical spacer. */
+const MAX_GAP = TAIL_SPACER;
function setScrollerMetrics(
scroller: HTMLElement,
@@ -57,8 +57,7 @@ interface HarnessProps {
isStreaming?: boolean;
scroller: HTMLElement;
scrollToContentEnd?: (behavior: ScrollBehavior) => void;
- scrollTurnToTop?: (turnId: string) => boolean;
- resolveTurnTopScrollTop?: (turnId: string) => number | null;
+ revealNewTurnTail?: (turnId: string) => boolean;
isOpeningViewport?: boolean;
onController: (controller: Controller) => void;
/** The register the hook writes through, for a test that has to hold it. */
@@ -71,8 +70,7 @@ function Harness({
isStreaming = true,
scroller,
scrollToContentEnd = () => {},
- scrollTurnToTop = () => false,
- resolveTurnTopScrollTop = () => null,
+ revealNewTurnTail = () => false,
isOpeningViewport = false,
onController,
onViewportOwner,
@@ -93,8 +91,7 @@ function Harness({
// Sized from live layout, exactly as the component's state does.
getTailSpacerPx: () => tailSpacerPxForViewport(scroller.clientHeight, BOTTOM_INSET),
scrollToContentEnd,
- scrollTurnToTop,
- resolveTurnTopScrollTop,
+ revealNewTurnTail,
isOpeningViewport: () => isOpeningViewport,
viewportOwner,
});
@@ -141,13 +138,16 @@ describe('useFlowChatFollowOutput', () => {
vi.unstubAllGlobals();
});
- it('opens a newly submitted Turn at the viewport top instead of the tail', () => {
- const scrollTurnToTop = vi.fn(() => true);
+ it('reveals a newly submitted Turn at the physical bottom once', () => {
+ const revealNewTurnTail = vi.fn(() => {
+ scroller.scrollTop = 100;
+ return true;
+ });
const scrollToContentEnd = vi.fn();
const props = {
scroller,
scrollToContentEnd,
- scrollTurnToTop,
+ revealNewTurnTail,
onController: (next: Controller) => { controller = next; },
};
@@ -162,7 +162,7 @@ describe('useFlowChatFollowOutput', () => {
root.render();
});
- expect(scrollTurnToTop).toHaveBeenCalledWith('turn-2');
+ expect(revealNewTurnTail).toHaveBeenCalledWith('turn-2');
expect(scrollToContentEnd).not.toHaveBeenCalled();
expect(controller?.isFollowingOutput).toBe(true);
});
@@ -174,17 +174,20 @@ describe('useFlowChatFollowOutput', () => {
* the ledger's last Turn — but the detector asked whether the identity had
* changed, and a truncation changes it without anything having arrived.
*/
- const scrollTurnToTop = vi.fn(() => true);
+ const revealNewTurnTail = vi.fn(() => {
+ scroller.scrollTop = 100;
+ return true;
+ });
const props = {
scroller,
- scrollTurnToTop,
+ revealNewTurnTail,
onController: (next: Controller) => { controller = next; },
};
act(() => {
root.render();
});
- scrollTurnToTop.mockClear();
+ revealNewTurnTail.mockClear();
// The rollback removes turn-2, so the ledger's last Turn is turn-1 again.
act(() => {
@@ -193,7 +196,7 @@ describe('useFlowChatFollowOutput', () => {
);
});
- expect(scrollTurnToTop).not.toHaveBeenCalled();
+ expect(revealNewTurnTail).not.toHaveBeenCalled();
});
it('returns to the end of the transcript when a rollback takes the Turn it was following', () => {
@@ -201,7 +204,7 @@ describe('useFlowChatFollowOutput', () => {
const props = {
scroller,
scrollToContentEnd,
- scrollTurnToTop: () => true,
+ revealNewTurnTail: () => true,
onController: (next: Controller) => { controller = next; },
};
@@ -331,76 +334,101 @@ describe('useFlowChatFollowOutput', () => {
expect(1000).toBeLessThan(1000 + MAX_GAP);
});
- it('falls back to the content end when the new Turn cannot be targeted', () => {
+ it('defers the reveal when the new Turn is not in the live-tail projection yet', () => {
const scrollToContentEnd = vi.fn();
+ const revealNewTurnTail = vi.fn(() => false);
const props = {
scroller,
scrollToContentEnd,
- scrollTurnToTop: () => false,
+ revealNewTurnTail,
onController: (next: Controller) => { controller = next; },
};
act(() => {
root.render();
});
+ scrollToContentEnd.mockClear();
act(() => {
root.render();
});
- expect(scrollToContentEnd).toHaveBeenCalledWith('auto');
+ expect(revealNewTurnTail).toHaveBeenCalledWith('turn-2');
+ expect(scrollToContentEnd).not.toHaveBeenCalled();
expect(controller?.isFollowingOutput).toBe(true);
});
- it('holds the pinned Turn at the top while its answer is shorter than the viewport', () => {
- // Real content ends at 1200, so the tail target is 700 — well above the
- // pinned Turn at 900. The pin must win until the answer overflows.
+ it('reveals a new Turn at physical bottom once and lets growth consume the blank', () => {
setScrollerMetrics(scroller, {
scrollHeight: 1200 + TAIL_SPACER,
clientHeight: VIEWPORT,
- scrollTop: 900,
+ scrollTop: 700,
+ });
+ const revealNewTurnTail = vi.fn(() => {
+ scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight;
+ return true;
});
const props = {
scroller,
- scrollTurnToTop: () => true,
- resolveTurnTopScrollTop: () => 900,
+ revealNewTurnTail,
onController: (next: Controller) => { controller = next; },
};
act(() => {
- root.render();
+ root.render();
});
act(() => {
root.render();
});
- scroller.scrollTop = 0;
- runNextFrame();
- expect(scroller.scrollTop).toBe(900);
+ expect(revealNewTurnTail).toHaveBeenCalledTimes(1);
+ expect(scroller.scrollTop).toBe(700 + TAIL_SPACER);
+
+ setScrollerMetrics(scroller, {
+ scrollHeight: 1300 + TAIL_SPACER,
+ clientHeight: VIEWPORT,
+ scrollTop: 700 + TAIL_SPACER,
+ });
+ act(() => controller?.scheduleFollowToLatest());
+ expect(scroller.scrollTop).toBe(700 + TAIL_SPACER);
});
- it('hands the pinned Turn off to tail follow once the answer overflows', () => {
+ it('starts following without a snap when streamed output consumes the reveal blank', () => {
setScrollerMetrics(scroller, {
- scrollHeight: 2000 + TAIL_SPACER,
+ scrollHeight: 1200 + TAIL_SPACER,
clientHeight: VIEWPORT,
- scrollTop: 900,
+ scrollTop: 700,
});
+ const revealNewTurnTail = () => {
+ scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight;
+ return true;
+ };
const props = {
scroller,
- scrollTurnToTop: () => true,
- resolveTurnTopScrollTop: () => 900,
+ revealNewTurnTail,
onController: (next: Controller) => { controller = next; },
};
act(() => {
- root.render();
+ root.render();
});
act(() => {
root.render();
});
+ const revealPosition = 700 + TAIL_SPACER;
+ setScrollerMetrics(scroller, {
+ scrollHeight: 1200 + TAIL_SPACER + TAIL_SPACER + 10,
+ clientHeight: VIEWPORT,
+ scrollTop: revealPosition,
+ });
+ act(() => controller?.scheduleFollowToLatest());
+ expect(scroller.scrollTop).toBe(revealPosition);
+
+ // The first queued frame may be the cancelled opening settle. The next one
+ // is ordinary following and advances from the unchanged reveal position.
+ runNextFrame();
runNextFrame();
- // Content end (2000 - 500) has overtaken the pin, so the tail owns it.
- expect(scroller.scrollTop).toBe(1500);
+ expect(scroller.scrollTop).toBeGreaterThan(revealPosition);
});
it('follows content growth against the content end, not the tail spacer', () => {
@@ -458,7 +486,7 @@ describe('useFlowChatFollowOutput', () => {
});
runNextFrame();
- expect(scroller.scrollTop).toBe(1000);
+ expect(scroller.scrollTop).toBe(1000 - (300 - MAX_GAP));
});
it('gives ground only past the tolerated gap after a very large collapse', () => {
@@ -516,7 +544,7 @@ describe('useFlowChatFollowOutput', () => {
act(() => controller?.scheduleFollowToLatest());
expect(scrollToContentEnd).not.toHaveBeenCalled();
- expect(scroller.scrollTop).toBe(1000);
+ expect(scroller.scrollTop).toBe(1000 - (300 - MAX_GAP));
});
it('settles the held blank once streaming stops', () => {
@@ -887,44 +915,21 @@ describe('useFlowChatFollowOutput', () => {
});
});
- describe('jumping to latest while the newest Turn is pinned', () => {
- /** Pins `turn-2` at 900 with real content ending at 1200 (tail target 700). */
- function pinLatestTurn(overrides?: { resolveTurnTopScrollTop?: () => number | null }) {
- setScrollerMetrics(scroller, {
- scrollHeight: 1200 + TAIL_SPACER,
- clientHeight: VIEWPORT,
- scrollTop: 900,
- });
- const props = {
- scroller,
- scrollTurnToTop: () => true,
- resolveTurnTopScrollTop: overrides?.resolveTurnTopScrollTop ?? (() => 900),
- onController: (next: Controller) => { controller = next; },
- };
- act(() => {
- root.render();
- });
- act(() => {
- root.render();
- });
- }
-
- it('returns to the pin rather than the end of content', () => {
- // Restoring the tail presentation asks for a jump to latest one frame
- // after the Turn that caused it got pinned, which used to overwrite the
- // pin. Aiming at the content end here also scrolls *up*, shoving the
- // message the user just sent into the middle of the viewport.
+ describe('new Turn reveal ownership', () => {
+ function revealLatestTurn() {
const scrollToContentEnd = vi.fn();
setScrollerMetrics(scroller, {
scrollHeight: 1200 + TAIL_SPACER,
clientHeight: VIEWPORT,
- scrollTop: 900,
+ scrollTop: 700,
});
const props = {
scroller,
scrollToContentEnd,
- scrollTurnToTop: () => true,
- resolveTurnTopScrollTop: () => 900,
+ revealNewTurnTail: () => {
+ scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight;
+ return true;
+ },
onController: (next: Controller) => { controller = next; },
};
act(() => {
@@ -934,60 +939,25 @@ describe('useFlowChatFollowOutput', () => {
act(() => {
root.render();
});
+ return scrollToContentEnd;
+ }
- act(() => controller?.enterFollowOutput('jump-to-latest'));
-
- expect(scrollToContentEnd).not.toHaveBeenCalled();
- scroller.scrollTop = 0;
- runNextFrame();
- expect(scroller.scrollTop).toBe(900);
- });
-
- it('animates back to the pin instead of jumping', () => {
- // The frame loop assigns scrollTop outright, so without the yield budget
- // this branch was an instant move where every other jump to latest is
- // animated.
- pinLatestTurn();
- scroller.scrollTop = 0;
- scrollTo.mockClear();
+ it('ignores a delayed jump-to-latest while the one-shot reveal is active', () => {
+ const scrollToContentEnd = revealLatestTurn();
+ const revealPosition = scroller.scrollTop;
act(() => controller?.enterFollowOutput('jump-to-latest'));
- expect(scrollTo).toHaveBeenCalledWith({ top: 900, behavior: 'smooth' });
- runNextFrame();
- // jsdom does not animate, so the loop must have left the viewport alone.
- expect(scroller.scrollTop).toBe(0);
+ expect(scrollToContentEnd).not.toHaveBeenCalled();
+ expect(scroller.scrollTop).toBe(revealPosition);
});
- it('resumes at the content end once the pin has been retired', () => {
- // The exemption is only for a Turn whose answer still fits one viewport.
- // Past the crossover the pin is gone and the ordinary rule applies.
- const scrollToContentEnd = vi.fn();
- setScrollerMetrics(scroller, {
- scrollHeight: 2000 + TAIL_SPACER,
- clientHeight: VIEWPORT,
- scrollTop: 900,
- });
- const props = {
- scroller,
- scrollToContentEnd,
- scrollTurnToTop: () => true,
- resolveTurnTopScrollTop: () => 900,
- onController: (next: Controller) => { controller = next; },
- };
- act(() => {
- root.render();
- });
- act(() => {
- root.render();
- });
- // Content end (1500) has overtaken the pin, which retires it.
- runNextFrame();
- scrollToContentEnd.mockClear();
+ it('releases reveal ownership on user intent', () => {
+ revealLatestTurn();
- act(() => controller?.enterFollowOutput('jump-to-latest'));
+ act(() => controller?.handleUserScrollIntent());
- expect(scrollToContentEnd).toHaveBeenCalledWith('smooth');
+ expect(controller?.isFollowingOutput).toBe(false);
});
});
@@ -1387,11 +1357,11 @@ describe('useFlowChatFollowOutput', () => {
expect(controller?.isFollowingOutput).toBe(false);
// Back down until the blank is on screen again, and stop.
- scroller.scrollTop = CONTENT_END + 300;
+ scroller.scrollTop = CONTENT_END + 250;
act(() => controller?.handleScroll());
expect(controller?.isFollowingOutput).toBe(false);
- setContentEnd(CONTENT_END + 300);
+ setContentEnd(CONTENT_END + 250);
act(() => controller?.scheduleFollowToLatest());
expect(controller?.isFollowingOutput).toBe(true);
@@ -1403,9 +1373,9 @@ describe('useFlowChatFollowOutput', () => {
departWithBlank(0);
act(() => controller?.handleUserScrollIntent());
- scroller.scrollTop = CONTENT_END + 300;
+ scroller.scrollTop = CONTENT_END + 250;
act(() => controller?.handleScroll());
- setContentEnd(CONTENT_END + 300);
+ setContentEnd(CONTENT_END + 250);
act(() => controller?.scheduleFollowToLatest());
expect(controller?.isFollowingOutput).toBe(true);
diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts
index 0cf4c56a61..1cc71d4ad0 100644
--- a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts
+++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts
@@ -19,7 +19,6 @@ import {
contentEndScrollTop,
FLOWCHAT_AT_CONTENT_END_THRESHOLD_PX,
isTailBlankMeasurable,
- memorylessFollowState,
nextTailFollowState,
resolveAnimatedJumpBehavior,
resolveTailDepartureCrossing,
@@ -44,18 +43,22 @@ export type FollowOutputExitReason =
/**
* Why a watch over a reader who owns the viewport ended.
*
- * All four are follow taking it back or the transcript going away. A *crossing*
- * is deliberately not on this list: the reader can climb out of the reserved
- * blank and scroll back down into it any number of times before either happens,
- * and each of those is another chance for output to catch up with them.
+ * These are follow taking it back, a reader replacing a reveal, or the
+ * transcript going away. A *crossing* is deliberately not on this list: the
+ * reader can climb out of the reserved blank and scroll back down into it any
+ * number of times before either happens, and each is another chance for output
+ * to catch up with them.
*/
type TailWatchOutcome =
/** Something handed the viewport back — a new Turn, a jump, a snap, this. */
| 'followed-again'
| 'navigated'
+ | 'reader-took-over'
| 'session-changed'
| 'unmounted';
+type TailWatchOrigin = 'new-turn-reveal' | 'user-departure';
+
interface UseFlowChatFollowOutputOptions {
activeSessionId?: string;
latestTurnId: string | null;
@@ -75,10 +78,8 @@ interface UseFlowChatFollowOutputOptions {
getTailSpacerPx: () => number;
/** One-shot scroll placing the end of real content at the viewport bottom. */
scrollToContentEnd: (behavior: ScrollBehavior) => void;
- /** One-shot scroll placing a Turn's user message at the viewport top. */
- scrollTurnToTop: (turnId: string) => boolean;
- /** Offset that would place a Turn's user message at the viewport top, if rendered. */
- resolveTurnTopScrollTop: (turnId: string) => number | null;
+ /** Reveal a rendered new Turn by placing the viewport at the physical bottom once. */
+ revealNewTurnTail: (turnId: string) => boolean;
/** True while the transcript is still hidden for the opening reveal. */
isOpeningViewport: () => boolean;
/**
@@ -138,13 +139,6 @@ interface UseFlowChatFollowOutputResult {
const BOTTOM_EPSILON_PX = 2;
-/**
- * Frames a pinned Turn may stay unmeasurable before the pin is abandoned.
- * The virtualizer renders the tail immediately, so this only guards against a Turn
- * that never mounts at all.
- */
-const PIN_RESOLVE_MAX_ATTEMPTS = 30;
-
/**
* How long a programmatic smooth scroll of ours may own the viewport before the
* follow loop resumes writing.
@@ -211,8 +205,7 @@ export function useFlowChatFollowOutput({
scrollerRef,
getTailSpacerPx,
scrollToContentEnd,
- scrollTurnToTop,
- resolveTurnTopScrollTop,
+ revealNewTurnTail,
isOpeningViewport,
viewportOwner,
viewportId = 0,
@@ -231,10 +224,8 @@ export function useFlowChatFollowOutput({
const hasMountedRef = useRef(false);
const wasStreamingRef = useRef(isStreaming);
- const followStateRef = useRef({ mode: 'hold-tail', target: 0 });
- const pinTurnIdRef = useRef(null);
- const pinScrollTopRef = useRef(null);
- const pinAttemptsRef = useRef(0);
+ const followStateRef = useRef({ target: 0 });
+ const followPhaseRef = useRef<'idle' | 'revealing-tail' | 'following-tail'>('idle');
/**
* A Turn that arrived while it was not in the transcript on screen.
*
@@ -242,7 +233,7 @@ export function useFlowChatFollowOutput({
* Turn a beat before the presentation is restored to the live tail, so the
* one moment the arrival is *detectable* is not a moment it can be answered.
* The answer is deferred rather than dropped — kept until the Turn can
- * actually be aligned, which is what the reader is waiting to see.
+ * actually be revealed, which is what the reader is waiting to see.
*/
const pendingNewTurnIdRef = useRef(null);
const settleFramesRef = useRef(0);
@@ -311,73 +302,15 @@ export function useFlowChatFollowOutput({
})
), [getTailSpacerPx]);
- /**
- * Forget which Turn is pinned.
- *
- * This is the pin's *identity*, not its activity. A user takeover suspends
- * the pin, while an explicit jump to latest may still return to it. Only
- * three things retire a pin: the crossover to `hold-tail`, a newer Turn
- * replacing it, and the session changing. The crossover is one-way by
- * construction, since nothing re-pins a Turn whose identity has been
- * dropped; a card collapse that pulls content back under one viewport must
- * not resurrect the pin.
- */
- const retirePin = useCallback(() => {
- pinTurnIdRef.current = null;
- pinScrollTopRef.current = null;
- pinAttemptsRef.current = 0;
- }, []);
-
- /**
- * Resolve the pinned Turn's offset from live layout every frame rather than
- * caching it once. Unrendered items are estimates until measured, and every
- * measurement shifts the absolute offsets below it; a cached pin would drift.
- */
- const readPinScrollTop = useCallback((): number | null => {
- const pinTurnId = pinTurnIdRef.current;
- if (!pinTurnId) {
- return null;
- }
-
- const resolved = resolveTurnTopScrollTop(pinTurnId);
- if (resolved !== null) {
- pinScrollTopRef.current = resolved;
- pinAttemptsRef.current = 0;
- return resolved;
- }
-
- if (pinScrollTopRef.current === null) {
- pinAttemptsRef.current += 1;
- if (pinAttemptsRef.current >= PIN_RESOLVE_MAX_ATTEMPTS) {
- retirePin();
- }
- }
- return pinScrollTopRef.current;
- }, [retirePin, resolveTurnTopScrollTop]);
-
/**
* The state the follow rule would hold for the current geometry, ignoring any
* offset it was holding. Used to resolve explicit follow targets and to
* resume on the live content end.
- *
- * Retires a pin that has crossed over on the way past: with no frame loop
- * running, this is the only place that crossover can be noticed.
*/
const resolveFollowState = useCallback((scroller: HTMLElement): TailFollowState => {
const desired = readContentEndScrollTop(scroller);
- const next = memorylessFollowState(
- pinTurnIdRef.current ? 'pin-turn-top' : 'hold-tail',
- {
- desiredScrollTop: desired,
- pinScrollTop: readPinScrollTop(),
- maxGapPx: tailHoldMaxGapPx(scroller.clientHeight),
- },
- );
- if (next.mode === 'hold-tail') {
- retirePin();
- }
- return next;
- }, [readContentEndScrollTop, readPinScrollTop, retirePin]);
+ return { target: desired };
+ }, [readContentEndScrollTop]);
const resolveFollowTargetScrollTop = useCallback((scroller: HTMLElement) => (
resolveFollowState(scroller).target
@@ -387,12 +320,10 @@ export function useFlowChatFollowOutput({
* ---------------------------------------------------------------------------
* A viewport in the reader's hands, watched for output catching up with them.
*
- * Scrolling up gives the follow away, and that is right only if the reader
- * actually left the live region. They may not have. The reserved blank is up
- * to 60% of a viewport under `hold-tail` and the whole gap under a pinned
- * Turn, so a small scroll up can leave the reader still looking at empty space
- * below the newest output — nothing is being hidden from them yet — and then
- * output grows past the bottom edge and they silently stop seeing it.
+ * The same crossing has two origins. A new Turn reveal deliberately starts in
+ * the resident blank while follow-output still owns the viewport; a user
+ * departure starts after a gesture releases it. In both cases output reaching
+ * the fixed viewport bottom is the event that may start ordinary tail follow.
*
* "Still looking at the blank" is `scrollTop > contentEnd`, because
* `contentEnd` is by definition the offset that puts the end of real content
@@ -416,12 +347,12 @@ export function useFlowChatFollowOutput({
* ---------------------------------------------------------------------------
*/
const tailWatchRef = useRef<{
+ origin: TailWatchOrigin;
openedAtMs: number;
- /** Blank on screen when the reader took the viewport. */
+ /** Blank on screen when the watch opened. */
exitBlankPx: number;
exitScrollTopPx: number;
exitContentEndPx: number;
- exitPinned: boolean;
exitStreaming: boolean;
/** Previous sample, so a crossing can be attributed to whatever moved. */
lastScrollTopPx: number;
@@ -446,9 +377,12 @@ export function useFlowChatFollowOutput({
: watch.lastContentEndPx;
traceViewport({
location: 'followOutput.tailWatchEnded',
- message: 'follow-output has the viewport back',
+ message: outcome === 'reader-took-over'
+ ? 'the reader replaced the passive new Turn reveal'
+ : 'the tail watch ended',
data: () => ({
outcome,
+ origin: watch.origin,
viewportId,
forMs: Math.round(performance.now() - watch.openedAtMs),
samples: watch.samples,
@@ -458,7 +392,6 @@ export function useFlowChatFollowOutput({
// The two movements, over the whole life of the watch.
contentGrewPx: roundViewportPx(contentEndPx - watch.exitContentEndPx),
readerMovedPx: roundViewportPx(scrollTopPx - watch.exitScrollTopPx),
- pinnedAtExit: watch.exitPinned,
streamingAtExit: watch.exitStreaming,
streamingNow: isStreamingRef.current,
...(extra ?? {}),
@@ -487,33 +420,33 @@ export function useFlowChatFollowOutput({
* blank at any point afterwards, and the whole question is where they are when
* output next reaches the bottom edge.
*/
- const openTailWatch = useCallback(() => {
+ const openTailWatch = useCallback((origin: TailWatchOrigin) => {
const scroller = scrollerRef.current;
if (!scroller) return;
const contentEndPx = readContentEndScrollTop(scroller);
const blankPx = scroller.scrollTop - contentEndPx;
- const pinned = pinTurnIdRef.current !== null;
traceViewport({
location: 'followOutput.tailWatch',
- message: 'the reader has the viewport, and is watched for output catching up',
+ message: origin === 'new-turn-reveal'
+ ? 'the new Turn is revealed in the blank and watched for output catching up'
+ : 'the reader has the viewport and is watched for output catching up',
data: () => ({
+ origin,
viewportId,
blankPx: roundViewportPx(blankPx),
blankVisible: blankPx > 0,
scrollTopPx: roundViewportPx(scroller.scrollTop),
contentEndPx: roundViewportPx(contentEndPx),
clientHeightPx: scroller.clientHeight,
- // A pin is the case where the gap between the two predicates is widest.
- pinned,
isStreaming: isStreamingRef.current,
}),
});
tailWatchRef.current = {
+ origin,
openedAtMs: performance.now(),
exitBlankPx: blankPx,
exitScrollTopPx: scroller.scrollTop,
exitContentEndPx: contentEndPx,
- exitPinned: pinned,
exitStreaming: isStreamingRef.current,
lastScrollTopPx: scroller.scrollTop,
lastContentEndPx: contentEndPx,
@@ -641,18 +574,13 @@ export function useFlowChatFollowOutput({
* output is about to fill it.
*/
const previous: TailFollowState = isOpeningViewport()
- ? { mode: remembered.mode, target: desired }
+ ? { target: desired }
: remembered;
- const pin = readPinScrollTop();
const next = nextTailFollowState(previous, {
desiredScrollTop: desired,
- pinScrollTop: pin,
- maxGapPx: tailHoldMaxGapPx(scroller.clientHeight),
+ maxGapPx: tailHoldMaxGapPx(scroller.clientHeight, getTailSpacerPx()),
});
followStateRef.current = next;
- if (next.mode === 'hold-tail') {
- retirePin();
- }
// Content is still moving, so keep the settle window open.
if (Math.abs(next.target - previous.target) > BOTTOM_EPSILON_PX) {
settleFramesRef.current = SETTLE_FRAMES;
@@ -675,7 +603,7 @@ export function useFlowChatFollowOutput({
// the key would be a string built sixty times a second for a switch that
// is off.
traceViewportRepeating(
- `follow|frame|${onTarget}|${isOpeningViewport()}|${next.mode}`,
+ `follow|frame|${onTarget}|${isOpeningViewport()}`,
{
location: 'followOutput.frame',
message: onTarget
@@ -685,11 +613,10 @@ export function useFlowChatFollowOutput({
data: () => ({
viewportId,
onTarget,
- mode: next.mode,
+ phase: followPhaseRef.current,
isOpening: isOpeningViewport(),
desiredPx: roundViewportPx(desired),
targetPx: roundViewportPx(next.target),
- pinPx: pin === null ? null : roundViewportPx(pin),
scrollTopPx: roundViewportPx(scroller.scrollTop),
scrollRangePx: roundViewportPx(scroller.scrollHeight),
settleFrames: settleFramesRef.current,
@@ -791,10 +718,9 @@ export function useFlowChatFollowOutput({
}
}, [
endSmoothScrollYield,
+ getTailSpacerPx,
isOpeningViewport,
readContentEndScrollTop,
- readPinScrollTop,
- retirePin,
scrollerRef,
viewportId,
viewportOwner,
@@ -814,6 +740,8 @@ export function useFlowChatFollowOutput({
*/
const standDownReason = !isFollowingOutputRef.current
? 'not-following'
+ : followPhaseRef.current !== 'following-tail'
+ ? 'revealing-tail'
: !isViewportActiveRef.current
? 'viewport-inactive'
: isViewportSuspendedRef.current()
@@ -853,6 +781,7 @@ export function useFlowChatFollowOutput({
if (
followFrameRef.current === null &&
isFollowingOutputRef.current &&
+ followPhaseRef.current === 'following-tail' &&
!isViewportSuspendedRef.current() &&
(isStreamingRef.current || settleFramesRef.current > 0)
) {
@@ -881,22 +810,22 @@ export function useFlowChatFollowOutput({
return;
}
- /*
- * A newly submitted Turn opens at the viewport top, and that is the whole
- * of the answer to one arriving. Until it is in the transcript on screen
- * there is nothing to align, and the fallback below — the end of real
- * content — is not a stand-in for it: it would leave the Turn unpinned
- * where the reader was, or pull them out of a history window entirely.
- *
- * So the answer waits for the Turn instead. Resolved before ownership
- * changes hands, because waiting has to leave the viewport exactly as it
- * was found.
- */
- const pinTurnId = reason === 'new-turn' ? latestTurnIdRef.current : null;
- const pinnedTurnToTop = pinTurnId !== null && scrollTurnToTop(pinTurnId);
+ if (reason === 'jump-to-latest' && followPhaseRef.current === 'revealing-tail') {
+ traceViewportRepeating('follow|jumpIgnored|revealing-tail', {
+ location: 'followOutput.jumpIgnored',
+ message: 'jump to latest was already satisfied by the new Turn reveal',
+ data: () => ({ viewportId }),
+ });
+ return;
+ }
+
+ /* A new Turn may arrive before the live-tail projection contains it. The
+ * one detectable arrival is retained until its one-shot reveal can run. */
+ const revealTurnId = reason === 'new-turn' ? latestTurnIdRef.current : null;
+ const revealedNewTurn = revealTurnId !== null && revealNewTurnTail(revealTurnId);
if (reason === 'new-turn') {
- pendingNewTurnIdRef.current = pinnedTurnToTop ? null : pinTurnId;
- if (!pinnedTurnToTop) {
+ pendingNewTurnIdRef.current = revealedNewTurn ? null : revealTurnId;
+ if (!revealedNewTurn) {
/*
* The viewport is deliberately left exactly as it was, so the only
* evidence that a submission was answered at all is this line. A
@@ -905,8 +834,8 @@ export function useFlowChatFollowOutput({
*/
traceViewportRepeating('follow|deferred-new-turn', {
location: 'followOutput.deferNewTurn',
- message: 'new Turn is not in the transcript on screen yet, so the answer waits',
- data: () => ({ turnId: pinTurnId }),
+ message: 'new Turn is not in the transcript on screen yet, so the reveal waits',
+ data: () => ({ turnId: revealTurnId }),
});
return;
}
@@ -925,7 +854,7 @@ export function useFlowChatFollowOutput({
data: () => ({
reason,
viewportId,
- pinnedTurnId: pinnedTurnToTop ? pinTurnId : pinTurnIdRef.current,
+ phase: revealedNewTurn ? 'revealing-tail' : 'following-tail',
isStreaming: isStreamingRef.current,
scrollTopPx: roundViewportPx(scrollerRef.current?.scrollTop ?? 0),
}),
@@ -942,62 +871,18 @@ export function useFlowChatFollowOutput({
const scroller = scrollerRef.current;
const contentEnd = scroller ? readContentEndScrollTop(scroller) : 0;
- /*
- * A pin on the newest Turn already satisfies "show me the latest output".
- * The mode only holds while that Turn's answer is shorter than one
- * viewport, so everything it has produced is on screen; re-aiming at the
- * content end would scroll *up* and shove the message the user just sent
- * into the middle of the viewport.
- *
- * This fires for real: restoring the tail presentation asks for a jump to
- * latest one frame after the Turn that caused it got pinned, which
- * overwrote the pin every time.
- */
- if (
- reason === 'jump-to-latest' &&
- pinTurnIdRef.current !== null &&
- pinTurnIdRef.current === latestTurnIdRef.current
- ) {
- const pinTarget = readPinScrollTop() ?? scroller?.scrollTop ?? contentEnd;
- followStateRef.current = { mode: 'pin-turn-top', target: pinTarget };
- // Travels like every other jump to latest, animated or not. The frame
- // loop would cancel an animation on its next tick, so it stands down for
- // this one exactly as it does for `runContentEndScroll` — and an instant
- // write of ours replaces whatever was still travelling.
- if (scroller && Math.abs(scroller.scrollTop - pinTarget) > BOTTOM_EPSILON_PX) {
- const behavior = resolveJumpBehavior(scroller, pinTarget);
- if (behavior === 'smooth') {
- beginSmoothScrollYield();
- } else {
- endSmoothScrollYield('superseded');
- }
- viewportOwner.write({
- owner: 'follow-output',
- topPx: pinTarget,
- behavior,
- });
- }
- startFollowFrame();
+ if (revealedNewTurn && scroller && scroller.scrollTop > contentEnd) {
+ followPhaseRef.current = 'revealing-tail';
+ endSmoothScrollYield('superseded');
+ followStateRef.current = { target: scroller.scrollTop };
+ stopFollowFrame();
+ openTailWatch('new-turn-reveal');
return;
}
- // Every other entry reason resumes at the end of real content.
- if (pinnedTurnToTop) {
- pinTurnIdRef.current = pinTurnId;
- pinScrollTopRef.current = null;
- pinAttemptsRef.current = 0;
- endSmoothScrollYield('superseded');
- followStateRef.current = { mode: 'pin-turn-top', target: scroller?.scrollTop ?? contentEnd };
- } else {
- /*
- * The pin is dropped here even when the departure happened under one, and
- * that is deliberate. The pin's reservation is the blank the reader just
- * scrolled out of; restoring it would pull them back down to the offset
- * they left. Following the content end keeps them where they put
- * themselves and shows what arrives below.
- */
- retirePin();
- followStateRef.current = { mode: 'hold-tail', target: contentEnd };
+ followPhaseRef.current = 'following-tail';
+ followStateRef.current = { target: contentEnd };
+ {
/*
* Output that caught up with a stationary reader is already at the end —
* the blank between them closing is what raised this — so the whole
@@ -1023,25 +908,20 @@ export function useFlowChatFollowOutput({
startFollowFrame();
}, [
viewportOwner,
- beginSmoothScrollYield,
closeTailWatch,
endSmoothScrollYield,
+ openTailWatch,
readContentEndScrollTop,
- readPinScrollTop,
+ revealNewTurnTail,
resolveJumpBehavior,
- retirePin,
runContentEndScroll,
- scrollTurnToTop,
scrollerRef,
startFollowFrame,
+ stopFollowFrame,
viewportId,
]);
- /**
- * Release the viewport without forgetting the pin. The user owns it from
- * here; the pin stays on record so an explicit jump to latest can restore
- * the mode rather than fall through to the tail.
- */
+ /** Release the viewport. A user gesture replaces any reveal watch with a reader watch. */
const exitFollowOutput = useCallback((reason: FollowOutputExitReason) => {
/*
* Traced whether or not there was anything to give up. An exit that finds
@@ -1058,11 +938,12 @@ export function useFlowChatFollowOutput({
reason,
viewportId,
wasFollowing: isFollowingOutputRef.current,
- pinnedTurnId: pinTurnIdRef.current,
+ phase: followPhaseRef.current,
scrollTopPx: roundViewportPx(scrollerRef.current?.scrollTop ?? 0),
}),
});
isFollowingOutputRef.current = false;
+ followPhaseRef.current = 'idle';
setIsFollowingOutput(false);
endSmoothScrollYield('superseded');
viewportOwner.release('follow-output');
@@ -1090,8 +971,11 @@ export function useFlowChatFollowOutput({
// watch starts again from there rather than carrying offsets across it.
closeTailWatch('navigated', { navigationReason: reason });
}
+ if (reason === 'user-scroll' && tailWatchRef.current?.origin === 'new-turn-reveal') {
+ closeTailWatch('reader-took-over');
+ }
if (tailWatchRef.current === null) {
- openTailWatch();
+ openTailWatch('user-departure');
}
}, [
closeTailWatch,
@@ -1178,6 +1062,7 @@ export function useFlowChatFollowOutput({
: 'the blank closed under the reader, and follow leaves it alone',
data: () => ({
crossing,
+ origin: watch.origin,
resumed,
gestureLive,
viewportId,
@@ -1210,6 +1095,16 @@ export function useFlowChatFollowOutput({
*/
const scheduleFollowToLatest = useCallback(() => {
if (isViewportSuspendedRef.current()) return;
+ if (
+ isFollowingOutputRef.current
+ && isViewportActiveRef.current
+ && followPhaseRef.current === 'revealing-tail'
+ ) {
+ // The reveal is intentionally passive: streamed growth consumes the
+ // visible blank while scrollTop stays fixed. Only the crossing is sampled.
+ sampleTailWatch();
+ return;
+ }
if (!isFollowingOutputRef.current || !isViewportActiveRef.current) {
/*
* This is the transcript's content-change signal — the resize observer
@@ -1236,9 +1131,8 @@ export function useFlowChatFollowOutput({
*
* The ledger cannot say this on its own — a shorter `dialogTurns` is also
* what a window re-cut and a hydration merge look like — so the rollback
- * announces it, the same way a submission does. What it asks for is the
- * *absence* of the pin: the Turn that was pinned is one of the ones that just
- * stopped existing, and the transcript now ends somewhere else.
+ * announces it, the same way a submission does. The transcript now ends
+ * somewhere else, so the answer is an ordinary content-end placement.
*
* This takes the viewport whether or not follow owned it. A rollback at
* Turn N removes N and everything after it, and the reader had N on screen —
@@ -1370,7 +1264,6 @@ export function useFlowChatFollowOutput({
previousLatestTurnIdRef.current = latestTurnId;
previousDialogTurnCountRef.current = dialogTurnCount;
exitFollowOutput('session-changed');
- retirePin();
// A Turn waiting to be shown belongs to the session that gained it.
pendingNewTurnIdRef.current = null;
if (virtualItemCount > 0) {
@@ -1383,7 +1276,7 @@ export function useFlowChatFollowOutput({
* An arrival, not a change. `latestTurnId` is the ledger's last Turn and it
* is the right identity — but a rollback truncates the ledger, which moves
* that identity *backwards* onto a Turn that has been there all along. Read
- * as an arrival it pinned the survivor to the viewport top, which is the
+ * as an arrival it revealed the survivor as though it were new, which is the
* reader's "I undid my message and it jumped to the one before it".
*
* The ledger growing is what separates the two. Nothing else that rewrites
@@ -1408,8 +1301,8 @@ export function useFlowChatFollowOutput({
}
/*
* The transcript changed without a new Turn, which is the moment a deferred
- * one can become alignable — the presentation being restored to the live
- * tail is exactly that. A retry that still cannot align it leaves the
+ * one can become revealable — the presentation being restored to the live
+ * tail is exactly that. A retry that still cannot reveal it leaves the
* viewport alone and stays pending.
*/
if (pendingNewTurnIdRef.current === latestTurnId && latestTurnId !== null) {
@@ -1422,7 +1315,6 @@ export function useFlowChatFollowOutput({
exitFollowOutput,
isStreaming,
latestTurnId,
- retirePin,
virtualItemCount,
]);
@@ -1437,14 +1329,17 @@ export function useFlowChatFollowOutput({
}, [isFollowingOutput, isStreaming, isViewportActive, scheduleFollowToLatest, stopFollowFrame]);
// Settle any blank the hold rule accumulated once output stops arriving.
- // A pinned Turn keeps its blank: that space is the mode, not a leftover.
+ // A short new-Turn reveal keeps its blank and its fixed viewport position.
useEffect(() => {
const wasStreaming = wasStreamingRef.current;
wasStreamingRef.current = isStreaming;
if (wasStreaming === isStreaming || isStreaming) {
return;
}
- if (!isFollowingOutputRef.current || followStateRef.current.mode !== 'hold-tail') {
+ if (
+ !isFollowingOutputRef.current
+ || followPhaseRef.current !== 'following-tail'
+ ) {
return;
}
@@ -1454,7 +1349,7 @@ export function useFlowChatFollowOutput({
}
const contentEnd = readContentEndScrollTop(scroller);
if (followStateRef.current.target - contentEnd > BOTTOM_EPSILON_PX) {
- followStateRef.current = { mode: 'hold-tail', target: contentEnd };
+ followStateRef.current = { target: contentEnd };
runContentEndScroll('smooth');
}
}, [isStreaming, readContentEndScrollTop, runContentEndScroll, scrollerRef]);