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 @@ -30,18 +30,23 @@ inside the window changes height, the browser reflows the ones below it in the
same layout pass, so there is no frame where the scroll has been corrected but
the items have not moved yet.

**The virtualizer does not compensate for its own late measurements.**
`shouldAdjustScrollPositionOnItemSizeChange` is set to refuse, always. Its rule
is the right shape — this item's delta, only for an item above the viewport —
but it applies that delta to `scrollOffset`, the library's own copy of the
scroll position, refreshed only from scroll events. Every continuous writer here
assigns `scrollTop` directly and the matching scroll event lands a frame later,
so a measurement arriving in between is compensated from a position the viewport
has already left. Measured on session open: **nine corrections across two frames
walked the viewport from 7440 back to 3556**, and the follow loop wrote 7440
again on the next frame. The interception this replaces was written for
react-virtuoso and removed on the assumption that TanStack asked the right
question. It does — from a stale base.
**The virtualizer does not use TanStack's own late-measurement adjustment.**
`shouldAdjustScrollPositionOnItemSizeChange` reads the real scroller position
and asks the viewport owner to apply the delta only when the whole item is above
the viewport. A partly visible row is left alone because its changed content is
inside what the reader is looking at. TanStack's adjustment is always refused:
it applies its delta to `scrollOffset`, the library's copy refreshed only from
scroll events. Every continuous writer here assigns `scrollTop` directly and
the matching scroll event lands a frame later, so that base can be stale. The
owner's displacement is applied before the new size enters the cache, while
the anchor remains responsible for restoring relationships across larger layout
transactions.

The measurement decision is recorded as the switch-gated, coalesced
`virtualizer.itemResize` probe: item identity, estimated and measured sizes,
the above-viewport decision, and the real scroll geometry before and after the
owner's displacement. It deliberately omits flow-item contents, which made the
temporary investigation probe too large for a lasting diagnostic trail.

**Measurement is forced before any position is read in the commit that changed
the items.** The library skips its inline resize while the reader is scrolling,
Expand Down
15 changes: 13 additions & 2 deletions src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ interface ModelRoundItemProps {
turnEndedAt?: number;
turnDurationMs?: number;
turnTokenUsage?: TokenUsage;
expandedThinkingItemIds?: string[];
}

function sortRoundAttempts(attempts: ModelRoundAttempt[]): ModelRoundAttempt[] {
Expand Down Expand Up @@ -362,6 +363,7 @@ export const ModelRoundItem = React.memo<ModelRoundItemProps>(
turnEndedAt,
turnDurationMs,
turnTokenUsage,
expandedThinkingItemIds = [],
}) => {
const { t } = useTranslation('flow-chat');
const { formatDate, formatNumber } = useI18n('flow-chat');
Expand Down Expand Up @@ -488,6 +490,7 @@ export const ModelRoundItem = React.memo<ModelRoundItemProps>(
turnId={turnId}
roundId={options.roundId}
isLastItem={isLast && itemIdx === group.items.length - 1}
expandedThinkingItemIds={expandedThinkingItemIds}
/>
));

Expand Down Expand Up @@ -516,6 +519,7 @@ export const ModelRoundItem = React.memo<ModelRoundItemProps>(
turnId={turnId}
roundId={options.roundId}
isLastItem={isLast}
expandedThinkingItemIds={expandedThinkingItemIds}
/>
);
}
Expand All @@ -524,7 +528,7 @@ export const ModelRoundItem = React.memo<ModelRoundItemProps>(
return null;
}
})
), [sessionId, turnId]);
), [expandedThinkingItemIds, sessionId, turnId]);

const handleCopyScope = useCallback(async (scope: TranscriptExportScope) => {
setIsCopyMenuOpen(false);
Expand Down Expand Up @@ -842,6 +846,7 @@ export const ModelRoundItem = React.memo<ModelRoundItemProps>(
prev.round.historyRounds === next.round.historyRounds &&
prev.isLastRound === next.isLastRound &&
prev.isTurnComplete === next.isTurnComplete &&
prev.expandedThinkingItemIds === next.expandedThinkingItemIds &&
prev.turnStartedAt === next.turnStartedAt &&
prev.turnEndedAt === next.turnEndedAt &&
prev.turnDurationMs === next.turnDurationMs &&
Expand All @@ -860,6 +865,7 @@ interface FlowItemRendererProps {
turnId: string;
roundId?: string;
isLastItem?: boolean;
expandedThinkingItemIds?: string[];
}

// Do not memoize: streaming content updates frequently.
Expand All @@ -868,6 +874,7 @@ const FlowItemRenderer: React.FC<FlowItemRendererProps> = ({
turnId,
roundId,
isLastItem,
expandedThinkingItemIds = [],
}) => {
const {
onToolConfirm,
Expand Down Expand Up @@ -898,7 +905,11 @@ const FlowItemRenderer: React.FC<FlowItemRendererProps> = ({

case 'thinking':
return (
<ModelThinkingDisplay thinkingItem={item as FlowThinkingItem} isLastItem={isLastItem} />
<ModelThinkingDisplay
thinkingItem={item as FlowThinkingItem}
isLastItem={isLastItem}
forceExpanded={expandedThinkingItemIds.includes(item.id)}
/>
);

case 'tool': {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export const VirtualItemRenderer = React.memo<VirtualItemRendererProps>(
turnEndedAt={item.turnEndedAt}
turnDurationMs={item.turnDurationMs}
turnTokenUsage={item.turnTokenUsage}
expandedThinkingItemIds={item.layoutHints?.expandedThinkingItemIds ?? []}
/>
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,35 @@ describe('estimateVirtualMessageItemHeight', () => {
expect(estimateVirtualMessageItemHeight(item)).toBeGreaterThan(1000);
});

it('uses the shared collapsed hint for completed thinking rounds', () => {
const item = {
type: 'model-round',
turnId: 'turn-1',
isLastRound: false,
isTurnComplete: true,
layoutHints: { expandedThinkingItemIds: [] },
data: {
id: 'round-thinking',
status: 'completed',
isStreaming: false,
items: [{
id: 'thinking-1',
type: 'thinking',
content: 'x'.repeat(13_016),
status: 'completed',
timestamp: 1,
}],
},
} as VirtualItem;

expect(estimateVirtualMessageItemHeight(item)).toBe(200);

expect(estimateVirtualMessageItemHeight({
...item,
layoutHints: { expandedThinkingItemIds: ['thinking-1'] },
})).toBeGreaterThan(1000);
});

it('keeps compact user-only rows small enough for partial history tails', () => {
const item = {
type: 'user-message',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,7 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef, VirtualMessa
estimateItemHeightPx: estimateVirtualMessageItemHeight,
scrollPaddingStartPx: FLOWCHAT_TURN_TOP_GAP_PX,
writeViewport: viewportOwner.write,
shiftViewport: viewportOwner.shift,
});

const userMessageItems = useMemo(() => virtualItems
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,12 +166,13 @@ describe('canShiftViewport', () => {
expect(canShiftViewport(heldBy('user-gesture', { holdForMs: 200 }), NOW)).toBe(true);
});

it('leaves the displacement to anyone holding a target', () => {
// All three re-assert a position of their own, so a shift underneath is
// either redundant or a fight.
it('leaves the displacement to navigation and follow targets', () => {
// Those writers re-assert a position of their own. Snap-back is different:
// a late history measurement invalidates its target coordinate, so the
// displacement must cancel the stale animation and be reconsidered.
expect(canShiftViewport(heldBy('follow-output'), NOW)).toBe(false);
expect(canShiftViewport(heldBy('one-shot-navigation', { holdForMs: 600 }), NOW)).toBe(false);
expect(canShiftViewport(heldBy('snap-back', { holdForMs: 1_200 }), NOW)).toBe(false);
expect(canShiftViewport(heldBy('snap-back', { holdForMs: 1_200 }), NOW)).toBe(true);
});

it('shifts an unheld viewport, and one held only by a correction', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,12 +197,15 @@ export function claimViewport(
* the reader belongs, so a displacement applied underneath them is either
* redundant or a fight.
*
* `snap-back` is deliberately not one of them either: a late measurement of
* history above the reader invalidates the snap target's coordinate. Letting
* that displacement through cancels the stale animation by changing the real
* scroll position; the next settle re-evaluates the target from fresh geometry.
* `user-gesture` is deliberately not one of them, and that is the whole point
* of this being a separate question.
*/
const OWNERS_THAT_HOLD_A_TARGET: ReadonlySet<FlowChatViewportOwner> = new Set([
'one-shot-navigation',
'snap-back',
'follow-output',
]);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
import { describe, expect, it } from 'vitest';
import {
isItemFullyAboveViewport,
virtualWindowPaddingPx,
visibleRowRange,
type FlowChatVirtualRow,
} from './useFlowChatVirtualizer';

describe('isItemFullyAboveViewport', () => {
it('does not compensate a row that is partly visible', () => {
expect(isItemFullyAboveViewport(8280, 5450)).toBe(false);
});

it('compensates only a row whose end is above the viewport', () => {
expect(isItemFullyAboveViewport(4722, 5450)).toBe(true);
});
});

/** Header above the items, which every offset below is measured past. */
const CONTENT_START = 24;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import {
roundViewportPx,
traceViewport,
traceViewportRepeating,
isViewportDiagnosticsEnabled,
} from '@/infrastructure/diagnostics/flowChatViewportDiagnostics';
import type { FlowChatViewportOwner } from './flowChatViewportOwnership';

Expand All @@ -47,7 +49,7 @@ const FLOW_CHAT_OVERSCAN_ITEMS = 6;
const VIRTUALIZER_REAIM_WINDOW_MS = 5_000;

/**
* The virtualizer does not compensate for its own late measurements.
* TanStack's default late-measurement adjustment is not safe for this list.
*
* Its rule is the right shape — adjust by *this item's* delta, and only for an
* item above the viewport — but it applies that delta to `scrollOffset`, which
Expand All @@ -59,11 +61,10 @@ const VIRTUALIZER_REAIM_WINDOW_MS = 5_000;
* two frames walked the viewport from 7440 back to 3556 before the follow loop
* wrote 7440 again.
*
* The viewport anchor is the compensator instead. It restores a relationship
* rather than replaying a delta, so it has no base to go stale.
* For a row wholly above the reader, the real viewport owner applies the
* measured delta before TanStack updates its cache. Rows intersecting the
* viewport are left alone: their content is what the reader is looking at.
*/
const neverAdjustScrollPositionOnItemResize = () => false;

export interface FlowChatVirtualRow {
index: number;
key: string;
Expand All @@ -76,6 +77,19 @@ export interface FlowChatItemBounds {
endPx: number;
}

/** A resize can shift the reader only when the whole row is above it. */
export function isItemFullyAboveViewport(itemEndPx: number, scrollTopPx: number): boolean {
return itemEndPx <= scrollTopPx;
}

function resizeDeltaBand(deltaPx: number): string {
const magnitudePx = Math.abs(deltaPx);
if (magnitudePx < 1) return 'subpixel';
if (magnitudePx < 16) return 'small';
if (magnitudePx < 128) return 'medium';
return 'large';
}

/**
* The measurement pass, which the published types keep to themselves.
*
Expand Down Expand Up @@ -125,6 +139,8 @@ export interface UseFlowChatVirtualizerOptions<T> {
behavior?: ScrollBehavior;
holdForMs?: number;
}) => boolean;
/** Shift the viewport before a measurement changes row heights. */
shiftViewport?: (byPx: number) => boolean;
}

export interface FlowChatVirtualizer {
Expand Down Expand Up @@ -264,6 +280,7 @@ export function useFlowChatVirtualizer<T>({
estimateItemHeightPx,
scrollPaddingStartPx,
writeViewport,
shiftViewport = () => false,
}: UseFlowChatVirtualizerOptions<T>): FlowChatVirtualizer {
const itemsRef = useRef(items);
itemsRef.current = items;
Expand Down Expand Up @@ -358,7 +375,65 @@ export function useFlowChatVirtualizer<T>({
});
// An instance field rather than an option, so it is assigned here — before
// any measurement callback can reach `resizeItem`.
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = neverAdjustScrollPositionOnItemResize;
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, delta) => {
const scroller = scrollerRef.current;
if (!scroller) return false;
const beforeScrollTopPx = scroller.scrollTop;
const beforeScrollHeightPx = scroller.scrollHeight;
// A row that merely starts above the viewport may still be visible. Its
// resize changes content inside the reader rather than moving content that
// is wholly above it, so compensating its full delta would pull the reader
// by the size of a row they are looking at (history model rounds can be
// several thousand pixels). Only a row whose end is above the viewport can
// move the reader's existing content and needs a viewport shift.
const fullyAboveViewport = isItemFullyAboveViewport(item.end, beforeScrollTopPx);
const applied = fullyAboveViewport ? shiftViewport(delta) : false;
const virtualItem = itemsRef.current[item.index];
if (isViewportDiagnosticsEnabled()) {
const diagnosticItem = virtualItem as {
type?: unknown;
turnId?: unknown;
} | undefined;
const itemKey = virtualItem === undefined ? null : getItemKeyRef.current(virtualItem);
traceViewportRepeating(
`itemResize|${itemKey ?? 'unknown'}|${fullyAboveViewport}|${applied}|${resizeDeltaBand(delta)}`,
{
location: 'virtualizer.itemResize',
message: 'an item changed size during virtualizer measurement',
travelPx: delta,
data: () => ({
index: item.index,
itemKey,
itemType: typeof diagnosticItem?.type === 'string' ? diagnosticItem.type : null,
turnId: typeof diagnosticItem?.turnId === 'string' ? diagnosticItem.turnId : null,
estimatedSizePx: virtualItem === undefined
? null
: roundViewportPx(estimateItemHeightRef.current(virtualItem)),
previousItemSizePx: roundViewportPx(item.size),
nextItemSizePx: roundViewportPx(item.size + delta),
itemStartPx: roundViewportPx(item.start),
itemEndPx: roundViewportPx(item.end),
deltaPx: roundViewportPx(delta),
fullyAboveViewport,
beforeScrollTopPx: roundViewportPx(beforeScrollTopPx),
beforeScrollHeightPx: roundViewportPx(beforeScrollHeightPx),
applied,
afterScrollTopPx: roundViewportPx(scroller.scrollTop),
afterScrollHeightPx: roundViewportPx(scroller.scrollHeight),
}),
},
);
}
/*
* TanStack's default adjustment is based on its cached scroll offset. This
* list writes the real scroller through the viewport register, so that copy
* can be one frame stale. Move the real viewport before the new size enters
* the cache; otherwise a shrinking range can make the browser clamp
* scrollTop to the physical tail before the reader's anchor gets a chance
* to restore it.
*/
return false;
};

const virtualRows = virtualizer.getVirtualItems();
const totalSizePx = virtualizer.getTotalSize();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ function getFlowItemTextLength(item: AnyFlowItem): number {
return 0;
}

function estimateFlowItemHeight(item: AnyFlowItem): number {
function estimateFlowItemHeight(item: AnyFlowItem, expandedThinkingItemIds: readonly string[]): number {
if (item.type === 'thinking' && !expandedThinkingItemIds.includes(item.id)) return 40;
const textLength = getFlowItemTextLength(item);
if (textLength > 0) {
return Math.min(
Expand Down Expand Up @@ -103,7 +104,10 @@ function estimateModelRoundHeight(item: Extract<VirtualItem, { type: 'model-roun
}

const contentHeight = flowItems.reduce(
(total, flowItem) => total + estimateFlowItemHeight(flowItem),
(total, flowItem) => total + estimateFlowItemHeight(
flowItem,
item.layoutHints?.expandedThinkingItemIds ?? [],
),
0,
);
return Math.min(3600, Math.max(LIVE_SESSION_DEFAULT_ITEM_HEIGHT_PX, MODEL_ROUND_BASE_HEIGHT_PX + contentHeight));
Expand Down
Loading