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 @@ -4,6 +4,10 @@ import React from 'react';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
VirtualMessageList,
type VirtualMessageListRef,
} from './VirtualMessageList';
import {
consumeBottomReservationForContentGrowth,
getCanceledUnsettledStickyPinGrowthPx,
Expand All @@ -13,9 +17,7 @@ import {
shouldSyncPhysicalBottom,
shouldSuppressFollowingTailNegativeScrollBy,
transferCollapseReservationToPin,
VirtualMessageList,
type VirtualMessageListRef,
} from './VirtualMessageList';
} from './flowChatScrollStability';
import { activeSessionHistoryProjectionHandoff } from './historyProjectionHandoff';
import type { Session } from '../../types/flow-chat';
import type { VirtualItem } from '../../store/modernFlowChatStore';
Expand Down
228 changes: 17 additions & 211 deletions src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,30 @@ import {
getFlowChatSearchTextRoot,
setFlowChatSearchHighlight,
} from './flowChatSearchDom';
import {
areBottomReservationStatesEqual,
COMPENSATION_EPSILON_PX,
consumeBottomReservationForContentGrowth,
createInitialBottomReservationState,
getCanceledUnsettledStickyPinGrowthPx,
getReservationTotalPx,
resolveAutoCollapseAnchorScrollTop,
sanitizeBottomReservationState,
shouldBypassShrinkCompensationInTailFollow,
shouldPreserveCollapseReservationAfterIntent,
shouldSuppressFollowingTailNegativeScrollBy,
shouldSyncPhysicalBottom,
transferCollapseReservationToPin,
type BottomReservationState,
type PinBottomReservation,
} from './flowChatScrollStability';
import {
canHandoffPinnedItemToTail,
FlowChatViewportCoordinator,
type FlowChatViewportRangeHost,
} from './FlowChatViewportCoordinator';
import './VirtualMessageList.scss';

const COMPENSATION_EPSILON_PX = 0.5;
const PINNED_TURN_VIEWPORT_OFFSET_PX = 57; // Keep in sync with `.message-list-header`.
const TOUCH_SCROLL_INTENT_EXIT_THRESHOLD_PX = 6;
const USER_UPWARD_SCROLL_INTENT_WINDOW_MS = 800;
Expand Down Expand Up @@ -215,44 +231,6 @@ interface LatestEndAnchorRequestState {
lastTargetBottom: number | null;
}

type BottomReservationKind = 'collapse' | 'pin';

interface BottomReservationBase {
kind: BottomReservationKind;
px: number;
floorPx: number;
}

interface CollapseBottomReservation extends BottomReservationBase {
kind: 'collapse';
}

interface PinBottomReservation extends BottomReservationBase {
kind: 'pin';
mode: FlowChatPinTurnToTopMode;
targetTurnId: string | null;
}

interface BottomReservationState {
collapse: CollapseBottomReservation;
pin: PinBottomReservation;
}

export function transferCollapseReservationToPin(
currentState: BottomReservationState,
nextPinReservation: PinBottomReservation,
): BottomReservationState {
return {
...currentState,
collapse: {
...currentState.collapse,
px: 0,
floorPx: 0,
},
pin: nextPinReservation,
};
}

interface ScrollerGeometrySnapshot {
scrollTop: number;
scrollHeight: number;
Expand All @@ -272,23 +250,6 @@ interface PendingStaticTurnPinState {
behavior: ScrollBehavior;
}

function createInitialBottomReservationState(): BottomReservationState {
return {
collapse: {
kind: 'collapse',
px: 0,
floorPx: 0,
},
pin: {
kind: 'pin',
px: 0,
floorPx: 0,
mode: 'transient',
targetTurnId: null,
},
};
}

function sanitizeReservationPx(value: number): number {
return Number.isFinite(value) ? Math.max(0, value) : 0;
}
Expand Down Expand Up @@ -361,39 +322,6 @@ function isPointerOnScrollbarGutter(
return isWithinVerticalScrollbar || isWithinHorizontalScrollbar;
}

function sanitizeBottomReservationState(state: BottomReservationState): BottomReservationState {
const collapsePx = sanitizeReservationPx(state.collapse.px);
const collapseFloorPx = Math.min(collapsePx, sanitizeReservationPx(state.collapse.floorPx));
const pinPx = sanitizeReservationPx(state.pin.px);
const pinFloorPx = Math.min(pinPx, sanitizeReservationPx(state.pin.floorPx));

return {
collapse: {
kind: 'collapse',
px: collapsePx,
floorPx: collapseFloorPx,
},
pin: {
kind: 'pin',
px: pinPx,
floorPx: pinFloorPx,
mode: state.pin.mode ?? 'transient',
targetTurnId: state.pin.targetTurnId ?? null,
},
};
}

function areBottomReservationStatesEqual(left: BottomReservationState, right: BottomReservationState): boolean {
return (
Math.abs(left.collapse.px - right.collapse.px) <= COMPENSATION_EPSILON_PX &&
Math.abs(left.collapse.floorPx - right.collapse.floorPx) <= COMPENSATION_EPSILON_PX &&
Math.abs(left.pin.px - right.pin.px) <= COMPENSATION_EPSILON_PX &&
Math.abs(left.pin.floorPx - right.pin.floorPx) <= COMPENSATION_EPSILON_PX &&
left.pin.mode === right.pin.mode &&
left.pin.targetTurnId === right.pin.targetTurnId
);
}

function getVirtualItemStableKey(item: VirtualItem): string {
switch (item.type) {
case 'user-message':
Expand Down Expand Up @@ -427,128 +355,6 @@ function getPrependedVirtualItemCount(previousItems: VirtualItem[], nextItems: V
return prependedCount;
}

function getReservationTotalPx(reservation: BottomReservationBase): number {
return Math.max(0, reservation.px);
}

function getReservationConsumablePx(reservation: BottomReservationBase): number {
return Math.max(0, reservation.px - reservation.floorPx);
}

export function consumeBottomReservationForContentGrowth(
state: BottomReservationState,
amountPx: number,
consumeStickyPinFloor: boolean,
preserveCollapseReservation = false,
): BottomReservationState {
let remaining = Math.max(0, amountPx);

const collapseConsumablePx = preserveCollapseReservation
? 0
: getReservationConsumablePx(state.collapse);
const collapseConsumed = Math.min(collapseConsumablePx, remaining);
remaining -= collapseConsumed;

const pinConsumablePx = getReservationConsumablePx(state.pin);
const pinConsumed = Math.min(pinConsumablePx, remaining);
remaining -= pinConsumed;

const stickyPinFloorConsumed = consumeStickyPinFloor && state.pin.mode === 'sticky-latest'
? Math.min(state.pin.floorPx, remaining)
: 0;

return sanitizeBottomReservationState({
collapse: {
...state.collapse,
px: state.collapse.px - collapseConsumed,
},
pin: {
...state.pin,
px: state.pin.px - pinConsumed - stickyPinFloorConsumed,
floorPx: state.pin.floorPx - stickyPinFloorConsumed,
},
});
}

export function shouldSyncPhysicalBottom(options: {
viewportGeometryChanged: boolean;
collapseProtectionActive: boolean;
wasAtPhysicalBottom: boolean;
ownsElementAnchor: boolean;
}): boolean {
return (
options.viewportGeometryChanged &&
!options.collapseProtectionActive &&
options.wasAtPhysicalBottom &&
!options.ownsElementAnchor
);
}

export function shouldSuppressFollowingTailNegativeScrollBy(options: {
requestedTop: number | null;
isFollowingOutput: boolean;
isStreamingOutput: boolean;
wasAtPhysicalBottom: boolean;
}): boolean {
return (
options.requestedTop !== null &&
options.requestedTop < -COMPENSATION_EPSILON_PX &&
options.isFollowingOutput &&
options.isStreamingOutput &&
options.wasAtPhysicalBottom
);
}

export function getCanceledUnsettledStickyPinGrowthPx(options: {
pendingGrowthPx: number;
shrinkPx: number;
hasActiveCollapseIntent: boolean;
}): number {
if (options.hasActiveCollapseIntent) {
return 0;
}
return Math.min(
sanitizeReservationPx(options.pendingGrowthPx),
sanitizeReservationPx(options.shrinkPx),
);
}

export function shouldBypassShrinkCompensationInTailFollow(options: {
isFollowingOutput: boolean;
isStreamingOutput: boolean;
hasActiveCollapseIntent: boolean;
}): boolean {
return (
options.isFollowingOutput &&
options.isStreamingOutput &&
!options.hasActiveCollapseIntent
);
}

export function shouldPreserveCollapseReservationAfterIntent(options: {
isFollowingOutput: boolean;
isStreamingOutput: boolean;
}): boolean {
return options.isFollowingOutput && options.isStreamingOutput;
}

export function resolveAutoCollapseAnchorScrollTop(options: {
currentScrollTop: number;
previousStableScrollTop: number;
reason: string | null | undefined;
isFollowingOutput: boolean;
isStreamingOutput: boolean;
}): number {
if (
options.reason !== 'auto' ||
!options.isFollowingOutput ||
!options.isStreamingOutput
) {
return options.currentScrollTop;
}
return Math.max(options.currentScrollTop, options.previousStableScrollTop);
}

const VirtualMessageListSession = forwardRef<VirtualMessageListRef, VirtualMessageListProps>(({
onUserScrollIntent,
}, ref) => {
Expand Down
Loading