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 @@ -335,8 +335,15 @@ 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". The continuous
RAF loop re-pins `scrollTop` toward the bottom every frame.
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.

Collapses interact with follow mode in three mutually exclusive ways:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -818,19 +818,28 @@ describe('VirtualMessageList session boundary', () => {

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

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

it('suppresses only negative virtualizer compensation while following the streaming tail', () => {
expect(shouldSuppressFollowingTailNegativeScrollBy({
requestedTop: -242,
Expand Down
18 changes: 7 additions & 11 deletions src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -687,11 +687,10 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef, VirtualMessa
return false;
}

const viewportGeometryChanged =
Math.abs(scroller.scrollHeight - previousGeometry.scrollHeight) > COMPENSATION_EPSILON_PX ||
const viewportSizeChanged =
Math.abs(scroller.clientHeight - previousGeometry.clientHeight) > COMPENSATION_EPSILON_PX;
if (
!viewportGeometryChanged ||
!viewportSizeChanged ||
pendingCollapseIntentRef.current.active ||
retainedCollapseAnchorRef.current !== null
) {
Expand All @@ -708,7 +707,7 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef, VirtualMessa
const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
const ownsElementAnchor = viewportCoordinatorRef.current.ownsElementAnchor();
const willWrite = shouldSyncPhysicalBottom({
viewportGeometryChanged,
viewportSizeChanged,
collapseProtectionActive: pendingCollapseIntentRef.current.active,
wasAtPhysicalBottom,
ownsElementAnchor,
Expand Down Expand Up @@ -1453,12 +1452,9 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef, VirtualMessa
const currentScrollTop = scroller.scrollTop;
const previousScrollTop = previousScrollTopRef.current;
const previousScrollerGeometry = previousScrollerGeometryRef.current;
const viewportGeometryChanged = Boolean(
const viewportSizeChanged = Boolean(
previousScrollerGeometry &&
(
Math.abs(scroller.scrollHeight - previousScrollerGeometry.scrollHeight) > COMPENSATION_EPSILON_PX ||
Math.abs(scroller.clientHeight - previousScrollerGeometry.clientHeight) > COMPENSATION_EPSILON_PX
)
Math.abs(scroller.clientHeight - previousScrollerGeometry.clientHeight) > COMPENSATION_EPSILON_PX
);
const wasAtPhysicalBottom = Boolean(
previousScrollerGeometry &&
Expand Down Expand Up @@ -1516,7 +1512,7 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef, VirtualMessa
collapseIntentActive: pendingCollapseIntentRef.current.active,
retainedCollapseAnchor: retainedCollapseAnchorRef.current,
wasAtPhysicalBottom,
viewportGeometryChanged,
viewportSizeChanged,
}),
});
}
Expand All @@ -1527,7 +1523,7 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef, VirtualMessa
retainedCollapseAnchorRef.current !== null
);
if (shouldSyncPhysicalBottom({
viewportGeometryChanged,
viewportSizeChanged,
collapseProtectionActive: hasCollapseAnchorProtection,
wasAtPhysicalBottom,
ownsElementAnchor,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -423,13 +423,13 @@ export function consumeBottomReservationForContentGrowth(
}

export function shouldSyncPhysicalBottom(options: {
viewportGeometryChanged: boolean;
viewportSizeChanged: boolean;
collapseProtectionActive: boolean;
wasAtPhysicalBottom: boolean;
ownsElementAnchor: boolean;
}): boolean {
return (
options.viewportGeometryChanged &&
options.viewportSizeChanged &&
!options.collapseProtectionActive &&
options.wasAtPhysicalBottom &&
!options.ownsElementAnchor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,37 @@
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useFlowChatFollowOutput } from './useFlowChatFollowOutput';
import {
computeContinuousFollowStep,
useFlowChatFollowOutput,
} from './useFlowChatFollowOutput';

globalThis.IS_REACT_ACT_ENVIRONMENT = true;

type FollowOutputController = ReturnType<typeof useFlowChatFollowOutput>;

describe('computeContinuousFollowStep', () => {
it('spreads a line-height-sized tail growth across multiple frames', () => {
const firstStep = computeContinuousFollowStep(24, 1000 / 60);

expect(firstStep).toBeGreaterThan(0);
expect(firstStep).toBeLessThan(24);
});

it('retargets proportionally while capping large catch-up jumps', () => {
const smallStep = computeContinuousFollowStep(24, 1000 / 60);
const largeStep = computeContinuousFollowStep(240, 1000 / 60);

expect(largeStep).toBeGreaterThan(smallStep);
expect(largeStep).toBeLessThanOrEqual(32);
});

it('snaps only the final subpixel remainder', () => {
expect(computeContinuousFollowStep(0.4, 1000 / 60)).toBe(0.4);
expect(computeContinuousFollowStep(Number.NaN, 1000 / 60)).toBe(0);
});
});

function setScrollerMetrics(
scroller: HTMLElement,
metrics: { scrollHeight: number; clientHeight: number; scrollTop: number },
Expand Down Expand Up @@ -229,4 +254,57 @@ describe('useFlowChatFollowOutput', () => {
expect(performAutoFollowScroll).toHaveBeenCalledTimes(1);
expect(performLatestTurnStickyPin).not.toHaveBeenCalled();
});

it('eases line-height growth without issuing another bottom snap', () => {
const queuedFrames: FrameRequestCallback[] = [];
let nextFrameId = 0;
vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => {
queuedFrames.push(callback);
nextFrameId += 1;
return nextFrameId;
}));

const scroller = document.createElement('div');
setScrollerMetrics(scroller, {
scrollHeight: 1500,
clientHeight: 500,
scrollTop: 1000,
});
const performAutoFollowScroll = vi.fn(() => {
scroller.scrollTop = scroller.scrollHeight - scroller.clientHeight;
});

act(() => {
root.render(
<Harness
scroller={scroller}
onController={nextController => {
controller = nextController;
}}
performAutoFollowScroll={performAutoFollowScroll}
/>,
);
});

act(() => {
controller?.enterFollowOutput('auto-follow');
});
expect(performAutoFollowScroll).toHaveBeenCalledTimes(1);

setScrollerMetrics(scroller, {
scrollHeight: 1524,
clientHeight: 500,
scrollTop: 1000,
});
const firstFollowFrame = queuedFrames.shift();
expect(firstFollowFrame).toBeDefined();

act(() => {
firstFollowFrame?.(1000 / 60);
});

expect(performAutoFollowScroll).toHaveBeenCalledTimes(1);
expect(scroller.scrollTop).toBeGreaterThan(1000);
expect(scroller.scrollTop).toBeLessThan(1024);
});
});
Loading