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 @@ -1018,7 +1018,7 @@ describe('ModernFlowChatContainer historical empty state', () => {
expect(virtualListMock.pinTurnToTop).not.toHaveBeenCalled();
expect(startupTraceMock.markPhase).toHaveBeenCalledWith(
'historical_session_latest_anchor_skipped',
expect.objectContaining({ reason: 'streaming_follow_output', mode: 'sticky-latest' }),
expect.objectContaining({ reason: 'streaming_follow_output', mode: 'follow-output' }),
);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { resolveThreadGoalHeaderTitle } from '../../utils/threadGoalDisplay';
import {
findDialogTurn,
shouldUseStickyLatestPin,
shouldUseLatestTurnFollowOutput,
} from '../../utils/flowChatTurnScrollPolicy';
import { isRemoteTraceContext, startupTrace } from '@/shared/utils/startupTrace';
import { scheduleAfterStartupPaint } from '@/shared/utils/startupTaskScheduling';
Expand Down Expand Up @@ -588,6 +589,7 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
() => findDialogTurn(activeSession?.dialogTurns, latestTurnId),
[activeSession?.dialogTurns, latestTurnId],
);
const latestTurnUsesFollowOutput = shouldUseLatestTurnFollowOutput(latestTurn);
const latestTurnUsesStickyPin = shouldUseStickyLatestPin(latestTurn);

const navigationVisibleTurnInfo = useMemo<VisibleTurnInfo | null>(() => {
Expand Down Expand Up @@ -731,14 +733,14 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
const pinMode = latestTurnUsesStickyPin
? 'sticky-latest'
: null;
if (latestTurnUsesStickyPin) {
if (latestTurnUsesFollowOutput) {
autoPinnedTurnKeyRef.current = resolvedLatestTurnKey;
setPendingHeaderTurnId(null);
startupTrace.markPhase('historical_session_latest_anchor_skipped', {
sessionId,
latestTurnId,
reason: 'streaming_follow_output',
mode: pinMode,
mode: pinMode ?? 'follow-output',
turnCount: turnSummaries.length,
});
return;
Expand Down Expand Up @@ -871,6 +873,7 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
activeSession?.remoteSshHost,
hasPendingHistoryCompletion,
latestTurnId,
latestTurnUsesFollowOutput,
latestTurnUsesStickyPin,
turnSummaries.length,
visibleTurnInfo?.turnId,
Expand Down
196 changes: 188 additions & 8 deletions src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@ type LatestEndAnchorResolveReason =
| 'resize-observer'
| 'transition-finish';

type InitialHistoryTransitionState = {
key: string;
sessionId: string;
isPartial: boolean;
contextRestoreState: string;
usesInitialHistoryRenderBudget: boolean;
};

// Read `FLOWCHAT_SCROLL_STABILITY.md` before changing collapse compensation logic.

/**
Expand Down Expand Up @@ -357,6 +365,8 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef>((_, ref) =>
const pendingHistoryProjectionHandoffRef = useRef<HistoryProjectionHandoffSnapshot | null>(null);
const historyProjectionHandoffRef = useRef<HistoryProjectionHandoffSnapshot | null>(null);
const historyProjectionHandoffReleaseFrameRef = useRef<number | null>(null);
const latestVisibleHistoryTailSnapshotRef = useRef<HistoryProjectionHandoffSnapshot | null>(null);
const previousInitialHistoryTransitionStateRef = useRef<InitialHistoryTransitionState | null>(null);
const fullHistoryProjectionIntentFrameRef = useRef<number | null>(null);
const pendingFullHistoryProjectionReasonRef = useRef<string | null>(null);
const sessionOpenHandoffSessionIdRef = useRef<string | null>(null);
Expand All @@ -368,6 +378,7 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef>((_, ref) =>
const previousScrollerGeometryRef = useRef<ScrollerGeometrySnapshot | null>(null);
const markedInitialHistoryRenderWindowKeyRef = useRef<string | null>(null);
const autoScrolledInitialHistoryRenderKeyRef = useRef<string | null>(null);
const useStaticInitialHistoryListRef = useRef(false);
const pendingInitialHistoryExpansionRef = useRef<{
scrollTop: number;
scrollHeight: number;
Expand Down Expand Up @@ -532,13 +543,16 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef>((_, ref) =>

const startStaticInitialHistoryBottomGuard = useCallback((durationMs = 2500) => {
const scroller = scrollerElementRef.current;
if (!scroller?.classList.contains('virtual-message-list__static-scroller')) {
if (!scroller) {
return;
}

const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
if (Math.abs(maxScrollTop - scroller.scrollTop) > LATEST_END_ANCHOR_STABLE_EPSILON_PX) {
return;
scroller.scrollTop = maxScrollTop;
previousScrollTopRef.current = maxScrollTop;
previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(scroller);
recordScrollerGeometry(scroller);
}

staticInitialHistoryBottomGuardUntilMsRef.current = Math.max(
Expand All @@ -558,27 +572,31 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef>((_, ref) =>

const currentScroller = scrollerElementRef.current;
if (
!currentScroller?.classList.contains('virtual-message-list__static-scroller') ||
!currentScroller ||
!useStaticInitialHistoryListRef.current ||
now <= userInitiatedUpwardScrollUntilMsRef.current
) {
staticInitialHistoryBottomGuardUntilMsRef.current = 0;
return;
}

syncPhysicalBottomAfterViewportResize(currentScroller);
const distanceFromBottom = Math.max(
0,
currentScroller.scrollHeight - currentScroller.clientHeight - currentScroller.scrollTop,
);
if (distanceFromBottom <= LATEST_END_ANCHOR_STABLE_EPSILON_PX) {
recordScrollerGeometry(currentScroller);
if (distanceFromBottom > LATEST_END_ANCHOR_STABLE_EPSILON_PX) {
const maxScrollTop = Math.max(0, currentScroller.scrollHeight - currentScroller.clientHeight);
currentScroller.scrollTop = maxScrollTop;
previousScrollTopRef.current = maxScrollTop;
previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(currentScroller);
}
recordScrollerGeometry(currentScroller);

staticInitialHistoryBottomGuardFrameRef.current = requestAnimationFrame(tick);
};

staticInitialHistoryBottomGuardFrameRef.current = requestAnimationFrame(tick);
}, [recordScrollerGeometry, syncPhysicalBottomAfterViewportResize]);
}, [recordScrollerGeometry, snapshotMeasuredContentHeight]);

const updateBottomReservationState = useCallback((
updater: BottomReservationState | ((prev: BottomReservationState) => BottomReservationState),
Expand Down Expand Up @@ -1018,6 +1036,7 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef>((_, ref) =>
(activeSession.dialogTurns.length ?? 0) <= PARTIAL_HISTORY_INITIAL_TAIL_TURN_BUDGET;
const useInitialHistoryRenderBudget = hasPendingHistoryCompletion || hasPartialHistoryInitialViewport;
const useStaticInitialHistoryList = useInitialHistoryRenderBudget;
useStaticInitialHistoryListRef.current = useStaticInitialHistoryList;
useEffect(() => {
setStaticAnchorWindowTurnId(null);
}, [activeSession?.sessionId]);
Expand Down Expand Up @@ -2215,7 +2234,12 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef>((_, ref) =>
recordScrollerGeometryIfLayoutStable(scrollerElement);
scheduleVisibleTurnMeasure();
followOutputControllerRef.current.handleScroll();
scheduleFullHistoryProjectionForUserIntent('scroll-near-partial-history-boundary');
if (
performance.now() <= userInitiatedUpwardScrollUntilMsRef.current ||
scrollbarPointerInteractionActiveRef.current
) {
scheduleFullHistoryProjectionForUserIntent('scroll-near-partial-history-boundary');
}

if (anchorLockRef.current.active && performance.now() > anchorLockRef.current.lockUntilMs && !collapseProtectionActive) {
releaseAnchorLock('expired-after-scroll');
Expand Down Expand Up @@ -3729,6 +3753,131 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef>((_, ref) =>
}, [lastItemInfo.isTurnProcessing, lastItemInfo.lastItem, isProcessing, processingPhase, isContentGrowing]);

const footerHeightPx = getFooterHeightPx(getTotalBottomCompensationPx(bottomReservationState));
const initialHistoryTransitionState = React.useMemo<InitialHistoryTransitionState | null>(() => {
if (!activeSessionId || !latestTurnId) {
return null;
}

const contextRestoreState = activeSession?.contextRestoreState ?? 'unknown';
const isPartial = activeSession?.isPartial === true;
const usesInitialHistoryRenderBudget = useInitialHistoryRenderBudget;
return {
key: [
activeSessionId,
latestTurnId,
virtualItems.length,
isPartial ? 'partial' : 'full',
contextRestoreState,
usesInitialHistoryRenderBudget ? 'initial-budget' : 'normal',
].join(':'),
sessionId: activeSessionId,
isPartial,
contextRestoreState,
usesInitialHistoryRenderBudget,
};
}, [
activeSession?.contextRestoreState,
activeSession?.isPartial,
activeSessionId,
latestTurnId,
useInitialHistoryRenderBudget,
virtualItems.length,
]);
useLayoutEffect(() => {
const previousState = previousInitialHistoryTransitionStateRef.current;
previousInitialHistoryTransitionStateRef.current = initialHistoryTransitionState;

const shouldProtectTransition = Boolean(
previousState &&
initialHistoryTransitionState &&
previousState.sessionId === initialHistoryTransitionState.sessionId &&
previousState.key !== initialHistoryTransitionState.key &&
(
previousState.usesInitialHistoryRenderBudget ||
initialHistoryTransitionState.usesInitialHistoryRenderBudget ||
previousState.isPartial !== initialHistoryTransitionState.isPartial ||
previousState.contextRestoreState !== initialHistoryTransitionState.contextRestoreState
)
);

if (
!shouldProtectTransition ||
!initialHistoryTransitionState ||
!activeSessionId ||
historyProjectionHandoffRef.current?.sessionId === activeSessionId
) {
return;
}

const snapshot = latestVisibleHistoryTailSnapshotRef.current;
if (!snapshot || snapshot.sessionId !== activeSessionId || snapshot.items.length === 0) {
return;
}

const handoff: HistoryProjectionHandoffSnapshot = {
...snapshot,
reason: 'initial-history-content-transition',
createdAtMs: performance.now(),
targetTurnId: latestTurnId,
footerHeightPx,
};

pendingHistoryProjectionHandoffRef.current = null;
historyProjectionHandoffRef.current = handoff;
setHistoryProjectionHandoff(handoff);
startupTrace.markPhase('flowchat_history_projection_handoff_activated', {
sessionId: handoff.sessionId,
reason: handoff.reason,
previousItemCount: handoff.items.length,
nextItemCount: virtualItems.length,
previousInitialHistoryBudget: previousState?.usesInitialHistoryRenderBudget ?? false,
nextInitialHistoryBudget: initialHistoryTransitionState.usesInitialHistoryRenderBudget,
});
scheduleHistoryProjectionHandoffRelease(2);
}, [
activeSessionId,
footerHeightPx,
initialHistoryTransitionState,
latestTurnId,
scheduleHistoryProjectionHandoffRelease,
virtualItems.length,
]);
useLayoutEffect(() => {
if (!activeSessionId || !latestTurnId || virtualItems.length === 0) {
return;
}

if (!isTurnTextRenderedInViewportOutsideHandoff(latestTurnId)) {
return;
}

const scroller = scrollerElementRef.current;
if (!scroller) {
return;
}

const tailStartIndex = Math.max(0, virtualItems.length - SESSION_OPEN_HANDOFF_ITEM_BUDGET);
latestVisibleHistoryTailSnapshotRef.current = {
sessionId: activeSessionId,
reason: 'latest-visible-history-tail',
createdAtMs: performance.now(),
items: virtualItems.slice(tailStartIndex),
mode: 'bottom-tail',
targetTurnId: latestTurnId,
anchorKey: null,
anchorOffsetTopPx: 0,
scrollTop: scroller.scrollTop,
scrollHeight: scroller.scrollHeight,
clientHeight: scroller.clientHeight,
footerHeightPx,
};
}, [
activeSessionId,
footerHeightPx,
isTurnTextRenderedInViewportOutsideHandoff,
latestTurnId,
virtualItems,
]);
const initialHistoryRenderWindow = React.useMemo(() => {
if (!useStaticInitialHistoryList) {
return {
Expand Down Expand Up @@ -4186,6 +4335,37 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef>((_, ref) =>
staticAnchorWindowTurnId,
useStaticInitialHistoryList,
]);
useLayoutEffect(() => {
if (
!useStaticInitialHistoryList ||
autoScrolledInitialHistoryRenderKeyRef.current !== initialHistoryRenderKey ||
performance.now() <= userInitiatedUpwardScrollUntilMsRef.current
) {
return;
}

const scroller = scrollerElementRef.current;
if (!scroller) {
return;
}

const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
if (Math.abs(maxScrollTop - scroller.scrollTop) <= LATEST_END_ANCHOR_STABLE_EPSILON_PX) {
recordScrollerGeometry(scroller);
return;
}

scroller.scrollTop = maxScrollTop;
previousScrollTopRef.current = maxScrollTop;
previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(scroller);
recordScrollerGeometry(scroller);
}, [
footerHeightPx,
initialHistoryRenderKey,
recordScrollerGeometry,
snapshotMeasuredContentHeight,
useStaticInitialHistoryList,
]);
// ── Render ────────────────────────────────────────────────────────────
useLayoutEffect(() => {
const snapshot = historyProjectionHandoffRef.current;
Expand Down
21 changes: 20 additions & 1 deletion src/web-ui/src/flow_chat/utils/flowChatTurnScrollPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { DialogTurn } from '../types/flow-chat';
import {
isDialogTurnInFlight,
isThreadGoalContinuationTurn,
shouldUseLatestTurnFollowOutput,
shouldUseStickyLatestPin,
} from './flowChatTurnScrollPolicy';

Expand Down Expand Up @@ -39,11 +40,29 @@ describe('flowChatTurnScrollPolicy', () => {
},
});
expect(isThreadGoalContinuationTurn(turn)).toBe(true);
expect(shouldUseLatestTurnFollowOutput(turn)).toBe(false);
expect(shouldUseStickyLatestPin(turn)).toBe(false);
});

it('allows sticky pin for in-flight user turns', () => {
const turn = makeTurn({ status: 'processing' });
const turn = makeTurn({
status: 'processing',
modelRounds: [{
id: 'round-1',
turnId: 'turn-1',
items: [],
isStreaming: true,
createdAt: 1,
}],
} as Partial<DialogTurn>);
expect(shouldUseLatestTurnFollowOutput(turn)).toBe(true);
expect(shouldUseStickyLatestPin(turn)).toBe(true);
});

it('lets follow-output own a newly submitted user-only turn before sticky pin starts', () => {
const turn = makeTurn({ status: 'processing', modelRounds: [] });
expect(isDialogTurnInFlight(turn)).toBe(true);
expect(shouldUseLatestTurnFollowOutput(turn)).toBe(true);
expect(shouldUseStickyLatestPin(turn)).toBe(false);
});
});
12 changes: 10 additions & 2 deletions src/web-ui/src/flow_chat/utils/flowChatTurnScrollPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,22 @@ export function isThreadGoalContinuationTurn(turn: DialogTurn | undefined): bool
return Boolean(turn?.userMessage?.metadata?.threadGoalContinuation);
}

/** Sticky-latest pin is for active user turns; auto goal checks stay at the natural tail. */
export function shouldUseStickyLatestPin(turn: DialogTurn | undefined): boolean {
/** Follow-output owns active user turns; auto goal checks stay at the natural tail. */
export function shouldUseLatestTurnFollowOutput(turn: DialogTurn | undefined): boolean {
if (!turn || isThreadGoalContinuationTurn(turn)) {
return false;
}
return isDialogTurnInFlight(turn);
}

/** Sticky-latest pin starts only after model output exists. */
export function shouldUseStickyLatestPin(turn: DialogTurn | undefined): boolean {
if (!turn || !shouldUseLatestTurnFollowOutput(turn)) {
return false;
}
return turn.modelRounds.length > 0;
}

export function findDialogTurn(
dialogTurns: DialogTurn[] | undefined,
turnId: string | null | undefined,
Expand Down
Loading
Loading