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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,19 @@ restores it after the list remeasures. While an element anchor is active, the
coordinator also owns virtualizer compensation corrections, so independent
scroll writers cannot fight the pinned header.

The logical `isFollowingOutput` flag follows the same ownership rule: it is
only true while the coordinator owns `following-tail`. A `sticky-latest` pin
clears the flag and arms its turn for handoff. Once collapse protection and
unsettled pin growth have drained, the handoff re-enters tail follow when
either the pin reservation is empty or the natural content tail (excluding
Footer reservations) reaches the viewport bottom. The latter condition avoids
making real content grow through stale synthetic pin space before follow can
start. This prevents a stale React render from allowing follow effects to
overwrite a pinned header.
The armed turn identity is owned only by `useFlowChatFollowOutput`; the list
must not mirror it in a second ref because session resume and pin preparation
can otherwise update the two identities in different commits.

Collapse anchors have three phases: active while CSS layout is changing,
retained-provisional while delayed virtualizer measurements may still arrive,
and settled-grace after the provisional estimate has been reconciled to current
Expand Down Expand Up @@ -193,10 +206,13 @@ source of truth because integer `scrollHeight` can overstate the browser's
subpixel scroll limit.

Physical-bottom synchronization must yield whenever the coordinator owns an
element anchor. A sticky pin intentionally sits at the physical bottom created
by its reservation; treating that geometry as tail-follow causes every content
growth measurement to push the pinned header upward before the coordinator can
restore it.
element anchor. It also yields while streaming `following-tail` owns the
viewport, because the single tail loop is the writer for content-growth motion.
A sticky pin intentionally sits at the physical bottom created by its
reservation; treating that geometry as tail-follow causes every content growth
measurement to push the pinned header upward before the coordinator can restore
it. Pinned, anchored, and non-streaming paths keep the normal physical-bottom
synchronization behavior.

Sticky pin floors are not reduced from a transient target rect. Positive
effective content growth first enters a short settlement ledger (currently
Expand Down Expand Up @@ -335,39 +351,53 @@ cumulative provisional whitespace. Any deferred follow is then replayed.
## C. Follow-Output Mode (continuous tail)

When the viewport is in follow-output mode and the latest turn is still
streaming, the user's intent is "keep the tail visible". Text layout grows in
discrete line-height steps even when characters are revealed smoothly, so the
continuous RAF loop eases `scrollTop` toward the bottom with a retargetable
exponential step. It does not restart native smooth scrolling or snap by a
whole line on observer notifications.

Content `scrollHeight` growth is not a viewport resize. Physical-bottom
synchronization is reserved for an actual `clientHeight` change; live content
growth is owned by the continuous follow loop.
streaming, the user's intent is "keep the tail visible". After the viewport
coordinator has entered `following-tail`, one RAF loop eases `scrollTop` toward
the effective bottom. Follow events only wake this loop; they do not launch
additional scroll writers. The target subtracts the current Footer
reservation, and large gaps snap directly to the target instead of leaving the
user visibly behind the output.

The loop is dormant while `pinned-item`, `preserving-element`, or a collapse
transaction owns the viewport. It never clears reservations or calls
`followTail()` from inside the animation frame. This keeps the semantic
handoff and Virtuoso compensation paths authoritative while allowing small
line-height growth to move over several frames. Explicit "jump to latest"
navigation keeps its native smooth scroll; the RAF loop waits for that motion
to settle before writing.

Collapses interact with follow mode in three mutually exclusive ways:

1. **Known collapse while follow + streaming is active:** the intent applies
synchronous Footer pre-compensation before the card shrinks. The active
intent allows shrink reconciliation even though tail follow is running.
When the CSS window ends, the transaction becomes a retained-provisional
When the CSS window ends, the transaction becomes a retained-provisional
collapse anchor instead of shrinking the Footer from a signed net-height
estimate. Virtuoso
can publish the matching item measurement after the CSS transition and after
stream end; reducing synthetic range before that measurement clamps the
viewport by exactly the removed pixels.
The retained transaction records the latest safe follow position. After a
negative-layout quiet window, it replaces both the provisional `px` and stale
`floorPx` with the minimum geometrically required Footer range. If a later
`floorPx` with the minimum geometrically required Footer range. The final
release uses one timer plus a geometry generation: any effective height
change invalidates that timer's snapshot, and the timer performs one more
quiet check instead of every token allocating new timer work. If a later
measurement clamps below that position, the scroll handler synchronously
extends the range and restores it before paint. Real content growth and
downward follow movement consume the range one-for-one. User intent, a new
pin, session reset, or a final quiet grace releases the retained anchor.
Stream end restarts the same settlement path;
it does not preserve the provisional full-card estimate indefinitely.
2. **Unsignaled shrink while follow + streaming is active:** there is no
semantic collapse transaction to preserve, so the RAF loop re-pins to the
new bottom on the next frame.
2. **Unsignaled shrink while follow + streaming is active:** a strict physical
clamp signature (the previous and current geometries are both at their
physical bottoms, the range shrink matches the negative `scrollTop` delta,
viewport height is stable, and there is no user intent) starts a
`late-shrink` viewport transaction. The scroll handler extends the Footer and
restores the pre-clamp position synchronously, covering virtualizer size
commits that arrive after the originating collapse transaction was released.
Other unsignaled shrinks remain owned by the tail loop; it follows only
downward toward the new effective bottom on the next frame.
A negative `scrollBy` issued by Virtuoso after a virtualized height
reduction is also suppressed when the previous geometry was already at the
physical bottom. That compensation would move the viewport away from the
Expand All @@ -378,7 +408,9 @@ Collapses interact with follow mode in three mutually exclusive ways:
deferred until the intent's TTL lapses.

The loop is cancelled as soon as follow exits (user upward scroll,
session change, streaming ends, or an explicit navigation).
session change, or streaming end). Explicit "jump to latest" navigation pauses
the writer while its native smooth scroll completes, then resumes the same
single tail loop.

## Why `overflow-anchor: none` Must Stay

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,26 +37,68 @@ afterEach(() => {
});

describe('FlowChatViewportCoordinator', () => {
it('hands off to tail follow only after every reservation is drained', () => {
it('hands off after the pin drains or natural content reaches the viewport tail', () => {
expect(canHandoffPinnedItemToTail({
pinReservationPx: 1029,
collapseReservationPx: 0,
pendingStickyPinGrowthPx: 0,
hasPendingCollapseIntent: false,
viewport: {
scrollTop: 900,
clientHeight: 1000,
naturalContentHeight: 1899,
},
})).toBe(false);
expect(canHandoffPinnedItemToTail({
pinReservationPx: 0,
pinReservationPx: 1029,
collapseReservationPx: 0,
pendingStickyPinGrowthPx: 0,
hasPendingCollapseIntent: false,
viewport: {
scrollTop: 900,
clientHeight: 1000,
naturalContentHeight: 1900,
},
})).toBe(true);
expect(canHandoffPinnedItemToTail({
pinReservationPx: 1029,
collapseReservationPx: 200,
pendingStickyPinGrowthPx: 0,
hasPendingCollapseIntent: false,
viewport: {
scrollTop: 900,
clientHeight: 1000,
naturalContentHeight: 2000,
},
})).toBe(false);
expect(canHandoffPinnedItemToTail({
pinReservationPx: 0,
pinReservationPx: 1029,
collapseReservationPx: 0,
pendingStickyPinGrowthPx: 20,
hasPendingCollapseIntent: false,
viewport: {
scrollTop: 900,
clientHeight: 1000,
naturalContentHeight: 2000,
},
})).toBe(false);
expect(canHandoffPinnedItemToTail({
pinReservationPx: 1029,
collapseReservationPx: 0,
pendingStickyPinGrowthPx: 0,
hasPendingCollapseIntent: true,
viewport: {
scrollTop: 900,
clientHeight: 1000,
naturalContentHeight: 2000,
},
})).toBe(false);
expect(canHandoffPinnedItemToTail({
pinReservationPx: 0,
collapseReservationPx: 0,
pendingStickyPinGrowthPx: 0,
hasPendingCollapseIntent: false,
viewport: null,
})).toBe(true);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,40 @@ const ELEMENT_ANCHOR_RANGE_GUARD_PX = 1;
export function canHandoffPinnedItemToTail(options: {
pinReservationPx: number;
collapseReservationPx: number;
pendingStickyPinGrowthPx: number;
hasPendingCollapseIntent: boolean;
viewport: {
scrollTop: number;
clientHeight: number;
naturalContentHeight: number;
} | null;
}): boolean {
if (
options.collapseReservationPx > ELEMENT_ANCHOR_EPSILON_PX ||
options.pendingStickyPinGrowthPx > ELEMENT_ANCHOR_EPSILON_PX ||
options.hasPendingCollapseIntent
) {
return false;
}

if (options.pinReservationPx <= ELEMENT_ANCHOR_EPSILON_PX) {
return true;
}

const viewport = options.viewport;
if (
!viewport ||
!Number.isFinite(viewport.scrollTop) ||
!Number.isFinite(viewport.clientHeight) ||
!Number.isFinite(viewport.naturalContentHeight) ||
viewport.clientHeight <= ELEMENT_ANCHOR_EPSILON_PX
) {
return false;
}

return (
options.pinReservationPx <= ELEMENT_ANCHOR_EPSILON_PX &&
options.collapseReservationPx <= ELEMENT_ANCHOR_EPSILON_PX &&
!options.hasPendingCollapseIntent
viewport.naturalContentHeight + ELEMENT_ANCHOR_EPSILON_PX >=
viewport.scrollTop + viewport.clientHeight
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
releasePinReservationForUserNavigation,
resolveAutoCollapseAnchorScrollTop,
resolveCollapseIntentSettlementStrategy,
resolveFollowingTailShrinkClampRecovery,
resolveProvisionalStickyPinReservationPx,
resolveStickyPinGrowthSettlementStrategy,
settleRetainedCollapseReservationForAnchor,
Expand Down Expand Up @@ -818,25 +819,25 @@ describe('VirtualMessageList session boundary', () => {

it('does not let physical-bottom follow compete with a semantic element anchor', () => {
expect(shouldSyncPhysicalBottom({
viewportSizeChanged: true,
viewportGeometryChanged: true,
collapseProtectionActive: false,
wasAtPhysicalBottom: true,
ownsElementAnchor: true,
isFollowingTail: false,
})).toBe(false);
expect(shouldSyncPhysicalBottom({
viewportSizeChanged: true,
viewportGeometryChanged: true,
collapseProtectionActive: false,
wasAtPhysicalBottom: true,
ownsElementAnchor: false,
isFollowingTail: false,
})).toBe(true);
});

it('does not treat streamed content growth as a viewport resize', () => {
expect(shouldSyncPhysicalBottom({
viewportSizeChanged: false,
viewportGeometryChanged: true,
collapseProtectionActive: false,
wasAtPhysicalBottom: true,
ownsElementAnchor: false,
isFollowingTail: true,
})).toBe(false);
});

Expand Down Expand Up @@ -867,6 +868,53 @@ describe('VirtualMessageList session boundary', () => {
})).toBe(false);
});

it('recognizes only a non-user physical-bottom clamp from a shrinking scroll range', () => {
const clampGeometry = {
previousGeometry: {
scrollTop: 645.3333129882812,
scrollHeight: 1_673,
clientHeight: 1_027,
},
currentGeometry: {
scrollTop: 402.6666564941406,
scrollHeight: 1_430,
clientHeight: 1_027,
},
isFollowingOutput: true,
isStreamingOutput: true,
hasRecentUserUpwardIntent: false,
scrollbarPointerInteractionActive: false,
collapseProtectionActive: false,
};

const recovery = resolveFollowingTailShrinkClampRecovery(clampGeometry);
expect(recovery?.targetScrollTop).toBe(645.3333129882812);
expect(recovery?.rangeShrinkPx).toBe(243);
expect(recovery?.scrollClampPx).toBeCloseTo(242.6666564941406, 10);
expect(resolveFollowingTailShrinkClampRecovery({
...clampGeometry,
hasRecentUserUpwardIntent: true,
})).toBeNull();
expect(resolveFollowingTailShrinkClampRecovery({
...clampGeometry,
isFollowingOutput: false,
})).toBeNull();
expect(resolveFollowingTailShrinkClampRecovery({
...clampGeometry,
currentGeometry: {
...clampGeometry.currentGeometry,
scrollTop: 500,
},
})).toBeNull();
expect(resolveFollowingTailShrinkClampRecovery({
...clampGeometry,
currentGeometry: {
...clampGeometry.currentGeometry,
clientHeight: 900,
},
})).toBeNull();
});

it('cancels unsettled sticky pin growth only for unsignaled height corrections', () => {
expect(getCanceledUnsettledStickyPinGrowthPx({
pendingGrowthPx: 207,
Expand Down Expand Up @@ -1110,6 +1158,65 @@ describe('VirtualMessageList session boundary', () => {
);
});

it('recovers a late following-tail shrink clamp after collapse protection was released', () => {
flowDiagnosticsMocks.enabled = true;
const session = createSession('session-a', 'turn-a');
session.dialogTurns[0].status = 'processing';
session.dialogTurns[0].modelRounds = [{
id: 'round-turn-a',
status: 'streaming',
isStreaming: true,
items: [],
startTime: 1,
} as typeof session.dialogTurns[number]['modelRounds'][number]];
stateMocks.activeSession = session;
stateMocks.virtualItems = [createItem('turn-a'), createModelItem('turn-a')];

act(() => {
root.render(<VirtualMessageList />);
});

const scroller = container.querySelector<HTMLElement>('[data-virtuoso-scroller="true"]');
const footer = container.querySelector<HTMLElement>('.message-list-footer');
expect(scroller).not.toBeNull();
expect(footer).not.toBeNull();
if (!scroller || !footer) {
return;
}

let contentHeight = 2_076;
Object.defineProperties(scroller, {
clientHeight: { configurable: true, value: 1_000 },
scrollHeight: {
configurable: true,
get: () => contentHeight + (Number.parseFloat(footer.style.height) || 0),
},
scrollTop: { configurable: true, writable: true, value: 0 },
});
const footerHeightBeforeClamp = Number.parseFloat(footer.style.height);
const stableScrollTop = scroller.scrollHeight - scroller.clientHeight;
scroller.scrollTop = stableScrollTop;
act(() => {
window.dispatchEvent(new Event('resize'));
});

contentHeight -= 250;
scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight;
act(() => {
scroller.dispatchEvent(new Event('scroll', { bubbles: true }));
});

expect(scroller.scrollTop).toBe(stableScrollTop);
expect(Number.parseFloat(footer.style.height)).toBeCloseTo(
footerHeightBeforeClamp + 251,
2,
);
expect(flowDiagnosticsMocks.trace).toHaveBeenCalledWith(expect.objectContaining({
location: 'VirtualMessageList.handleScroll',
message: 'Following-tail shrink clamp recovered as a viewport transaction',
}));
});

it('retains following-tail collapse protection after the animation finalizer', () => {
flowDiagnosticsMocks.enabled = true;
const session = createSession('session-a', 'turn-a');
Expand Down
Loading