diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx index a6bdcd70f..85e678d24 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx @@ -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' }), ); }); diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx index cd97dbac0..bc9cfe6a7 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -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'; @@ -588,6 +589,7 @@ export const ModernFlowChatContainer: React.FC = ( () => findDialogTurn(activeSession?.dialogTurns, latestTurnId), [activeSession?.dialogTurns, latestTurnId], ); + const latestTurnUsesFollowOutput = shouldUseLatestTurnFollowOutput(latestTurn); const latestTurnUsesStickyPin = shouldUseStickyLatestPin(latestTurn); const navigationVisibleTurnInfo = useMemo(() => { @@ -731,14 +733,14 @@ export const ModernFlowChatContainer: React.FC = ( 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; @@ -871,6 +873,7 @@ export const ModernFlowChatContainer: React.FC = ( activeSession?.remoteSshHost, hasPendingHistoryCompletion, latestTurnId, + latestTurnUsesFollowOutput, latestTurnUsesStickyPin, turnSummaries.length, visibleTurnInfo?.turnId, diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx index 38522749f..9405e2c84 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -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. /** @@ -357,6 +365,8 @@ const VirtualMessageListSession = forwardRef((_, ref) => const pendingHistoryProjectionHandoffRef = useRef(null); const historyProjectionHandoffRef = useRef(null); const historyProjectionHandoffReleaseFrameRef = useRef(null); + const latestVisibleHistoryTailSnapshotRef = useRef(null); + const previousInitialHistoryTransitionStateRef = useRef(null); const fullHistoryProjectionIntentFrameRef = useRef(null); const pendingFullHistoryProjectionReasonRef = useRef(null); const sessionOpenHandoffSessionIdRef = useRef(null); @@ -368,6 +378,7 @@ const VirtualMessageListSession = forwardRef((_, ref) => const previousScrollerGeometryRef = useRef(null); const markedInitialHistoryRenderWindowKeyRef = useRef(null); const autoScrolledInitialHistoryRenderKeyRef = useRef(null); + const useStaticInitialHistoryListRef = useRef(false); const pendingInitialHistoryExpansionRef = useRef<{ scrollTop: number; scrollHeight: number; @@ -532,13 +543,16 @@ const VirtualMessageListSession = forwardRef((_, 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( @@ -558,27 +572,31 @@ const VirtualMessageListSession = forwardRef((_, 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), @@ -1018,6 +1036,7 @@ const VirtualMessageListSession = forwardRef((_, 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]); @@ -2215,7 +2234,12 @@ const VirtualMessageListSession = forwardRef((_, 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'); @@ -3729,6 +3753,131 @@ const VirtualMessageListSession = forwardRef((_, ref) => }, [lastItemInfo.isTurnProcessing, lastItemInfo.lastItem, isProcessing, processingPhase, isContentGrowing]); const footerHeightPx = getFooterHeightPx(getTotalBottomCompensationPx(bottomReservationState)); + const initialHistoryTransitionState = React.useMemo(() => { + 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 { @@ -4186,6 +4335,37 @@ const VirtualMessageListSession = forwardRef((_, 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; diff --git a/src/web-ui/src/flow_chat/utils/flowChatTurnScrollPolicy.test.ts b/src/web-ui/src/flow_chat/utils/flowChatTurnScrollPolicy.test.ts index b40017a28..6777fd623 100644 --- a/src/web-ui/src/flow_chat/utils/flowChatTurnScrollPolicy.test.ts +++ b/src/web-ui/src/flow_chat/utils/flowChatTurnScrollPolicy.test.ts @@ -3,6 +3,7 @@ import type { DialogTurn } from '../types/flow-chat'; import { isDialogTurnInFlight, isThreadGoalContinuationTurn, + shouldUseLatestTurnFollowOutput, shouldUseStickyLatestPin, } from './flowChatTurnScrollPolicy'; @@ -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); + 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); + }); }); diff --git a/src/web-ui/src/flow_chat/utils/flowChatTurnScrollPolicy.ts b/src/web-ui/src/flow_chat/utils/flowChatTurnScrollPolicy.ts index 783488b3a..1d1ceafd0 100644 --- a/src/web-ui/src/flow_chat/utils/flowChatTurnScrollPolicy.ts +++ b/src/web-ui/src/flow_chat/utils/flowChatTurnScrollPolicy.ts @@ -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, diff --git a/tests/e2e/specs/performance/startup-session-perf.spec.ts b/tests/e2e/specs/performance/startup-session-perf.spec.ts index be7009492..54c06990a 100644 --- a/tests/e2e/specs/performance/startup-session-perf.spec.ts +++ b/tests/e2e/specs/performance/startup-session-perf.spec.ts @@ -688,18 +688,25 @@ async function waitForOptionalTracePhaseForSessionSince( async function findSessionItem(sessionId: string): Promise | null> { const readVisibleSessionIds = async (): Promise => browser.execute(() => - Array.from(document.querySelectorAll('[data-testid="session-nav-item"]')) + Array.from(document.querySelectorAll( + '[data-testid="session-nav-item"], [data-testid="nav-session-item"]', + )) .map(element => element.getAttribute('data-session-id') || '') .filter(Boolean) ); const findTarget = async (): Promise | null> => { - const item = await $(`[data-testid="session-nav-item"][data-session-id="${sessionId}"]`); + const item = await $( + `[data-testid="session-nav-item"][data-session-id="${sessionId}"], ` + + `[data-testid="nav-session-item"][data-session-id="${sessionId}"]`, + ); return await item.isExisting() ? item : null; }; const findExpandableToggles = async (): Promise>> => { - const toggles = await browser.$$('[data-testid="session-nav-show-more"]'); + const toggles = await browser.$$( + '[data-testid="session-nav-show-more"], [data-testid="nav-session-list-toggle"]', + ); const expandable: Array> = []; for (const toggle of toggles) { if ( @@ -3611,6 +3618,42 @@ type LongSessionOpenMeasurementOptions = { postVisibleInteraction?: LongSessionPostVisibleInteraction; }; +type LongSessionSendAfterOpenMeasurement = { + appMode: string; + sessionId: string; + fixtureScenario: string | null; + previousLatestTurnId: string; + message: string; + clickedAtMs: number; + postSendObserveMs: number; + beforeState: LongSessionActiveMessageState; + afterSendState: LongSessionActiveMessageState; + finalState: LongSessionActiveMessageState; + observedLatestTurnId: string; + viewport: LongSessionViewportState; + viewportTimelineSummary: LongSessionViewportTimelineSummary; + visualStateSummary: LongSessionVisualStateSummary; + visualStateEvents: LongSessionVisualStateEvent[]; + mutationEvents: LongSessionDomMutationEvent[]; + layoutShiftEvents: LongSessionLayoutShiftEvent[]; + screenshotPath: string | null; + events: StartupTraceSnapshot['phases']['events']; + apiSegments: ReturnType; + api: StartupTraceSnapshot['api']; + native: StartupTraceSnapshot['native']; +}; + +type LongSessionActiveMessageState = { + activeSessionId: string | null; + latestTurnId: string | null; + dialogTurnCount: number; + virtualItemCount: number; + visibleTextLength: number; + showHistoryLoadingLayer: string | null; + showHistoryOpenIntentOverlay: string | null; + showHistoryTransitionOverlay: string | null; +}; + type RapidLongSessionSwitchMeasurement = { appMode: string; requestedSessionIds: string[]; @@ -3898,6 +3941,7 @@ async function readActiveSessionNavId(): Promise { return browser.execute(() => { const active = document.querySelector( '[data-testid="session-nav-item"].is-active, ' + + '[data-testid="nav-session-item"].is-active, ' + '.bitfun-nav-panel__inline-item.is-active[data-session-id]', ); return active?.getAttribute('data-session-id') ?? null; @@ -4385,6 +4429,261 @@ async function collectLongSessionOpenMeasurement( return measurement; } +async function readLongSessionActiveMessageState(): Promise { + return browser.execute(() => { + const messages = document.querySelector('.modern-flowchat-container__messages'); + const virtualItems = Array.from( + document.querySelectorAll('.modern-flowchat-container__messages .virtual-item-wrapper'), + ); + const visibleTextLength = virtualItems.reduce((total, item) => { + const rect = item.getBoundingClientRect(); + const style = window.getComputedStyle(item); + if ( + rect.width <= 0 || + rect.height <= 0 || + style.display === 'none' || + style.visibility === 'hidden' || + Number(style.opacity || '1') <= 0.01 + ) { + return total; + } + return total + (item.innerText?.length ?? 0); + }, 0); + const numeric = (value: string | undefined): number => { + const parsed = Number(value ?? 0); + return Number.isFinite(parsed) ? parsed : 0; + }; + + return { + activeSessionId: messages?.dataset.activeSessionId || null, + latestTurnId: messages?.dataset.latestTurnId || null, + dialogTurnCount: numeric(messages?.dataset.dialogTurnCount), + virtualItemCount: numeric(messages?.dataset.virtualItemCount), + visibleTextLength, + showHistoryLoadingLayer: messages?.dataset.showHistoryLoadingLayer || null, + showHistoryOpenIntentOverlay: messages?.dataset.showHistoryOpenIntentOverlay || null, + showHistoryTransitionOverlay: messages?.dataset.showHistoryTransitionOverlay || null, + }; + }); +} + +async function ensureLongSessionOpenForSend( + sessionId: string, + expectedLatestTurnId: string, +): Promise { + await switchAwayFromSession(sessionId); + + const activeSessionId = await readActiveSessionNavId(); + if (activeSessionId !== sessionId) { + const item = await findSessionItem(sessionId); + if (!item) { + return false; + } + await item.click(); + } + + const fixtureScenario = await readLongSessionFixtureScenario(sessionId); + await waitForLatestLongSessionTurnVisible(5000, expectedLatestTurnId); + await waitForLatestLongSessionViewportUsable( + 5000, + expectedLatestTurnId, + { requireLatestModelRound: requiresLatestModelRoundForFixture(fixtureScenario) }, + ); + await waitForBrowserAnimationFrames(2); + return true; +} + +async function clearLongSessionPendingQueue(sessionId: string): Promise { + await browser.execute(async (targetSessionId) => { + try { + const { pendingQueueManager } = await import( + '/src/flow_chat/services/flow-chat-manager/PendingQueueModule.ts' + ); + pendingQueueManager.clear(targetSessionId); + } catch { + // Keep the localStorage fallback below for bundled/runtime import differences. + } + try { + window.localStorage.removeItem(`flowChat.pendingQueue.${targetSessionId}`); + } catch { + // localStorage may be unavailable in unusual WebView states. + } + }, sessionId); + + await browser.waitUntil(async () => { + const pendingQueueCount = await browser.execute(() => + document.querySelectorAll('[data-testid="pending-queue-panel"] .bitfun-pending-queue-panel__item').length + ); + return pendingQueueCount === 0; + }, { + timeout: 1000, + interval: 50, + timeoutMsg: 'Pending queue did not clear before send-after-open measurement', + }).catch(() => undefined); +} + +async function setLongSessionChatInputValue(message: string): Promise { + await browser.execute((value) => { + const input = document.querySelector( + '.rich-text-input[contenteditable="true"], ' + + '[data-testid="chat-input-textarea"], ' + + '.bitfun-chat-input [contenteditable="true"]', + ); + if (!input) { + throw new Error('Chat input was not found'); + } + + input.focus(); + if (input.isContentEditable) { + input.textContent = value; + } else if ('value' in input) { + (input as HTMLInputElement | HTMLTextAreaElement).value = value; + } else { + input.textContent = value; + } + + const inputEvent = typeof InputEvent !== 'undefined' + ? new InputEvent('input', { bubbles: true, inputType: 'insertText', data: value }) + : new Event('input', { bubbles: true }); + input.dispatchEvent(inputEvent); + input.dispatchEvent(new Event('change', { bubbles: true })); + }, message); + + await browser.waitUntil(async () => { + const button = await $('[data-testid="chat-input-send-btn"], button.bitfun-chat-input__send-button'); + if (!await button.isExisting()) { + return false; + } + const disabled = await button.getAttribute('disabled'); + const ariaDisabled = await button.getAttribute('aria-disabled'); + return await button.isEnabled() && disabled === null && ariaDisabled !== 'true'; + }, { + timeout: 3000, + interval: 50, + timeoutMsg: 'Chat send button did not become enabled', + }); +} + +async function clickLongSessionSendButton(): Promise { + const button = await $('[data-testid="chat-input-send-btn"], button.bitfun-chat-input__send-button'); + await button.waitForExist({ timeout: 3000 }); + await button.click(); + await browser.execute(() => { + window.dispatchEvent(new CustomEvent('bitfun:e2e-long-session-user-interaction', { + detail: { type: 'send-message' }, + })); + }); +} + +async function collectLongSessionSendAfterOpenMeasurement( + sessionId: string, + previousLatestTurnId: string, +): Promise { + const fixtureScenario = await readLongSessionFixtureScenario(sessionId); + const message = `E2E history send blank probe ${Date.now()}`; + + await clearLongSessionPendingQueue(sessionId); + await setLongSessionChatInputValue(message); + const beforeState = await readLongSessionActiveMessageState(); + if (beforeState.activeSessionId !== sessionId) { + throw new Error( + `Expected active session ${sessionId} before send, got ${beforeState.activeSessionId ?? 'none'}`, + ); + } + + const clickedAtMs = await readPerformanceNow(); + await startLongSessionViewportTimelineRecorder( + previousLatestTurnId, + clickedAtMs, + process.env.BITFUN_E2E_RENDER_PROFILE === '1', + ); + + await clickLongSessionSendButton(); + + let afterSendState = await readLongSessionActiveMessageState(); + await browser.waitUntil(async () => { + const state = await readLongSessionActiveMessageState(); + const changed = + state.dialogTurnCount !== beforeState.dialogTurnCount || + state.latestTurnId !== beforeState.latestTurnId || + state.virtualItemCount !== beforeState.virtualItemCount; + if (changed) { + afterSendState = state; + } + return changed; + }, { + timeout: 1500, + interval: 25, + timeoutMsg: 'Send did not change the active session message state', + }).catch(() => undefined); + + const postSendObserveMs = numericEnv('BITFUN_E2E_PERF_SEND_AFTER_OPEN_OBSERVE_MS') ?? 2000; + if (postSendObserveMs > 0) { + await browser.pause(postSendObserveMs); + } + + const finalState = await readLongSessionActiveMessageState(); + const observedLatestTurnId = + finalState.latestTurnId || + afterSendState.latestTurnId || + beforeState.latestTurnId || + previousLatestTurnId; + const viewport = await readLongSessionViewportState(observedLatestTurnId); + const viewportTimeline = await stopLongSessionViewportTimelineRecorder(); + const viewportTimelineSummary = summarizeLongSessionViewportTimeline( + viewportTimeline.samples, + sessionId, + ); + const visualStateSummary = summarizeLongSessionVisualStateEvents( + viewportTimeline.visualStateEvents, + viewportTimeline.mutationEvents, + viewportTimeline.layoutShiftEvents, + viewportTimelineSummary, + sessionId, + ); + const verboseTimelineReport = process.env.BITFUN_E2E_PERF_VERBOSE_REPORT === '1'; + const finalSnapshot = await readStartupTraceSnapshot(); + const events = finalSnapshot.phases.events.filter(event => + event.atMs >= clickedAtMs && + ( + ( + event.phase.startsWith('historical_session') && + traceEventSessionId(event) === sessionId + ) || + event.phase.startsWith('flowchat') || + event.phase === 'react_render_profile' || + event.phase === 'git_status_request' || + event.phase === 'git_state_refresh' + ) + ); + const screenshotPath = await maybeSavePerfScreenshot(`long-session-send-${sessionId}`); + + return { + appMode: process.env.BITFUN_E2E_APP_MODE ?? 'auto', + sessionId, + fixtureScenario, + previousLatestTurnId, + message, + clickedAtMs, + postSendObserveMs, + beforeState, + afterSendState, + finalState, + observedLatestTurnId, + viewport, + viewportTimelineSummary, + visualStateSummary, + visualStateEvents: verboseTimelineReport ? viewportTimeline.visualStateEvents : [], + mutationEvents: verboseTimelineReport ? viewportTimeline.mutationEvents : [], + layoutShiftEvents: verboseTimelineReport ? viewportTimeline.layoutShiftEvents : [], + screenshotPath, + events, + apiSegments: summarizeApiCommandSegments(finalSnapshot), + api: finalSnapshot.api, + native: finalSnapshot.native, + }; +} + function expectLongSessionMeasurementUsable( measurement: LongSessionOpenMeasurement, maxLatestFrameMs?: number, @@ -4569,6 +4868,30 @@ function expectLongSessionMeasurementUsable( } } +function expectLongSessionSendAfterOpenStable( + measurement: LongSessionSendAfterOpenMeasurement, +): void { + expect(measurement.beforeState.activeSessionId).toBe(measurement.sessionId); + expect(measurement.afterSendState.activeSessionId).toBe(measurement.sessionId); + expect(measurement.finalState.activeSessionId).toBe(measurement.sessionId); + expect(measurement.beforeState.dialogTurnCount).toBeGreaterThan(0); + expect(measurement.afterSendState.dialogTurnCount).toBeGreaterThan(measurement.beforeState.dialogTurnCount); + expect(measurement.afterSendState.latestTurnId).not.toBe(measurement.beforeState.latestTurnId); + expect(measurement.afterSendState.virtualItemCount).toBeGreaterThan(measurement.beforeState.virtualItemCount); + expect(measurement.viewport.hasScroller).toBe(true); + expect(measurement.viewport.visibleItemCount).toBeGreaterThan(0); + expect(measurement.viewport.visibleTextLength).toBeGreaterThan(0); + expect(measurement.visualStateSummary.firstLoadingLayerAtMs).toBeNull(); + expect(measurement.visualStateSummary.loadingLayerToggleCount).toBe(0); + expect(measurement.visualStateSummary.overlayCountToggleCount).toBe(0); + expect(measurement.visualStateSummary.placeholderCountToggleCount).toBe(0); + expect(measurement.visualStateSummary.postUserInteractionBlankSurfacePointEventCount).toBe(0); + expect(measurement.visualStateSummary.postLatestTextVisibleBlankSurfacePointEventCount).toBe(0); + expect(measurement.visualStateSummary.postLatestTextVisibleLoadingSurfacePointEventCount).toBe(0); + expect(measurement.visualStateSummary.postLatestTextVisibleTransparentSurfacePointEventCount).toBe(0); + expect(measurement.visualStateSummary.loadingTransitions).toHaveLength(0); +} + describe('Performance telemetry', () => { const startupPage = new StartupPage(); @@ -4685,6 +5008,52 @@ describe('Performance telemetry', () => { }); }); + it('keeps a generated long session visible after sending a new message', async function () { + await ensurePerformanceWorkspace(startupPage); + + const sessionId = process.env.BITFUN_E2E_PERF_SESSION_ID || DEFAULT_PERF_SESSION_ID; + const expectedLatestTurnId = await readExpectedLatestTurnId(sessionId); + if (!expectedLatestTurnId) { + console.log(`[Perf] Session ${sessionId} not found; generate it before running this spec.`); + this.skip(); + return; + } + + const opened = await ensureLongSessionOpenForSend(sessionId, expectedLatestTurnId); + if (!opened) { + console.log(`[Perf] Session ${sessionId} not reachable from the session navigation.`); + this.skip(); + return; + } + + const measurement = await collectLongSessionSendAfterOpenMeasurement( + sessionId, + expectedLatestTurnId, + ); + + console.log('[Perf] long-session-send-after-open', JSON.stringify({ + appMode: measurement.appMode, + sessionId, + fixtureScenario: measurement.fixtureScenario, + beforeState: measurement.beforeState, + afterSendState: measurement.afterSendState, + finalState: measurement.finalState, + visualStateSummary: { + firstLoadingLayerAtMs: measurement.visualStateSummary.firstLoadingLayerAtMs, + loadingLayerToggleCount: measurement.visualStateSummary.loadingLayerToggleCount, + postUserInteractionBlankSurfacePointEventCount: + measurement.visualStateSummary.postUserInteractionBlankSurfacePointEventCount, + postLatestTextVisibleBlankSurfacePointEventCount: + measurement.visualStateSummary.postLatestTextVisibleBlankSurfacePointEventCount, + virtualItemElementChangeCount: + measurement.visualStateSummary.postLatestTextVisibleVirtualItemElementChangeCount, + }, + })); + + await writeReport('long-session-send-after-open', measurement); + expectLongSessionSendAfterOpenStable(measurement); + }); + it('collects rapid-switch timing across generated long sessions', async function () { await ensurePerformanceWorkspace(startupPage);