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 @@ -429,11 +429,11 @@ to nothing else. That is what let the virtualizer underneath it be replaced
without the keeper changing at all.

One consequence of the refresh rule is worth stating plainly: a frame that finds
the anchor already in place still counts as answered, so an open transcript that
no other writer owns holds one animation frame in flight indefinitely. The cost
is a `querySelectorAll` and two rect reads per frame. That is accepted; the
window winds down when there is no anchor to keep, and when another owner holds
the viewport.
the anchor already in place consumes the remaining settle budget rather than
refreshing it. The cost is a `querySelectorAll` and two rect reads per frame for
the bounded settle window. A correction or a Turn still waiting to render
refreshes the window, while a stable anchor winds it down and another owner
holding the viewport still stands it down.

The anchor is skipped entirely while follow-output owns the viewport. Restoring
a pre-prepend position is only meaningful when the user owns it — and a frame
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,15 @@ pass: `size = measured ?? estimateSize(i)`. A per-item estimate for everything
unmeasured. react-virtuoso reserves a single scalar (`lastSize`) for all of
them, and this transcript alternates 38px user messages with model rounds up to
5012px, so the scroll range was wrong by an order of magnitude until an item was
actually measured. `estimateVirtualMessageItemHeight` now feeds it directly.
actually measured. `estimateVirtualMessageItemHeightWithContext` now feeds it
directly. The estimate is owned by the data shape in
`virtualItemHeightEstimators.ts`: text, thinking, user messages, model rounds,
Explore groups, and tool families each derive a bounded height from their
content, status, width, and expansion state. This code is pure and runs before
a row has a DOM node. Once mounted, DOM measurement remains authoritative and
replaces the estimate. Width and volatile Explore expansion changes invalidate
only the derived position pass; TanStack's key-based measured-size cache is
retained.

**Items stay in normal flow inside a padded window**, not absolutely positioned.
Everything outside the window stands in as `padding-top` and `padding-bottom`
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { describe, expect, it } from 'vitest';
import {
estimateTextHeightFromLength,
estimateVirtualMessageItemHeightWithContext,
estimateVirtualMessageItemHeight,
getVirtualMessageDefaultItemHeight,
selectInitialHistoryRenderWindow,
} from './virtualMessageListLayout';
import { estimateToolHeight } from './virtualItemHeightEstimators';
import type { VirtualItem } from '../../store/modernFlowChatStore';
import type { FlowToolItem } from '../../types/flow-chat';

describe('getVirtualMessageDefaultItemHeight', () => {
it('keeps compact historical projections on the small row estimate', () => {
Expand Down Expand Up @@ -113,6 +116,80 @@ describe('estimateVirtualMessageItemHeight', () => {

expect(estimateVirtualMessageItemHeight(item)).toBeLessThanOrEqual(160);
});

it('uses tool-owned data to distinguish compact and expanded Write estimates', () => {
const tool = {
id: 'write-1',
type: 'tool',
toolName: 'Write',
status: 'completed',
timestamp: 1,
toolCall: {
id: 'call-1',
input: { content: 'x'.repeat(600) },
},
} as FlowToolItem;

const completed = estimateToolHeight(tool);
const running = estimateToolHeight({
...tool,
status: 'running',
} as FlowToolItem);

expect(completed.heightPx).toBeLessThan(running.heightPx);
expect(running.kind).toBe('tool-write');
});

it('uses layout width and explicit Explore state for unmeasured rows', () => {
const textItem = {
type: 'model-round',
turnId: 'turn-1',
isLastRound: true,
isTurnComplete: true,
data: {
id: 'round-width',
status: 'completed',
isStreaming: false,
items: [{
id: 'text-width',
type: 'text',
content: 'x'.repeat(600),
status: 'completed',
timestamp: 1,
}],
},
} as VirtualItem;
expect(estimateVirtualMessageItemHeightWithContext(textItem, { availableWidthPx: 360 }))
.toBeGreaterThan(estimateVirtualMessageItemHeightWithContext(textItem, { availableWidthPx: 1200 }));

const exploreItem = {
type: 'explore-group',
turnId: 'turn-1',
data: {
groupId: 'group-1',
allItems: [{
id: 'tool-1',
type: 'tool',
toolName: 'Write',
status: 'running',
timestamp: 1,
toolCall: { id: 'call-1', input: { content: 'x'.repeat(300) } },
}],
stats: { readCount: 0, searchCount: 0, commandCount: 0 },
rounds: [],
isGroupStreaming: false,
isLastGroupInTurn: true,
wasCutByCritical: true,
},
} as VirtualItem;
const collapsed = estimateVirtualMessageItemHeightWithContext(exploreItem, {
exploreGroupStates: new Map([['group-1', false]]),
});
const expanded = estimateVirtualMessageItemHeightWithContext(exploreItem, {
exploreGroupStates: new Map([['group-1', true]]),
});
expect(expanded).toBeGreaterThan(collapsed);
});
});

describe('selectInitialHistoryRenderWindow', () => {
Expand Down
22 changes: 20 additions & 2 deletions src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,10 @@ import {
type HistoryBoundaryProximity,
} from './flowChatHistoryBoundary';
import { VirtualItemRenderer } from './VirtualItemRenderer';
import { useFlowChatVolatileContext } from './FlowChatContext';
import {
estimateVirtualMessageItemHeight,
estimateVirtualMessageItemHeightWithContext,
type VirtualItemHeightEstimateContext,
} from './virtualMessageListLayout';
import { resolveVisibleFlowChatTurnIds } from './flowChatVisibleTurns';
import { warnHistoryPagingRefusedWithPendingTurns } from '../../services/historySessionDiagnostics';
Expand Down Expand Up @@ -371,6 +373,7 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef, VirtualMessa
});
const canonicalVirtualItems = useVirtualItems();
const virtualItems = items ?? canonicalVirtualItems;
const { exploreGroupStates } = useFlowChatVolatileContext();
const activeSession = useActiveSession();
const activeSessionState = useActiveSessionState();
const activeSessionId = activeSession?.sessionId ?? null;
Expand Down Expand Up @@ -414,6 +417,7 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef, VirtualMessa
const headerElementRef = useRef<HTMLDivElement | null>(null);
const [scrollerElement, setScrollerElement] = useState<HTMLElement | null>(null);
const [viewportHeightPx, setViewportHeightPx] = useState(0);
const [viewportWidthPx, setViewportWidthPx] = useState(0);
/** Last scroller box the resize observer saw, to tell it apart from a content change. */
const observedViewportBoxRef = useRef({ width: 0, height: 0 });
/** Remaining resize callbacks over which to keep a resting viewport at the end. */
Expand Down Expand Up @@ -485,7 +489,20 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef, VirtualMessa
scrollerRef: scrollerElementRef,
headerRef: headerElementRef,
getItemKey: getVirtualItemStableKey,
estimateItemHeightPx: estimateVirtualMessageItemHeight,
estimateItemHeightPx: estimateVirtualMessageItemHeightWithContext,
estimateContext: {
availableWidthPx: viewportWidthPx > 0 ? viewportWidthPx : scrollerElement?.clientWidth,
isHistorical: activeSession?.isHistorical === true,
exploreGroupStates,
} satisfies VirtualItemHeightEstimateContext,
estimateContextRevision: [
viewportWidthPx,
activeSession?.isHistorical === true ? 'historical' : 'live',
[...(exploreGroupStates?.entries() ?? [])]
.sort(([left], [right]) => left.localeCompare(right))
.map(([groupId, expanded]) => `${groupId}:${expanded ? 1 : 0}`)
.join(','),
].join('|'),
scrollPaddingStartPx: FLOWCHAT_TURN_TOP_GAP_PX,
writeViewport: viewportOwner.write,
shiftViewport: viewportOwner.shift,
Expand Down Expand Up @@ -1298,6 +1315,7 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef, VirtualMessa
tailRealignCallbacksRef.current = TAIL_REALIGN_RESIZE_CALLBACKS;
}
setViewportHeightPx(nextViewportBox.height);
setViewportWidthPx(nextViewportBox.width);

/*
* Before paint, and ahead of everything below: this observer is the one
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -545,21 +545,24 @@ describe('useFlowChatViewportAnchor', () => {
expect(frames).toHaveLength(0);
});

it('keeps a frame in flight for as long as an anchor stands', () => {
it('winds the settle window down when the anchor stays in place', () => {
/*
* A frame that answered refreshes the window, and a frame that found the
* anchor already in place counts as answered — so an open transcript that
* nobody else is writing to holds the loop open indefinitely. That is the
* shipped behaviour, and its cost is one `querySelectorAll` and two rect
* reads per frame for as long as a session is open.
* An in-place frame means the relationship is already stable. It spends
* the remaining settle budget instead of refreshing it, so an open
* transcript that nobody else is writing to does not keep a RAF loop alive.
*/
layoutTurns({ 'turn-3': 100 });
api.captureAnchor();
api.openSettleWindow();

for (let index = 0; index < 60; index += 1) runFrame();
let ranFrames = 0;
while (frames.length > 0 && ranFrames < 60) {
runFrame();
ranFrames += 1;
}

expect(frames).toHaveLength(1);
expect(ranFrames).toBe(ANCHOR_SETTLE_FRAMES);
expect(frames).toHaveLength(0);
expect(scroller.scrollTop).toBe(0);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -670,13 +670,15 @@ export function useFlowChatViewportAnchor({
*/
correctionFrameStartMsRef.current = frameStartMs;
/*
* A frame that answered refreshes the windowand so does one still
* waiting for the anchored Turn to be rendered. "Not there yet" is
* A frame that made a correction refreshes the window, and so does one
* still waiting for the anchored Turn to be rendered. "Not there yet" is
* neither a repair nor a failure, and spending a frame on it makes the
* settle outlast the wait only for as long as `ANCHOR_SETTLE_FRAMES` and
* `ANCHOR_MISSING_TURN_ATTEMPTS` happen to be the same number. They are
* independent constants describing different things; this says what was
* meant instead of relying on them coinciding.
* meant instead of relying on them coinciding. An in-place frame is
* already settled and must consume the remaining budget, otherwise an
* anchor that never moves keeps this RAF loop alive forever.
*/
const outcome = attemptRestoreRef.current();
// Counted here rather than beside `attempts`, because it is frames the
Expand All @@ -695,7 +697,7 @@ export function useFlowChatViewportAnchor({
* and that count can only advance on a frame that does not stand down:
* the one condition jammed the loop and made its only exit unreachable.
*/
if (outcome === 'corrected' || outcome === 'in-place' || outcome === 'awaiting-turn') {
if (outcome === 'corrected' || outcome === 'awaiting-turn') {
settleFramesRef.current = ANCHOR_SETTLE_FRAMES;
}
correctionFrameStartMsRef.current = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ import {
isViewportDiagnosticsEnabled,
} from '@/infrastructure/diagnostics/flowChatViewportDiagnostics';
import type { FlowChatViewportOwner } from './flowChatViewportOwnership';
import {
describeVirtualItemEstimate,
type VirtualItemHeightEstimateContext,
} from './virtualMessageListLayout';

/** Item-count overscan. Roughly two Turns either side of the viewport. */
const FLOW_CHAT_OVERSCAN_ITEMS = 6;
Expand Down Expand Up @@ -117,7 +121,11 @@ export interface UseFlowChatVirtualizerOptions<T> {
*/
headerRef: RefObject<HTMLElement | null>;
getItemKey: (item: T) => string;
estimateItemHeightPx: (item: T) => number;
estimateItemHeightPx: (item: T, context?: VirtualItemHeightEstimateContext) => number;
/** Data-driven inputs used by estimates before an item is mounted. */
estimateContext?: VirtualItemHeightEstimateContext;
/** Stable identity for data that changes an unmeasured row's estimate. */
estimateContextRevision?: string | number;
/**
* Gap kept above a Turn that has been scrolled to the top of the viewport.
* Applied by the virtualizer itself so that its re-aim, which runs while
Expand Down Expand Up @@ -278,6 +286,8 @@ export function useFlowChatVirtualizer<T>({
headerRef,
getItemKey,
estimateItemHeightPx,
estimateContext,
estimateContextRevision,
scrollPaddingStartPx,
writeViewport,
shiftViewport = () => false,
Expand Down Expand Up @@ -318,6 +328,8 @@ export function useFlowChatVirtualizer<T>({
getItemKeyRef.current = getItemKey;
const estimateItemHeightRef = useRef(estimateItemHeightPx);
estimateItemHeightRef.current = estimateItemHeightPx;
const estimateContextRef = useRef(estimateContext);
estimateContextRef.current = estimateContext;

const [contentStartPx, setContentStartPx] = useState(0);
useEffect(() => {
Expand All @@ -340,13 +352,17 @@ export function useFlowChatVirtualizer<T>({
*/
const estimateSize = useCallback((index: number) => {
const item = itemsRef.current[index];
return item === undefined ? 0 : estimateItemHeightRef.current(item);
return item === undefined ? 0 : estimateItemHeightRef.current(item, estimateContextRef.current);
}, []);

const resolveItemKey = useCallback((index: number) => {
// Recreate the callback when estimate inputs change. TanStack uses the
// callback identity to invalidate its derived measurement positions while
// retaining DOM-measured sizes in its key cache.
void estimateContextRevision;
const item = itemsRef.current[index];
return item === undefined ? index : getItemKeyRef.current(item);
}, []);
}, [estimateContextRevision]);

const virtualizer = useVirtualizer({
count: items.length,
Expand Down Expand Up @@ -420,6 +436,9 @@ export function useFlowChatVirtualizer<T>({
applied,
afterScrollTopPx: roundViewportPx(scroller.scrollTop),
afterScrollHeightPx: roundViewportPx(scroller.scrollHeight),
estimateBreakdown: virtualItem && typeof virtualItem === 'object' && 'type' in virtualItem
? describeVirtualItemEstimate(virtualItem as unknown as Parameters<typeof describeVirtualItemEstimate>[0])
: null,
}),
},
);
Expand Down
Loading