diff --git a/package.json b/package.json index 898165517..765bac048 100644 --- a/package.json +++ b/package.json @@ -72,8 +72,8 @@ "e2e:test:l1": "cross-env BITFUN_E2E_APP_MODE=debug pnpm --dir tests/e2e run test:l1", "e2e:test:smoke": "cross-env BITFUN_E2E_APP_MODE=debug pnpm --dir tests/e2e run test:smoke", "e2e:test:chat": "cross-env BITFUN_E2E_APP_MODE=debug pnpm --dir tests/e2e run test:chat", - "e2e:test:perf:debug": "cross-env BITFUN_E2E_APP_MODE=debug pnpm --dir tests/e2e run test:perf", - "e2e:test:perf:release-fast": "cross-env BITFUN_E2E_APP_MODE=release-fast pnpm --dir tests/e2e run test:perf" + "e2e:test:perf:debug": "cross-env BITFUN_E2E_APP_MODE=debug E2E_LOG_LEVEL=warn pnpm --dir tests/e2e run test:perf", + "e2e:test:perf:release-fast": "cross-env BITFUN_E2E_APP_MODE=release-fast E2E_LOG_LEVEL=warn pnpm --dir tests/e2e run test:perf" }, "devDependencies": { "@tauri-apps/cli": "^2.10.0", diff --git a/src/apps/desktop/src/theme.rs b/src/apps/desktop/src/theme.rs index bcafd437b..ef4b93a0e 100644 --- a/src/apps/desktop/src/theme.rs +++ b/src/apps/desktop/src/theme.rs @@ -1,7 +1,7 @@ //! Theme System use std::sync::{OnceLock, RwLock}; -use std::time::Instant; +use std::time::{Duration, Instant}; use bitfun_core::infrastructure::try_get_path_manager_arc; use bitfun_core::service::config::types::GlobalConfig; @@ -18,11 +18,106 @@ const AGENT_COMPANION_WINDOW_MAX_WIDTH: f64 = 360.0; const AGENT_COMPANION_WINDOW_MAX_HEIGHT: f64 = 240.0; const AGENT_COMPANION_WINDOW_MARGIN: i32 = 64; const AGENT_COMPANION_WINDOW_EDGE_MARGIN: f64 = 8.0; +#[cfg(target_os = "windows")] +const WINDOWS_STARTUP_MAXIMIZE_SHOW_WAIT_MAX: Duration = Duration::from_millis(150); +#[cfg(target_os = "windows")] +const WINDOWS_STARTUP_MAXIMIZE_SHOW_WAIT_MIN: Duration = Duration::from_millis(16); +#[cfg(target_os = "windows")] +const WINDOWS_STARTUP_MAXIMIZE_SHOW_WAIT_POLL: Duration = Duration::from_millis(8); static AGENT_COMPANION_WINDOW_OPS: OnceLock> = OnceLock::new(); static AGENT_COMPANION_WINDOW_LAST_POSITION: OnceLock>>> = OnceLock::new(); +#[cfg(target_os = "windows")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WindowsMaximizeShowWaitAction { + Ready, + Sleep(Duration), + TimedOut, +} + +#[cfg(target_os = "windows")] +fn windows_maximize_show_wait_action( + is_maximized: Option, + elapsed: Duration, +) -> WindowsMaximizeShowWaitAction { + if is_maximized == Some(true) && elapsed >= WINDOWS_STARTUP_MAXIMIZE_SHOW_WAIT_MIN { + return WindowsMaximizeShowWaitAction::Ready; + } + + if elapsed >= WINDOWS_STARTUP_MAXIMIZE_SHOW_WAIT_MAX { + return WindowsMaximizeShowWaitAction::TimedOut; + } + + let target_wait = if is_maximized == Some(true) { + WINDOWS_STARTUP_MAXIMIZE_SHOW_WAIT_MIN + } else { + WINDOWS_STARTUP_MAXIMIZE_SHOW_WAIT_MAX + }; + WindowsMaximizeShowWaitAction::Sleep( + target_wait + .saturating_sub(elapsed) + .min(WINDOWS_STARTUP_MAXIMIZE_SHOW_WAIT_POLL), + ) +} + +#[cfg(target_os = "windows")] +fn read_windows_maximize_state( + window: &tauri::WebviewWindow, + logged_error: &mut bool, +) -> Option { + match window.is_maximized() { + Ok(is_maximized) => Some(is_maximized), + Err(error) => { + if !*logged_error { + warn!( + "Failed to read main window maximize state during startup show wait: {}", + error + ); + *logged_error = true; + } + None + } + } +} + +#[cfg(target_os = "windows")] +fn wait_for_windows_maximize_before_show(window: &tauri::WebviewWindow) -> &'static str { + let started_at = Instant::now(); + let mut logged_error = false; + + loop { + match windows_maximize_show_wait_action( + read_windows_maximize_state(window, &mut logged_error), + started_at.elapsed(), + ) { + WindowsMaximizeShowWaitAction::Ready => return "ready", + WindowsMaximizeShowWaitAction::TimedOut => return "timeout", + WindowsMaximizeShowWaitAction::Sleep(duration) => std::thread::sleep(duration), + } + } +} + +#[cfg(target_os = "windows")] +async fn wait_for_windows_maximize_before_show_async( + window: &tauri::WebviewWindow, +) -> &'static str { + let started_at = Instant::now(); + let mut logged_error = false; + + loop { + match windows_maximize_show_wait_action( + read_windows_maximize_state(window, &mut logged_error), + started_at.elapsed(), + ) { + WindowsMaximizeShowWaitAction::Ready => return "ready", + WindowsMaximizeShowWaitAction::TimedOut => return "timeout", + WindowsMaximizeShowWaitAction::Sleep(duration) => tokio::time::sleep(duration).await, + } + } +} + fn agent_companion_window_ops() -> &'static tokio::sync::Mutex<()> { AGENT_COMPANION_WINDOW_OPS.get_or_init(|| tokio::sync::Mutex::new(())) } @@ -568,12 +663,18 @@ fn show_main_window_for_startup( ); } let show_delay_started_at = Instant::now(); - std::thread::sleep(std::time::Duration::from_millis(150)); + let show_wait_outcome = wait_for_windows_maximize_before_show(window); startup_trace.record_elapsed_step( "native_window", "windows_show_after_maximize_wait", show_delay_started_at, ); + debug!( + "Main window startup show step completed: step=wait_for_maximize_state outcome={} duration_ms={} since_create_start_ms={}", + show_wait_outcome, + show_delay_started_at.elapsed().as_millis(), + total_started_at.elapsed().as_millis() + ); } let show_started_at = Instant::now(); @@ -915,7 +1016,13 @@ pub async fn show_main_window(app: tauri::AppHandle) -> Result<(), String> { step_started_at.elapsed().as_millis() ); - tokio::time::sleep(std::time::Duration::from_millis(150)).await; + let wait_started_at = Instant::now(); + let show_wait_outcome = wait_for_windows_maximize_before_show_async(&main_window).await; + debug!( + "Main window show step completed: step=wait_for_maximize_state outcome={} duration_ms={}", + show_wait_outcome, + wait_started_at.elapsed().as_millis() + ); } let step_started_at = Instant::now(); @@ -954,3 +1061,43 @@ pub async fn show_main_window(app: tauri::AppHandle) -> Result<(), String> { ); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(target_os = "windows")] + #[test] + fn windows_maximize_show_wait_releases_when_maximized() { + assert_eq!( + windows_maximize_show_wait_action(Some(true), Duration::ZERO), + WindowsMaximizeShowWaitAction::Sleep(WINDOWS_STARTUP_MAXIMIZE_SHOW_WAIT_POLL) + ); + assert_eq!( + windows_maximize_show_wait_action(Some(true), WINDOWS_STARTUP_MAXIMIZE_SHOW_WAIT_MIN), + WindowsMaximizeShowWaitAction::Ready + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_maximize_show_wait_polls_until_max_wait() { + assert_eq!( + windows_maximize_show_wait_action(Some(false), Duration::from_millis(20)), + WindowsMaximizeShowWaitAction::Sleep(WINDOWS_STARTUP_MAXIMIZE_SHOW_WAIT_POLL) + ); + assert_eq!( + windows_maximize_show_wait_action(None, Duration::from_millis(148)), + WindowsMaximizeShowWaitAction::Sleep(Duration::from_millis(2)) + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_maximize_show_wait_times_out_at_original_bound() { + assert_eq!( + windows_maximize_show_wait_action(Some(false), WINDOWS_STARTUP_MAXIMIZE_SHOW_WAIT_MAX), + WindowsMaximizeShowWaitAction::TimedOut + ); + } +} diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index 957b237f4..a19fe72e2 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useEffect, useCallback, useState, useRef } from 'react'; +import { lazy, Suspense, useEffect, useCallback, useLayoutEffect, useState, useRef } from 'react'; import { useShortcut } from '@/infrastructure/hooks/useShortcut'; import { useHasDismissibleLayer } from '@/infrastructure/hooks/useDismissibleLayer'; import { dismissibleLayerManager } from '@/infrastructure/services/DismissibleLayerManager'; @@ -17,6 +17,8 @@ import { useGlobalSceneShortcuts } from './hooks/useGlobalSceneShortcuts'; import { useDebugInspector } from '@/infrastructure/debug/useDebugInspector'; import { useI18n } from '@/infrastructure/i18n'; import { scheduleDeferredStartupSystems } from './startup/deferredStartupSystems'; +import { shouldScheduleDeferredStartupSystems } from './startup/deferredStartupGate'; +import { STARTUP_OVERLAY_HIDDEN_EVENT } from './startup/startupSignals'; import { getStartupOverlayElapsedMs, hideStartupOverlay, @@ -41,7 +43,7 @@ const LazyAppLayout = lazy(async () => { startupTrace.markPhase('app_layout_import_end'); return { default: function AppLayoutStartupGate({ onReady }: AppLayoutStartupGateProps) { - useEffect(() => { + useLayoutEffect(() => { startupTrace.markPhase('app_layout_ready'); onReady(); }, [onReady]); @@ -77,12 +79,77 @@ function App() { const [startupOverlayVisible, setStartupOverlayVisible] = useState(isStartupOverlayPresent); const hasAppDismissibleLayer = useHasDismissibleLayer('app'); const mainWindowShownRef = useRef(false); + const userCloseRequestedRef = useRef(false); const interactiveShellReadyRef = useRef(false); + const interactiveShellReadyFrameRef = useRef(null); + const workspaceLoadingRef = useRef(workspaceLoading); + const appLayoutReadyRef = useRef(false); const [interactiveShellReady, setInteractiveShellReady] = useState(false); const [appLayoutReady, setAppLayoutReady] = useState(false); + + workspaceLoadingRef.current = workspaceLoading; + + const releaseInteractiveShellReadyIfReady = useCallback((reason: string) => { + const latestWorkspaceLoading = workspaceLoadingRef.current; + const latestAppLayoutReady = appLayoutReadyRef.current; + startupTrace.markPhase('interactive_shell_ready_gate_check', { + workspaceLoading: latestWorkspaceLoading, + appLayoutReady: latestAppLayoutReady, + alreadyReady: interactiveShellReadyRef.current, + reason, + afterPaint: true, + }); + if (latestWorkspaceLoading || !latestAppLayoutReady || interactiveShellReadyRef.current) { + return; + } + interactiveShellReadyRef.current = true; + startupTrace.markPhase('interactive_shell_ready', { reason }); + window.dispatchEvent(new CustomEvent('bitfun:interactive-shell-ready', { + detail: { reason }, + })); + setInteractiveShellReady(true); + }, []); + + const markInteractiveShellReadyIfReady = useCallback((reason: string) => { + const latestWorkspaceLoading = workspaceLoadingRef.current; + const latestAppLayoutReady = appLayoutReadyRef.current; + startupTrace.markPhase('interactive_shell_ready_gate_check', { + workspaceLoading: latestWorkspaceLoading, + appLayoutReady: latestAppLayoutReady, + alreadyReady: interactiveShellReadyRef.current, + alreadyScheduled: interactiveShellReadyFrameRef.current !== null, + reason, + }); + if ( + latestWorkspaceLoading || + !latestAppLayoutReady || + interactiveShellReadyRef.current || + interactiveShellReadyFrameRef.current !== null + ) { + return; + } + + startupTrace.markPhase('interactive_shell_ready_after_paint_scheduled', { reason }); + interactiveShellReadyFrameRef.current = window.requestAnimationFrame(() => { + interactiveShellReadyFrameRef.current = null; + releaseInteractiveShellReadyIfReady(`${reason}-after-paint`); + }); + }, [releaseInteractiveShellReadyIfReady]); + const handleAppLayoutReady = useCallback(() => { startupTrace.markPhase('app_layout_ready_state_update_requested'); + appLayoutReadyRef.current = true; setAppLayoutReady(true); + markInteractiveShellReadyIfReady('app-layout-ready'); + }, [markInteractiveShellReadyIfReady]); + + useEffect(() => { + return () => { + if (interactiveShellReadyFrameRef.current !== null) { + window.cancelAnimationFrame(interactiveShellReadyFrameRef.current); + interactiveShellReadyFrameRef.current = null; + } + }; }, []); // Once the workspace finishes loading, wait for the remaining min-display @@ -96,6 +163,8 @@ function App() { void hideStartupOverlay().then(() => { if (!cancelled) { setStartupOverlayVisible(false); + startupTrace.markPhase('startup_overlay_hidden'); + window.dispatchEvent(new CustomEvent(STARTUP_OVERLAY_HIDDEN_EVENT)); } }); }, remaining); @@ -105,6 +174,38 @@ function App() { }; }, [workspaceLoading, appLayoutReady]); + useEffect(() => { + if (!isTauriRuntime()) { + return; + } + + let unlisten: (() => void) | null = null; + let disposed = false; + + void import('@tauri-apps/api/event') + .then(({ listen }) => listen('bitfun_main_window_close_requested', () => { + userCloseRequestedRef.current = true; + startupTrace.markPhase('main_window_user_close_requested', { reason: 'user-close-requested' }); + })) + .then(removeListener => { + if (disposed) { + removeListener(); + return; + } + unlisten = removeListener; + }) + .catch(error => { + if (!disposed) { + log.warn('Failed to listen for main window close request in startup visibility guard', error); + } + }); + + return () => { + disposed = true; + unlisten?.(); + }; + }, []); + const showMainWindow = useCallback(async (reason: string) => { if (mainWindowShownRef.current) { return; @@ -136,6 +237,14 @@ function App() { }, []); const verifyMainWindowVisible = useCallback(async (reason: string) => { + if (userCloseRequestedRef.current) { + log.debug('Skipping main window startup visibility retry after user close request', { + reason, + closeReason: 'user-close-requested', + }); + return; + } + if (!isTauriRuntime()) { void showMainWindow(reason); return; @@ -172,21 +281,11 @@ function App() { }, [showMainWindow]); useEffect(() => { - startupTrace.markPhase('interactive_shell_ready_gate_check', { - workspaceLoading, - appLayoutReady, - alreadyReady: interactiveShellReadyRef.current, - }); - if (workspaceLoading || !appLayoutReady || interactiveShellReadyRef.current) { - return; + if (appLayoutReady) { + appLayoutReadyRef.current = true; } - interactiveShellReadyRef.current = true; - startupTrace.markPhase('interactive_shell_ready'); - window.dispatchEvent(new CustomEvent('bitfun:interactive-shell-ready', { - detail: { reason: 'workspace-and-layout-ready' }, - })); - setInteractiveShellReady(true); - }, [workspaceLoading, appLayoutReady]); + markInteractiveShellReadyIfReady('workspace-or-layout-state'); + }, [workspaceLoading, appLayoutReady, markInteractiveShellReadyIfReady]); // If the early reveal path fails, keep the old post-splash show as a retry. useEffect(() => { @@ -210,13 +309,14 @@ function App() { return () => window.clearTimeout(timer); }, [verifyMainWindowVisible]); - // Non-critical systems are delayed until the shell is interactive. + // Non-critical systems are delayed until the shell is interactive and the + // startup overlay has fully handed off to the app surface. useEffect(() => { - if (!interactiveShellReady) { + if (!shouldScheduleDeferredStartupSystems({ interactiveShellReady, startupOverlayVisible })) { return; } - log.info('Application interactive, scheduling deferred systems'); + log.info('Application visible and interactive, scheduling deferred systems'); const startupSystemsHandle = scheduleDeferredStartupSystems(); startupSystemsHandle.promise.catch(error => { if (!isBackgroundTaskCancelledError(error)) { @@ -225,7 +325,7 @@ function App() { }); return () => startupSystemsHandle.cancel(); - }, [interactiveShellReady]); + }, [interactiveShellReady, startupOverlayVisible]); useEffect(() => { if (!interactiveShellReady || startupOverlayVisible) { diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index 565400199..8d0a50c97 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -22,6 +22,10 @@ import { openMainSession, selectActiveBtwSessionTab, } from '@/flow_chat/services/openBtwSession'; +import { + dispatchHistorySessionOpenIntent, + shouldShowHistorySessionOpenIntent, +} from '@/flow_chat/services/sessionOpenIntent'; import { resolveSessionRelationship } from '@/flow_chat/utils/sessionMetadata'; import { compareSessionsForNavStable, @@ -62,6 +66,18 @@ const resolveSessionModeType = (session: Session): SessionMode => { const getTitle = (session: Session): string => resolveSessionTitle(session, (key, options) => i18nService.t(key, options)); +const waitForHistoryOpenIntentPaint = (): Promise => + new Promise(resolve => { + if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') { + globalThis.setTimeout(resolve, 0); + return; + } + + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => resolve()); + }); + }); + const getChildSessionBadge = (kind: Session['sessionKind']): string => { const normalizedKind = kind === 'review' || kind === 'deep_review' || kind === 'subagent' @@ -379,12 +395,48 @@ const SessionsSection: React.FC = ({ const scheduledJobsSession = scheduledJobsSessionId ? flowChatState.sessions.get(scheduledJobsSessionId) ?? null : null; + const lastHistoryOpenIntentRef = useRef<{ sessionId: string; atMs: number } | null>(null); + + const dispatchHistoryOpenIntentForSession = useCallback( + (session: Session): boolean => { + const sessionId = session.sessionId; + if ( + sessionId === activeSessionId || + !shouldShowHistorySessionOpenIntent(session) + ) { + return false; + } + + const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); + const lastIntent = lastHistoryOpenIntentRef.current; + if ( + lastIntent && + lastIntent.sessionId === sessionId && + now - lastIntent.atMs < 250 + ) { + return true; + } + + lastHistoryOpenIntentRef.current = { sessionId, atMs: now }; + dispatchHistorySessionOpenIntent(sessionId, getTitle(session)); + return true; + }, + [activeSessionId], + ); const handleSwitch = useCallback( async (sessionId: string) => { if (editingSessionId) return; try { const session = flowChatStore.getState().sessions.get(sessionId); + const historyOpenIntentDispatched = session + ? dispatchHistoryOpenIntentForSession(session) + : false; + if (session) { + if (historyOpenIntentDispatched) { + await waitForHistoryOpenIntentPaint(); + } + } const relationship = resolveSessionRelationship(session); const parentSessionId = relationship.parentSessionId; const mustActivateWorkspace = @@ -429,6 +481,7 @@ const SessionsSection: React.FC = ({ }, [ activeSessionId, + dispatchHistoryOpenIntentForSession, editingSessionId, setActiveWorkspace, workspaceId, @@ -436,6 +489,22 @@ const SessionsSection: React.FC = ({ ] ); + const handleSessionOpenPointerDown = useCallback( + (event: React.PointerEvent, session: Session) => { + if (editingSessionId || session.sessionId === activeSessionId) { + return; + } + + const target = event.target as HTMLElement | null; + if (target?.closest('.bitfun-nav-panel__inline-item-actions, .bitfun-nav-panel__inline-item-edit')) { + return; + } + + dispatchHistoryOpenIntentForSession(session); + }, + [activeSessionId, dispatchHistoryOpenIntentForSession, editingSessionId], + ); + const resolveSessionTitle = useCallback( (session: Session): string => { const rawTitle = getTitle(session); @@ -711,6 +780,7 @@ const SessionsSection: React.FC = ({ data-testid="session-nav-item" data-session-id={session.sessionId} data-session-title={sessionTitle} + onPointerDown={event => handleSessionOpenPointerDown(event, session)} onClick={() => handleSwitch(session.sessionId)} > {showSessionModeIcon ? ( diff --git a/src/web-ui/src/app/startup/deferredStartupGate.ts b/src/web-ui/src/app/startup/deferredStartupGate.ts new file mode 100644 index 000000000..d49fdf136 --- /dev/null +++ b/src/web-ui/src/app/startup/deferredStartupGate.ts @@ -0,0 +1,8 @@ +export interface DeferredStartupGateState { + interactiveShellReady: boolean; + startupOverlayVisible: boolean; +} + +export function shouldScheduleDeferredStartupSystems(state: DeferredStartupGateState): boolean { + return state.interactiveShellReady && !state.startupOverlayVisible; +} diff --git a/src/web-ui/src/app/startup/deferredStartupSystems.test.ts b/src/web-ui/src/app/startup/deferredStartupSystems.test.ts index 5e21968fc..174680116 100644 --- a/src/web-ui/src/app/startup/deferredStartupSystems.test.ts +++ b/src/web-ui/src/app/startup/deferredStartupSystems.test.ts @@ -1,7 +1,29 @@ import { describe, expect, it, vi } from 'vitest'; +import { shouldScheduleDeferredStartupSystems } from './deferredStartupGate'; import { scheduleDeferredStartupSystems } from './deferredStartupSystems'; +describe('shouldScheduleDeferredStartupSystems', () => { + it('waits for both interactive readiness and startup overlay handoff', () => { + expect(shouldScheduleDeferredStartupSystems({ + interactiveShellReady: false, + startupOverlayVisible: true, + })).toBe(false); + expect(shouldScheduleDeferredStartupSystems({ + interactiveShellReady: true, + startupOverlayVisible: true, + })).toBe(false); + expect(shouldScheduleDeferredStartupSystems({ + interactiveShellReady: false, + startupOverlayVisible: false, + })).toBe(false); + expect(shouldScheduleDeferredStartupSystems({ + interactiveShellReady: true, + startupOverlayVisible: false, + })).toBe(true); + }); +}); + describe('scheduleDeferredStartupSystems', () => { it('schedules MCP, ACP, and IDE startup as deferred idle work', async () => { let scheduledTask: ((signal: AbortSignal) => Promise) | null = null; diff --git a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts index a767cee30..649aa1d19 100644 --- a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts +++ b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts @@ -2,6 +2,9 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; +import { shouldScheduleDeferredStartupSystems } from './deferredStartupGate'; +import { STARTUP_OVERLAY_HIDDEN_EVENT } from './startupSignals'; + function readSource(relativePath: string): string { return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8'); } @@ -81,14 +84,46 @@ describe('startup performance contract', () => { ); }); - it('starts non-critical work after the shell is interactive', () => { + it('keeps Windows startup show wait state-driven instead of fixed-delay only', () => { + const desktopThemeSource = readSource('../../../../apps/desktop/src/theme.rs'); + + expect(desktopThemeSource).toContain('WINDOWS_STARTUP_MAXIMIZE_SHOW_WAIT_MAX'); + expect(desktopThemeSource).toContain('WINDOWS_STARTUP_MAXIMIZE_SHOW_WAIT_MIN'); + expect(desktopThemeSource).toContain('WINDOWS_STARTUP_MAXIMIZE_SHOW_WAIT_POLL'); + expect(desktopThemeSource).toContain('windows_maximize_show_wait_action'); + expect(desktopThemeSource).toContain('window.is_maximized()'); + expect(desktopThemeSource).toContain('windows_show_after_maximize_wait'); + expect(desktopThemeSource).not.toContain( + 'std::thread::sleep(std::time::Duration::from_millis(150))' + ); + }); + + it('starts non-critical work after the startup overlay handoff', () => { const source = readSource('../../main.tsx'); - expect(source).toContain("signalName: 'bitfun:interactive-shell-ready'"); + expect(STARTUP_OVERLAY_HIDDEN_EVENT).toBe('bitfun:startup-overlay-hidden'); + expect(source).toContain('STARTUP_OVERLAY_HIDDEN_EVENT'); + expect(source).not.toContain("signalName: 'bitfun:interactive-shell-ready'"); expect(source).not.toContain("signalName: 'bitfun:main-window-shown'"); expect(source).toContain('fallbackTimeoutMs: 10000'); }); + it('starts deferred app systems only after the startup overlay has handed off', () => { + const source = readSource('../App.tsx'); + + expect(shouldScheduleDeferredStartupSystems({ + interactiveShellReady: true, + startupOverlayVisible: true, + })).toBe(false); + expect(shouldScheduleDeferredStartupSystems({ + interactiveShellReady: true, + startupOverlayVisible: false, + })).toBe(true); + expect(source).toContain('shouldScheduleDeferredStartupSystems({ interactiveShellReady, startupOverlayVisible })'); + expect(source).toContain('window.dispatchEvent(new CustomEvent(STARTUP_OVERLAY_HIDDEN_EVENT))'); + expect(source).toContain('}, [interactiveShellReady, startupOverlayVisible]);'); + }); + it('does not initialize AI from the root app component', () => { const source = readSource('../App.tsx'); @@ -97,6 +132,7 @@ describe('startup performance contract', () => { expect(source).not.toMatch(/useCurrentModelConfig/); expect(source).not.toMatch(/from\s+['"]@\/infrastructure\/config\/services\/AIExperienceConfigService['"]/); expect(source).toContain('bitfun:interactive-shell-ready'); + expect(source).toContain('STARTUP_OVERLAY_HIDDEN_EVENT'); }); it('keeps the heavy app layout out of the root startup module', () => { @@ -108,6 +144,21 @@ describe('startup performance contract', () => { expect(source).toContain('!appLayoutReady'); }); + it('releases interactive shell readiness without waiting for an extra AppLayout state commit', () => { + const source = readSource('../App.tsx'); + + expect(source).toContain('workspaceLoadingRef.current'); + expect(source).toContain('appLayoutReadyRef.current'); + expect(source).toContain('interactiveShellReadyFrameRef.current'); + expect(source).toContain('markInteractiveShellReadyIfReady'); + expect(source).toContain('releaseInteractiveShellReadyIfReady'); + expect(source).toContain('useLayoutEffect'); + expect(source).toContain('requestAnimationFrame'); + expect(source).toContain('interactive_shell_ready_after_paint_scheduled'); + expect(source).toContain("markInteractiveShellReadyIfReady('app-layout-ready')"); + expect(source).toContain("markInteractiveShellReadyIfReady('workspace-or-layout-state')"); + }); + it('loads Monaco styling and loader config only through editor initialization', () => { const source = readSource('../../tools/editor/services/MonacoInitManager.ts'); @@ -144,14 +195,25 @@ describe('startup performance contract', () => { expect(source).toMatch(/import\s+type\s+\*\s+as\s+monaco\s+from\s+['"]monaco-editor['"]/); }); - it('prewarms editor runtime only after the shell is interactive', () => { + it('prewarms editor runtime only after the shell is interactive and visible', () => { const source = readSource('../App.tsx'); expect(source).toContain('interactiveShellReady'); + expect(source).toContain('startupOverlayVisible'); expect(source).toContain("import('@/tools/editor/services/MonacoStartupWarmup')"); expect(source).toContain('scheduleMonacoStartupWarmup()'); }); + it('does not let startup visibility retries reopen a user-hidden main window', () => { + const source = readSource('../App.tsx'); + + expect(source).toContain('userCloseRequestedRef'); + expect(source).toContain("listen('bitfun_main_window_close_requested'"); + expect(source).toContain('user-close-requested'); + expect(source).toContain('startup-complete'); + expect(source).toContain('startup-watchdog'); + }); + it('does not remount the historical message list when full hydration prepends older turns', () => { const source = readSource('../../flow_chat/components/modern/VirtualMessageList.tsx'); @@ -223,6 +285,15 @@ describe('startup performance contract', () => { } }); + it('keeps startup session metadata paging on the narrow SessionAPI entrypoint', () => { + const source = readSource('../../flow_chat/store/FlowChatStore.ts'); + + expect(source).toContain("import('@/infrastructure/api/service-api/SessionAPI')"); + expect(source).not.toMatch( + /const\s+\{\s*sessionAPI\s*\}\s*=\s*await\s+import\(['"]@\/infrastructure\/api['"]\)/ + ); + }); + it('keeps Agent companion implementation modules out of the root startup bundle', () => { const source = readSource('../App.tsx'); diff --git a/src/web-ui/src/app/startup/startupSignals.ts b/src/web-ui/src/app/startup/startupSignals.ts new file mode 100644 index 000000000..9157b3c9e --- /dev/null +++ b/src/web-ui/src/app/startup/startupSignals.ts @@ -0,0 +1 @@ +export const STARTUP_OVERLAY_HIDDEN_EVENT = 'bitfun:startup-overlay-hidden'; diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss index 70c9d5132..d9fba1b37 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss +++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss @@ -155,6 +155,14 @@ animation: flow-tool-completed-exit 1000ms ease-in-out both; } +.flowchat-flow-item--tool-settled { + opacity: 0; + transform: translateY(-4px); + max-height: 0; + margin-bottom: 0; + pointer-events: none; +} + @keyframes flow-tool-active-enter { from { opacity: 0; diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx index e2f7d4d3f..ae4f58096 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx @@ -20,7 +20,12 @@ import { FlowChatStore } from '../../store/FlowChatStore'; import { taskCollapseStateManager } from '../../store/TaskCollapseStateManager'; import { ExportImageButton } from './ExportImageButton'; import { ForkSessionButton } from './ForkSessionButton'; -import { buildModelRoundItemGroups, COMPLETED_TOOL_TRANSIENT_MS, type ModelRoundItemGroup } from './modelRoundItemGrouping'; +import { + buildModelRoundItemGroups, + COMPLETED_TOOL_TRANSIENT_MS, + isCompletedToolInTransientWindow, + type ModelRoundItemGroup, +} from './modelRoundItemGrouping'; import { MODEL_ROUND_GROUP_RENDER_CHUNK_DELAY_MS, getInitialModelRoundGroupRenderCount, @@ -163,6 +168,7 @@ interface TaskWithSubagentWrapperProps { directSubagentSessionId?: string; turnId: string; roundId?: string; + completedToolExitNowMs: number; } const TaskWithSubagentWrapper: React.FC = React.memo(({ @@ -172,6 +178,7 @@ const TaskWithSubagentWrapper: React.FC = React.me directSubagentSessionId, turnId, roundId, + completedToolExitNowMs, }) => { const isCollapsed = useTaskCollapsed(parentTaskToolId); const isTaskRunning = @@ -193,6 +200,7 @@ const TaskWithSubagentWrapper: React.FC = React.me turnId={turnId} roundId={roundId} isLastItem={false} + completedToolExitNowMs={completedToolExitNowMs} /> ( turnId={turnId} roundId={round.id} isLastItem={isLast && itemIdx === group.items.length - 1} + completedToolExitNowMs={transientNowMs} /> )); @@ -499,6 +508,7 @@ export const ModelRoundItem = React.memo( directSubagentSessionId={projectedSubagent.subagentSessionId} turnId={turnId} roundId={round.id} + completedToolExitNowMs={transientNowMs} /> ); } @@ -509,6 +519,7 @@ export const ModelRoundItem = React.memo( turnId={turnId} roundId={round.id} isLastItem={isLast} + completedToolExitNowMs={transientNowMs} /> ); } @@ -570,10 +581,17 @@ interface FlowItemRendererProps { turnId: string; roundId?: string; isLastItem?: boolean; + completedToolExitNowMs: number; } // Do not memoize: streaming content updates frequently. -const FlowItemRenderer: React.FC = ({ item, turnId, roundId, isLastItem }) => { +const FlowItemRenderer: React.FC = ({ + item, + turnId, + roundId, + isLastItem, + completedToolExitNowMs, +}) => { const { onToolConfirm, onToolReject, @@ -604,10 +622,16 @@ const FlowItemRenderer: React.FC = ({ item, turnId, round const toolItem = item as FlowToolItem; const isCompletedTool = toolItem.status === 'completed'; const isCollapsible = isCollapsibleTool(toolItem.toolName); + const shouldAnimateCompletedExit = + isCollapsible && + isCompletedTool && + isCompletedToolInTransientWindow(toolItem, completedToolExitNowMs); + const isSettledCompletedTool = isCollapsible && isCompletedTool && !shouldAnimateCompletedExit; const toolClassName = [ 'flowchat-flow-item', isCollapsible && isCompletedTool ? 'flowchat-flow-item--tool-transition' : null, - isCollapsible && isCompletedTool ? 'flowchat-flow-item--tool-completed' : null, + shouldAnimateCompletedExit ? 'flowchat-flow-item--tool-completed' : null, + isSettledCompletedTool ? 'flowchat-flow-item--tool-settled' : null, isCollapsible && !isCompletedTool ? 'flowchat-flow-item--tool-active' : null, ].filter(Boolean).join(' '); 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 bf156d73a..3ce47a925 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 @@ -6,6 +6,7 @@ import { createRoot, type Root } from 'react-dom/client'; import { ModernFlowChatContainer } from './ModernFlowChatContainer'; import type { Session } from '../../types/flow-chat'; import { flowChatStore } from '../../store/FlowChatStore'; +import { HISTORY_SESSION_OPEN_INTENT_EVENT } from '../../services/sessionOpenIntent'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -116,6 +117,7 @@ vi.mock('./VirtualMessageList', () => ({ })); vi.mock('@/shared/utils/startupTrace', () => ({ + isRemoteTraceContext: () => false, startupTrace: startupTraceMock, })); @@ -311,8 +313,74 @@ describe('ModernFlowChatContainer historical empty state', () => { expect(container.querySelector('[data-testid="welcome-panel"]')).toBeNull(); }); - it('keeps a history loading overlay until restored latest text is visible', async () => { + it('covers the current message list after a historical session open intent', async () => { stateMocks.activeSession = createSession({ + sessionId: 'current-session', + isHistorical: false, + historyState: 'ready', + dialogTurns: [createTurn('turn-1', 'Current visible prompt')], + } as Partial); + stateMocks.virtualItems = [ + { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Current visible prompt' } }, + ]; + + await act(async () => { + root.render(); + }); + + expect(container.querySelector('[data-testid="virtual-list"]')).not.toBeNull(); + expect(container.querySelector('.modern-flowchat-container__history-overlay')).toBeNull(); + + act(() => { + window.dispatchEvent(new CustomEvent(HISTORY_SESSION_OPEN_INTENT_EVENT, { + detail: { sessionId: 'history-session', sessionTitle: 'Saved investigation' }, + })); + }); + + expect(container.textContent).not.toContain('Loading saved session'); + expect(container.querySelector('[data-testid="virtual-list"]')).not.toBeNull(); + expect(container.querySelector('.modern-flowchat-container__history-overlay')).toBeNull(); + expect(container.querySelector('.modern-flowchat-container__history-open-intent-shield')).not.toBeNull(); + expect(container.querySelector('.modern-flowchat-container__history-open-intent-spinner')).not.toBeNull(); + expect(container.textContent).toContain('Hidden action'); + expect(container.textContent).not.toContain('Saved investigation'); + expect(container.querySelector('.modern-flowchat-container__messages')?.getAttribute('data-show-history-open-intent-overlay')) + .toBe('true'); + (container.querySelector('[data-testid="virtual-list-action"]') as HTMLButtonElement | null)?.click(); + expect(virtualListActionClickMock).not.toHaveBeenCalled(); + + stateMocks.activeSession = createSession({ + sessionId: 'history-session', + historyState: 'metadata-only', + } as Partial); + stateMocks.virtualItems = []; + + await act(async () => { + root.render(); + }); + + expect(container.textContent).toContain('Loading saved session'); + expect(container.querySelector('.modern-flowchat-container__history-overlay')).not.toBeNull(); + }); + + it('removes the loading layer when a hydrating session receives its initial tail turns', async () => { + stateMocks.activeSession = createSession({ + sessionId: 'history-session', + historyState: 'hydrating', + dialogTurns: [], + } as Partial); + stateMocks.virtualItems = []; + + await act(async () => { + root.render(); + }); + + const initialOverlay = container.querySelector('.modern-flowchat-container__history-overlay'); + expect(initialOverlay).not.toBeNull(); + expect(container.querySelector('[data-testid="virtual-list"]')).toBeNull(); + + stateMocks.activeSession = createSession({ + sessionId: 'history-session', isHistorical: false, historyState: 'ready', contextRestoreState: 'pending', @@ -332,18 +400,106 @@ describe('ModernFlowChatContainer historical empty state', () => { }); expect(container.querySelector('[data-testid="virtual-list"]')).not.toBeNull(); - expect(container.textContent).toContain('Loading saved session'); + expect(container.querySelector('.modern-flowchat-container__history-overlay')).not.toBe(initialOverlay); + expect(container.querySelector('.modern-flowchat-container__history-overlay')).toBeNull(); + expect(container.textContent).not.toContain('Loading saved session'); + expect(container.querySelector('.modern-flowchat-container__messages')?.getAttribute('data-show-history-transition-overlay')) + .toBe('true'); + }); + + it('keeps restored content visible while restored latest text is not ready', async () => { + stateMocks.activeSession = createSession({ + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'pending', + dialogTurns: [ + createTurn('turn-1', 'Older restored prompt'), + createTurn('turn-2', 'Latest restored prompt'), + ], + } as Partial); + stateMocks.virtualItems = [ + { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Older restored prompt' } }, + { type: 'user-message', turnId: 'turn-2', data: { id: 'user-turn-2', content: 'Latest restored prompt' } }, + ]; + virtualListMock.isTurnTextRenderedInViewport.mockReturnValue(false); + + await act(async () => { + root.render(); + }); + + expect(container.querySelector('[data-testid="virtual-list"]')).not.toBeNull(); + expect(container.textContent).not.toContain('Loading saved session'); + expect(container.querySelector('.modern-flowchat-container__history-overlay')).toBeNull(); + expect(container.querySelector('.modern-flowchat-container__messages')?.getAttribute('data-show-history-transition-overlay')) + .toBe('true'); flushAnimationFrame(); - expect(container.textContent).toContain('Loading saved session'); + expect(container.querySelector('[data-testid="virtual-list"]')).not.toBeNull(); + expect(container.textContent).not.toContain('Loading saved session'); + expect(container.querySelector('.modern-flowchat-container__history-overlay')).toBeNull(); virtualListMock.isTurnTextRenderedInViewport.mockReturnValue(true); flushAnimationFrame(); expect(container.textContent).not.toContain('Loading saved session'); + expect(container.querySelector('.modern-flowchat-container__history-overlay')).toBeNull(); }); - it('blocks pointer activation behind the history loading overlay', async () => { + it('does not show the initial history progress again when full hydration adds older turns', async () => { + stateMocks.activeSession = createSession({ + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'pending', + dialogTurns: [ + createTurn('turn-1', 'Older restored prompt'), + createTurn('turn-2', 'Latest restored prompt'), + ], + } as Partial); + stateMocks.virtualItems = [ + { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Older restored prompt' } }, + { type: 'user-message', turnId: 'turn-2', data: { id: 'user-turn-2', content: 'Latest restored prompt' } }, + ]; + virtualListMock.isTurnTextRenderedInViewport.mockReturnValue(false); + + await act(async () => { + root.render(); + }); + + expect(container.querySelector('.modern-flowchat-container__history-overlay')).toBeNull(); + + virtualListMock.isTurnTextRenderedInViewport.mockReturnValue(true); + flushAnimationFrame(); + + expect(container.querySelector('.modern-flowchat-container__history-overlay')).toBeNull(); + + stateMocks.activeSession = createSession({ + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'pending', + dialogTurns: [ + createTurn('turn-0', 'Restored older prompt'), + createTurn('turn-1', 'Older restored prompt'), + createTurn('turn-2', 'Latest restored prompt'), + ], + } as Partial); + stateMocks.virtualItems = [ + { type: 'user-message', turnId: 'turn-0', data: { id: 'user-turn-0', content: 'Restored older prompt' } }, + { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Older restored prompt' } }, + { type: 'user-message', turnId: 'turn-2', data: { id: 'user-turn-2', content: 'Latest restored prompt' } }, + ]; + + await act(async () => { + root.render(); + }); + + expect(container.querySelector('.modern-flowchat-container__history-overlay')).toBeNull(); + }); + + it('blocks pointer activation until restored latest text is visible', async () => { + const releaseSpy = vi + .spyOn(flowChatStore, 'releaseSessionHistoryCompletionAfterInitialPaint') + .mockReturnValue(true); + stateMocks.activeSession = createSession({ isHistorical: false, historyState: 'ready', @@ -365,7 +521,8 @@ describe('ModernFlowChatContainer historical empty state', () => { const hiddenAction = container.querySelector('[data-testid="virtual-list-action"]') as HTMLButtonElement; expect(hiddenAction).not.toBeNull(); - expect(container.textContent).toContain('Loading saved session'); + expect(container.textContent).not.toContain('Loading saved session'); + expect(container.querySelector('.modern-flowchat-container__history-overlay')).toBeNull(); act(() => { hiddenAction.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); @@ -375,15 +532,29 @@ describe('ModernFlowChatContainer historical empty state', () => { virtualListMock.isTurnTextRenderedInViewport.mockReturnValue(true); flushAnimationFrame(); + flushAnimationFrame(); + flushAnimationFrame(); + + expect(container.querySelector('.modern-flowchat-container__history-overlay')).toBeNull(); + expect(releaseSpy).toHaveBeenCalledWith('session-1'); + expect(startupTraceMock.markPhase).toHaveBeenCalledWith( + 'historical_session_initial_content_painted', + expect.objectContaining({ + sessionId: 'session-1', + latestTurnId: 'turn-2', + released: true, + }), + ); act(() => { hiddenAction.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); }); expect(virtualListActionClickMock).toHaveBeenCalledTimes(1); + releaseSpy.mockRestore(); }); - it('releases pending full history hydration when latest text visibility signal is missed', async () => { + it('keeps full history projection deferred when latest text visibility signal is missed', async () => { const releaseSpy = vi .spyOn(flowChatStore, 'releaseSessionHistoryCompletionAfterInitialPaint') .mockReturnValue(true); @@ -412,21 +583,21 @@ describe('ModernFlowChatContainer historical empty state', () => { } expect(container.textContent).not.toContain('Loading saved session'); - expect(releaseSpy).toHaveBeenCalledWith('session-1'); + expect(releaseSpy).not.toHaveBeenCalled(); expect(startupTraceMock.markPhase).toHaveBeenCalledWith( 'historical_session_initial_content_paint_signal_missed', - expect.objectContaining({ released: true }), + expect.objectContaining({ attempts: 120 }), ); releaseSpy.mockRestore(); }); - it('releases pending full history hydration when searching a partially loaded session', async () => { + it('requests full history projection when searching a partially loaded session', async () => { const pendingSpy = vi .spyOn(flowChatStore, 'hasPendingSessionHistoryCompletion') .mockReturnValue(true); - const releaseSpy = vi - .spyOn(flowChatStore, 'releaseSessionHistoryCompletionAfterInitialPaint') + const projectionSpy = vi + .spyOn(flowChatStore, 'requestSessionFullHistoryProjection') .mockReturnValue(true); searchStateMock.searchQuery = 'older prompt'; @@ -447,7 +618,7 @@ describe('ModernFlowChatContainer historical empty state', () => { root.render(); }); - expect(releaseSpy).toHaveBeenCalledWith('session-1'); + expect(projectionSpy).toHaveBeenCalledWith('session-1', 'search'); expect(startupTraceMock.markPhase).toHaveBeenCalledWith( 'historical_session_full_hydrate_released_for_search', expect.objectContaining({ @@ -456,7 +627,7 @@ describe('ModernFlowChatContainer historical empty state', () => { }), ); - releaseSpy.mockRestore(); + projectionSpy.mockRestore(); pendingSpy.mockRestore(); }); @@ -602,17 +773,19 @@ describe('ModernFlowChatContainer historical empty state', () => { }); expect(container.querySelector('[data-testid="virtual-list"]')).not.toBeNull(); - expect(rafCallbacks.length).toBeGreaterThan(0); - - flushAnimationFrame(); expect(virtualListMock.pinTurnToTop).toHaveBeenCalledTimes(1); expect(virtualListMock.pinTurnToTop).toHaveBeenLastCalledWith('turn-2', { behavior: 'auto', pinMode: 'sticky-latest', }); + expect(rafCallbacks.length).toBeGreaterThan(0); flushAnimationFrame(); expect(virtualListMock.pinTurnToTop).toHaveBeenCalledTimes(2); + expect(virtualListMock.pinTurnToTop).toHaveBeenLastCalledWith('turn-2', { + behavior: 'auto', + pinMode: 'sticky-latest', + }); expect(startupTraceMock.markPhase).toHaveBeenCalledWith( 'historical_session_latest_anchor_attempt', expect.objectContaining({ accepted: false, attempt: 1, mode: 'sticky-latest' }), @@ -690,7 +863,7 @@ describe('ModernFlowChatContainer historical empty state', () => { ); }); - it('re-anchors completed restored history after full hydration expands the same latest turn', async () => { + it('does not re-anchor local restored history after full hydration expands the same latest turn', async () => { stateMocks.activeSession = createSession({ isHistorical: false, historyState: 'ready', @@ -730,12 +903,16 @@ describe('ModernFlowChatContainer historical empty state', () => { flushAnimationFrame(); - expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(2); + expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(1); expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenLastCalledWith('turn-80'); expect(virtualListMock.pinTurnToTop).not.toHaveBeenCalled(); + expect(startupTraceMock.markPhase).toHaveBeenCalledWith( + 'historical_session_latest_anchor_skipped', + expect.objectContaining({ reason: 'local_full_history_projection' }), + ); }); - it('re-anchors full hydration even when the latest restored turn is already visible', async () => { + it('does not re-anchor local full hydration when the latest restored turn is already visible', async () => { stateMocks.activeSession = createSession({ isHistorical: false, historyState: 'ready', @@ -780,17 +957,17 @@ describe('ModernFlowChatContainer historical empty state', () => { root.render(); }); - expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(2); + expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(1); expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenLastCalledWith('turn-80'); flushAnimationFrame(); - expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(2); + expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(1); expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenLastCalledWith('turn-80'); expect(virtualListMock.pinTurnToTop).not.toHaveBeenCalled(); }); - it('re-anchors full hydration when visible turn info is stale after prepending older turns', async () => { + it('does not re-anchor local full hydration when visible turn info is stale after prepending older turns', async () => { stateMocks.activeSession = createSession({ isHistorical: false, historyState: 'ready', @@ -839,7 +1016,7 @@ describe('ModernFlowChatContainer historical empty state', () => { flushAnimationFrame(); - expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(2); + expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(1); expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenLastCalledWith('turn-80'); expect(virtualListMock.pinTurnToTop).not.toHaveBeenCalled(); }); diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss index 2a55540ae..cd9a972c2 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss @@ -36,15 +36,6 @@ background: transparent; } - &__history-overlay { - position: absolute; - inset: 0; - z-index: 10; - background: var(--color-bg-scene); - pointer-events: none; - } - - &__input { flex-shrink: 0; padding: var(--flowchat-card-expanded-pad-x, 0.625rem); @@ -128,6 +119,36 @@ background: var(--color-surface-active, rgba(255, 255, 255, 0.1)); } +.modern-flowchat-container__history-overlay { + position: absolute; + inset: 0; + z-index: 10; + pointer-events: none; + background: var(--color-bg-scene); +} + +.modern-flowchat-container__history-open-intent-shield { + position: absolute; + inset: 0; + z-index: 9; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 76px; + pointer-events: none; +} + +.modern-flowchat-container__history-open-intent-spinner { + width: 18px; + height: 18px; + border: 2px solid var(--color-border, rgba(255, 255, 255, 0.16)); + border-top-color: var(--color-accent, #5b8def); + border-radius: 50%; + background: var(--color-bg-scene); + box-shadow: 0 0 0 6px var(--color-bg-scene); + animation: history-placeholder-spin 0.9s linear infinite; +} + @keyframes history-placeholder-spin { to { transform: rotate(360deg); 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 8d1582cd2..a12e77372 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -35,8 +35,12 @@ import { findDialogTurn, shouldUseStickyLatestPin, } from '../../utils/flowChatTurnScrollPolicy'; -import { startupTrace } from '@/shared/utils/startupTrace'; +import { isRemoteTraceContext, startupTrace } from '@/shared/utils/startupTrace'; import { scheduleAfterStartupPaint } from '@/shared/utils/startupTaskScheduling'; +import { + HISTORY_SESSION_OPEN_INTENT_EVENT, + type HistorySessionOpenIntentDetail, +} from '../../services/sessionOpenIntent'; import './ModernFlowChatContainer.scss'; interface ModernFlowChatContainerProps { @@ -176,6 +180,7 @@ export const ModernFlowChatContainer: React.FC = ( const activeSession = useActiveSession(); const visibleTurnInfo = useVisibleTurnInfo(); const [pendingHeaderTurnId, setPendingHeaderTurnId] = useState(null); + const [pendingHistoryOpenSession, setPendingHistoryOpenSession] = useState(null); const [searchOpenRequest, setSearchOpenRequest] = useState(0); // Track whether a slash-command or @-mention popup is open in ChatInput. // When a popup is active, the global Escape shortcut is disabled so the @@ -206,6 +211,9 @@ export const ModernFlowChatContainer: React.FC = ( historyState === 'failed' || hasRestoredTurnsPendingVirtualItems ); + const showHistoryOpenIntentOverlay = + pendingHistoryOpenSession !== null && + activeSession?.sessionId !== pendingHistoryOpenSession.sessionId; const { exploreGroupStates, onExploreGroupToggle: handleExploreGroupToggle, @@ -255,6 +263,53 @@ export const ModernFlowChatContainer: React.FC = ( onExpandExploreGroup: handleExpandGroup, }); + useEffect(() => { + const handleHistorySessionOpenIntent = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!detail?.sessionId) { + return; + } + + setPendingHistoryOpenSession({ + sessionId: detail.sessionId, + sessionTitle: detail.sessionTitle, + }); + startupTrace.markPhase('historical_session_open_intent_overlay', { + sessionId: detail.sessionId, + }); + }; + + window.addEventListener(HISTORY_SESSION_OPEN_INTENT_EVENT, handleHistorySessionOpenIntent); + return () => { + window.removeEventListener(HISTORY_SESSION_OPEN_INTENT_EVENT, handleHistorySessionOpenIntent); + }; + }, []); + + useEffect(() => { + if (!pendingHistoryOpenSession) { + return; + } + + const timeoutId = window.setTimeout(() => { + setPendingHistoryOpenSession(current => + current?.sessionId === pendingHistoryOpenSession.sessionId ? null : current + ); + }, 4000); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [pendingHistoryOpenSession]); + + useEffect(() => { + if ( + pendingHistoryOpenSession && + activeSession?.sessionId === pendingHistoryOpenSession.sessionId + ) { + setPendingHistoryOpenSession(null); + } + }, [activeSession?.sessionId, pendingHistoryOpenSession]); + const contextValue: FlowChatContextValue = useMemo(() => ({ onFileViewRequest: handleFileViewRequest, onTabOpen, @@ -352,6 +407,9 @@ export const ModernFlowChatContainer: React.FC = ( const hasPendingHistoryCompletion = activeSession?.sessionId ? flowChatStore.hasPendingSessionHistoryCompletion(activeSession.sessionId) : false; + const hasDeferredHistoryProjection = activeSession?.sessionId + ? flowChatStore.hasDeferredSessionHistoryProjection(activeSession.sessionId) + : false; const historyInitialContentKey = activeSession?.sessionId && latestTurnId && @@ -361,19 +419,26 @@ export const ModernFlowChatContainer: React.FC = ( activeSession.contextRestoreState === 'pending' || hasPendingHistoryCompletion ) - ? `${activeSession.sessionId}:${latestTurnId}:${turnSummaries.length}` + ? `${activeSession.sessionId}:${latestTurnId}` : null; - const showHistoryInitialContentOverlay = + const shouldBlockHistoryInitialContentInteraction = historyInitialContentKey !== null && historyInitialContentReadyKey !== historyInitialContentKey; + const shouldBlockHistoryTransitionInteraction = + shouldBlockHistoryInitialContentInteraction || + showHistoryOpenIntentOverlay; + const showFailedHistoryPlaceholder = + showHistoryPlaceholder && historyState === 'failed'; + const showHistoryLoadingLayer = + !showFailedHistoryPlaceholder && showHistoryPlaceholder; const blockHistoryOverlayActivation = useCallback((event: React.SyntheticEvent) => { - if (!showHistoryInitialContentOverlay) { + if (!showHistoryLoadingLayer && !shouldBlockHistoryTransitionInteraction) { return; } event.preventDefault(); event.stopPropagation(); - }, [showHistoryInitialContentOverlay]); + }, [shouldBlockHistoryTransitionInteraction, showHistoryLoadingLayer]); const latestTurn = useMemo( () => findDialogTurn(activeSession?.dialogTurns, latestTurnId), [activeSession?.dialogTurns, latestTurnId], @@ -479,9 +544,26 @@ export const ModernFlowChatContainer: React.FC = ( const hasPreviouslyAnchoredSameLatestTurn = autoPinnedTurnKeyRef.current?.startsWith(previousAnchoredLatestTurnKeyPrefix) === true; const latestTurnRenderedInViewport = virtualListRef.current?.isTurnRenderedInViewport(latestTurnId) === true; - const shouldForceLatestAnchorAfterTurnCountChange = + const sameLatestTurnCountChanged = hasPreviouslyAnchoredSameLatestTurn && autoPinnedTurnKeyRef.current !== resolvedLatestTurnKey; + const shouldSkipLocalFullHistoryReanchor = + sameLatestTurnCountChanged && + !isRemoteTraceContext(activeSession.remoteConnectionId, activeSession.remoteSshHost); + const shouldForceLatestAnchorAfterTurnCountChange = + sameLatestTurnCountChanged && + !shouldSkipLocalFullHistoryReanchor; + if (shouldSkipLocalFullHistoryReanchor) { + autoPinnedTurnKeyRef.current = resolvedLatestTurnKey; + startupTrace.markPhase('historical_session_latest_anchor_skipped', { + sessionId, + latestTurnId, + reason: 'local_full_history_projection', + mode: pinMode ?? 'bottom', + turnCount: turnSummaries.length, + }); + return; + } if ( !shouldForceLatestAnchorAfterTurnCountChange && hasPreviouslyAnchoredSameLatestTurn && @@ -490,6 +572,8 @@ export const ModernFlowChatContainer: React.FC = ( ) { autoPinnedTurnKeyRef.current = resolvedLatestTurnKey; startupTrace.markPhase('historical_session_latest_anchor_skipped', { + sessionId, + latestTurnId, reason: 'latest_turn_already_visible', mode: pinMode ?? 'bottom', }); @@ -501,6 +585,8 @@ export const ModernFlowChatContainer: React.FC = ( !latestTurnRenderedInViewport ) { startupTrace.markPhase('historical_session_latest_anchor_stale_visible_info', { + sessionId, + latestTurnId, mode: pinMode ?? 'bottom', }); } @@ -530,6 +616,8 @@ export const ModernFlowChatContainer: React.FC = ( } startupTrace.markPhase('historical_session_latest_anchor_attempt', { + sessionId, + latestTurnId: resolvedLatestTurnId, accepted, attempt: attempts, mode: pinMode ?? 'bottom', @@ -543,6 +631,8 @@ export const ModernFlowChatContainer: React.FC = ( if (attempts >= LATEST_TURN_AUTO_PIN_MAX_ATTEMPTS) { setPendingHeaderTurnId(null); startupTrace.markPhase('historical_session_latest_anchor_failed', { + sessionId, + latestTurnId: resolvedLatestTurnId, attempts, mode: pinMode ?? 'bottom', }); @@ -552,7 +642,13 @@ export const ModernFlowChatContainer: React.FC = ( frameId = requestAnimationFrame(attemptLatestViewportAnchor); }; - if (shouldForceLatestAnchorAfterTurnCountChange) { + const shouldAttemptLatestAnchorImmediately = + shouldForceLatestAnchorAfterTurnCountChange || + activeSession?.isHistorical === true || + activeSession?.contextRestoreState === 'pending' || + hasPendingHistoryCompletion; + + if (shouldAttemptLatestAnchorImmediately) { attemptLatestViewportAnchor(); } else { frameId = requestAnimationFrame(attemptLatestViewportAnchor); @@ -566,6 +662,11 @@ export const ModernFlowChatContainer: React.FC = ( }; }, [ activeSession?.sessionId, + activeSession?.isHistorical, + activeSession?.contextRestoreState, + activeSession?.remoteConnectionId, + activeSession?.remoteSshHost, + hasPendingHistoryCompletion, latestTurnId, latestTurnUsesStickyPin, turnSummaries.length, @@ -586,7 +687,7 @@ export const ModernFlowChatContainer: React.FC = ( return; } - const releaseKey = `${sessionId}:${latestTurnId}:${turnSummaries.length}`; + const releaseKey = `${sessionId}:${latestTurnId}`; if (releasedHistoryCompletionKeyRef.current === releaseKey) { return; } @@ -602,13 +703,12 @@ export const ModernFlowChatContainer: React.FC = ( } releasedHistoryCompletionKeyRef.current = releaseKey; const released = flowChatStore.releaseSessionHistoryCompletionAfterInitialPaint(sessionId); - if (released) { - startupTrace.markPhase('historical_session_initial_content_painted', { - sessionId, - latestTurnId, - turnCount: turnSummaries.length, - }); - } + startupTrace.markPhase('historical_session_initial_content_painted', { + sessionId, + latestTurnId, + released, + turnCount: turnSummaries.length, + }); }; const checkLatestTextVisibility = () => { @@ -626,12 +726,10 @@ export const ModernFlowChatContainer: React.FC = ( if (attempts >= HISTORY_INITIAL_CONTENT_PAINT_MAX_ATTEMPTS) { setHistoryInitialContentReadyKey(releaseKey); releasedHistoryCompletionKeyRef.current = releaseKey; - const released = flowChatStore.releaseSessionHistoryCompletionAfterInitialPaint(sessionId); startupTrace.markPhase('historical_session_initial_content_paint_signal_missed', { sessionId, latestTurnId, attempts, - released, }); return; } @@ -673,18 +771,21 @@ export const ModernFlowChatContainer: React.FC = ( if ( !sessionId || activeSession.historyState !== 'ready' || - !hasPendingHistoryCompletion || + ( + !hasPendingHistoryCompletion && + !hasDeferredHistoryProjection + ) || trimmedSearchQuery.length === 0 ) { return; } if (latestTurnId) { - releasedHistoryCompletionKeyRef.current = `${sessionId}:${latestTurnId}:${turnSummaries.length}`; + releasedHistoryCompletionKeyRef.current = `${sessionId}:${latestTurnId}`; } - const released = flowChatStore.releaseSessionHistoryCompletionAfterInitialPaint(sessionId); - if (released) { + const requested = flowChatStore.requestSessionFullHistoryProjection(sessionId, 'search'); + if (requested) { startupTrace.markPhase('historical_session_full_hydrate_released_for_search', { sessionId, queryLength: trimmedSearchQuery.length, @@ -694,6 +795,7 @@ export const ModernFlowChatContainer: React.FC = ( }, [ activeSession?.historyState, activeSession?.sessionId, + hasDeferredHistoryProjection, hasPendingHistoryCompletion, latestTurnId, searchQuery, @@ -849,47 +951,80 @@ export const ModernFlowChatContainer: React.FC = (
- {showHistoryPlaceholder ? ( - - ) : virtualItems.length === 0 ? ( - { - window.dispatchEvent(new CustomEvent('fill-chat-input', { - detail: { message: command } - })); - }} - /> - ) : ( - <> - + {showFailedHistoryPlaceholder ? ( + - {showHistoryInitialContentOverlay && ( -
- -
- )} - - )} + ) : virtualItems.length === 0 ? ( + showHistoryLoadingLayer ? null : ( + { + window.dispatchEvent(new CustomEvent('fill-chat-input', { + detail: { message: command } + })); + }} + /> + ) + ) : ( + <> + + + )} + {showHistoryLoadingLayer && ( +
+ +
+ )} + {showHistoryOpenIntentOverlay && ( +
+
+ )} +
diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx index a8e977820..efae05bda 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx @@ -93,6 +93,7 @@ export const VirtualItemRenderer = React.memo( className={wrapperClassName} data-turn-id={item.turnId} data-item-type={item.type} + data-virtual-index={index} > {content ||
}
diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.layout.test.ts b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.layout.test.ts index b0dd44482..f19e5416a 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.layout.test.ts +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.layout.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { getVirtualMessageDefaultItemHeight } from './virtualMessageListLayout'; +import { + estimateTextHeightFromLength, + estimateVirtualMessageItemHeight, + getVirtualMessageDefaultItemHeight, +} from './virtualMessageListLayout'; +import type { VirtualItem } from '../../store/modernFlowChatStore'; describe('getVirtualMessageDefaultItemHeight', () => { it('keeps compact historical projections on the small row estimate', () => { @@ -18,6 +23,14 @@ describe('getVirtualMessageDefaultItemHeight', () => { })).toBeGreaterThan(200); }); + it('prioritizes the taller estimate when a historical initial projection contains model rounds', () => { + expect(getVirtualMessageDefaultItemHeight({ + isHistorical: true, + hasCompactHistoricalProjection: false, + hasInitialHistoryModelRoundProjection: true, + })).toBeGreaterThan(200); + }); + it('keeps live sessions on the legacy estimate', () => { expect(getVirtualMessageDefaultItemHeight({ isHistorical: false, @@ -26,3 +39,48 @@ describe('getVirtualMessageDefaultItemHeight', () => { })).toBe(200); }); }); + +describe('estimateVirtualMessageItemHeight', () => { + it('estimates text height directly from length', () => { + expect(estimateTextHeightFromLength(0, 72, 30)).toBe(102); + expect(estimateTextHeightFromLength(60, 72, 30)).toBe(102); + expect(estimateTextHeightFromLength(61, 72, 30)).toBe(132); + }); + + it('uses content-aware estimates for large historical model rounds', () => { + const item = { + type: 'model-round', + turnId: 'turn-1', + isLastRound: true, + isTurnComplete: true, + data: { + id: 'round-1', + status: 'completed', + isStreaming: false, + items: [{ + id: 'text-1', + type: 'text', + content: 'x'.repeat(3600), + status: 'completed', + timestamp: 1, + }], + }, + } as VirtualItem; + + expect(estimateVirtualMessageItemHeight(item)).toBeGreaterThan(1000); + }); + + it('keeps compact user-only rows small enough for partial history tails', () => { + const item = { + type: 'user-message', + turnId: 'turn-1', + data: { + id: 'user-1', + content: 'short prompt', + timestamp: 1, + }, + } as VirtualItem; + + expect(estimateVirtualMessageItemHeight(item)).toBeLessThanOrEqual(160); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss index dd0a4c2e5..3e873e360 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss @@ -67,5 +67,47 @@ min-height: 72px; overflow-anchor: none; } -} + &__static-scroller { + width: 100%; + height: 100%; + position: relative; + z-index: 1; + overflow-y: auto; + overflow-x: hidden; + overflow-anchor: none; + scrollbar-gutter: stable; + } + + &__static-items { + width: 100%; + } + + &__projection-handoff-overlay { + position: absolute; + inset: 0; + z-index: 2; + overflow: hidden; + pointer-events: none; + background: var(--color-bg-flowchat, var(--color-bg-scene, var(--color-bg-primary, #fff))); + contain: paint; + } + + &__projection-handoff-content { + width: 100%; + will-change: transform; + + &--bottom { + position: absolute; + left: 0; + right: 0; + bottom: 0; + min-height: 100%; + display: flex; + flex-direction: column; + justify-content: flex-end; + transform: none; + } + } + +} 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 cd925e1eb..85a3e6b39 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -11,7 +11,7 @@ * - "Scroll to latest" bar appears whenever the list is not at bottom. */ -import React, { useRef, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'; +import React, { useRef, useState, useCallback, useEffect, useLayoutEffect, forwardRef, useImperativeHandle } from 'react'; import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'; import { useActiveSessionState } from '../../hooks/useActiveSessionState'; import { VirtualItemRenderer } from './VirtualItemRenderer'; @@ -40,7 +40,10 @@ import { } from '../../utils/flowChatTurnScrollPolicy'; import { flowChatStore } from '../../store/FlowChatStore'; import { startupTrace } from '@/shared/utils/startupTrace'; -import { getVirtualMessageDefaultItemHeight } from './virtualMessageListLayout'; +import { + estimateVirtualMessageItemHeight, + getVirtualMessageDefaultItemHeight, +} from './virtualMessageListLayout'; import './VirtualMessageList.scss'; const COMPENSATION_EPSILON_PX = 0.5; @@ -56,6 +59,9 @@ const LATEST_END_ANCHOR_VISIBILITY_MARGIN_PX = 4; const LATEST_END_ANCHOR_STABLE_EPSILON_PX = 1; const VIRTUOSO_FIRST_ITEM_INDEX_BASE = 1_000_000; const PARTIAL_HISTORY_INITIAL_TAIL_TURN_BUDGET = 16; +const PARTIAL_HISTORY_FULL_PROJECTION_TOP_THRESHOLD_PX = 1200; +const HISTORY_PROJECTION_HANDOFF_MAX_DURATION_MS = 5000; +const SESSION_OPEN_HANDOFF_ITEM_BUDGET = 24; type LatestEndAnchorResolveReason = | 'raf' @@ -115,6 +121,21 @@ interface LatestEndAnchorRequestState { lastTargetBottom: number | null; } +interface HistoryProjectionHandoffSnapshot { + sessionId: string; + reason: string; + createdAtMs: number; + items: VirtualItem[]; + mode: 'scroll-position' | 'bottom-tail'; + targetTurnId: string | null; + anchorKey: string | null; + anchorOffsetTopPx: number; + scrollTop: number; + scrollHeight: number; + clientHeight: number; + footerHeightPx: number; +} + type BottomReservationKind = 'collapse' | 'pin'; interface BottomReservationBase { @@ -324,9 +345,18 @@ export const VirtualMessageList = forwardRef((_, ref) => () => createInitialBottomReservationState() ); const [pendingTurnPin, setPendingTurnPin] = useState(null); + const [historyProjectionHandoff, setHistoryProjectionHandoff] = useState(null); const scrollerElementRef = useRef(null); const footerElementRef = useRef(null); + const activeSessionIdRef = useRef(null); + const pendingHistoryProjectionHandoffRef = useRef(null); + const historyProjectionHandoffRef = useRef(null); + const historyProjectionHandoffReleaseFrameRef = useRef(null); + const fullHistoryProjectionIntentFrameRef = useRef(null); + const pendingFullHistoryProjectionReasonRef = useRef(null); + const sessionOpenHandoffSessionIdRef = useRef(null); + const previousActiveSessionIdForOpenHandoffRef = useRef(undefined); const bottomReservationStateRef = useRef(createInitialBottomReservationState()); const previousMeasuredHeightRef = useRef(null); const previousScrollTopRef = useRef(0); @@ -382,6 +412,7 @@ export const VirtualMessageList = forwardRef((_, ref) => const isStreamingOutputRef = useRef(false); const previousIsStreamingOutputRef = useRef(false); const transientTurnPinStabilizationRef = useRef(null); + activeSessionIdRef.current = activeSessionId; const isInputActive = useChatInputState(state => state.isActive); const isInputExpanded = useChatInputState(state => state.isExpanded); @@ -721,6 +752,14 @@ export const VirtualMessageList = forwardRef((_, ref) => const latestTurnId = userMessageItems[userMessageItems.length - 1]?.item.turnId ?? null; const latestUserMessageIndex = userMessageItems[userMessageItems.length - 1]?.index ?? 0; + const hasPendingHistoryCompletion = activeSession?.sessionId + ? flowChatStore.hasPendingSessionHistoryCompletion(activeSession.sessionId) + : false; + const hasPartialHistoryInitialViewport = + activeSession?.historyState === 'ready' && + activeSession.contextRestoreState === 'pending' && + (activeSession.dialogTurns.length ?? 0) <= PARTIAL_HISTORY_INITIAL_TAIL_TURN_BUDGET; + const useInitialHistoryRenderBudget = hasPendingHistoryCompletion || hasPartialHistoryInitialViewport; const latestTurnAutoFollowStateRef = useRef<{ turnId: string | null; sawPositiveFloor: boolean; @@ -822,12 +861,44 @@ export const VirtualMessageList = forwardRef((_, ref) => run(Math.max(1, frames)); }, [measureVisibleTurn]); + const escapeTurnIdSelector = useCallback((turnId: string) => ( + typeof CSS !== 'undefined' && typeof CSS.escape === 'function' + ? CSS.escape(turnId) + : turnId.replace(/["\\]/g, '\\$&') + ), []); + + const getRenderedTurnElements = useCallback((turnId: string, options?: { includeHistoryProjectionHandoff?: boolean }) => { + const scroller = scrollerElementRef.current; + if (!scroller) return []; + + const escapedTurnId = escapeTurnIdSelector(turnId); + const nodes = Array.from(scroller.querySelectorAll( + `.virtual-item-wrapper[data-turn-id="${escapedTurnId}"]`, + )); + + if (options?.includeHistoryProjectionHandoff === false) { + return nodes.filter(node => !node.closest('[data-history-projection-handoff="true"]')); + } + + return nodes; + }, [escapeTurnIdSelector]); + const getRenderedUserMessageElement = useCallback((turnId: string) => { const scroller = scrollerElementRef.current; if (!scroller) return null; + const escapedTurnId = escapeTurnIdSelector(turnId); return scroller.querySelector( - `.virtual-item-wrapper[data-item-type="user-message"][data-turn-id="${turnId}"]`, + `.virtual-item-wrapper[data-item-type="user-message"][data-turn-id="${escapedTurnId}"]`, + ); + }, [escapeTurnIdSelector]); + + const getRenderedVirtualItemElement = useCallback((itemIndex: number) => { + const scroller = scrollerElementRef.current; + if (!scroller) return null; + + return scroller.querySelector( + `.virtual-item-wrapper[data-virtual-index="${itemIndex}"]`, ); }, []); @@ -838,18 +909,13 @@ export const VirtualMessageList = forwardRef((_, ref) => } const scrollerRect = scroller.getBoundingClientRect(); - const escapedTurnId = typeof CSS !== 'undefined' && typeof CSS.escape === 'function' - ? CSS.escape(turnId) - : turnId.replace(/["\\]/g, '\\$&'); - const nodes = scroller.querySelectorAll( - `.virtual-item-wrapper[data-turn-id="${escapedTurnId}"]`, - ); + const nodes = getRenderedTurnElements(turnId); - return Array.from(nodes).some(node => { + return nodes.some(node => { const rect = node.getBoundingClientRect(); return rect.bottom > scrollerRect.top && rect.top < scrollerRect.bottom; }); - }, []); + }, [getRenderedTurnElements]); const isTurnTextRenderedInViewport = useCallback((turnId: string) => { const scroller = scrollerElementRef.current; @@ -858,20 +924,242 @@ export const VirtualMessageList = forwardRef((_, ref) => } const scrollerRect = scroller.getBoundingClientRect(); - const escapedTurnId = typeof CSS !== 'undefined' && typeof CSS.escape === 'function' - ? CSS.escape(turnId) - : turnId.replace(/["\\]/g, '\\$&'); - const nodes = scroller.querySelectorAll( - `.virtual-item-wrapper[data-turn-id="${escapedTurnId}"]`, - ); + const nodes = getRenderedTurnElements(turnId); + + return nodes.some(node => { + const rect = node.getBoundingClientRect(); + const visible = rect.bottom > scrollerRect.top && rect.top < scrollerRect.bottom; + return visible && (node.innerText?.trim().length ?? 0) > 0; + }); + }, [getRenderedTurnElements]); + + const isTurnTextRenderedInViewportOutsideHandoff = useCallback((turnId: string) => { + const scroller = scrollerElementRef.current; + if (!scroller) { + return false; + } - return Array.from(nodes).some(node => { + const scrollerRect = scroller.getBoundingClientRect(); + const nodes = getRenderedTurnElements(turnId, { includeHistoryProjectionHandoff: false }); + + return nodes.some(node => { const rect = node.getBoundingClientRect(); const visible = rect.bottom > scrollerRect.top && rect.top < scrollerRect.bottom; return visible && (node.innerText?.trim().length ?? 0) > 0; }); + }, [getRenderedTurnElements]); + + const hasVisibleRenderedVirtualItem = useCallback(() => { + const scroller = scrollerElementRef.current; + if (!scroller) { + return false; + } + + const scrollerRect = scroller.getBoundingClientRect(); + const nodes = Array.from( + scroller.querySelectorAll('.virtual-item-wrapper[data-turn-id]'), + ); + + return nodes.some(node => { + const rect = node.getBoundingClientRect(); + const style = window.getComputedStyle(node); + return ( + rect.bottom > scrollerRect.top && + rect.top < scrollerRect.bottom && + rect.width > 0 && + rect.height > 0 && + style.visibility !== 'hidden' && + style.display !== 'none' && + (node.innerText?.trim().length ?? 0) > 0 + ); + }); + }, []); + + const findVirtualItemIndexByStableKey = useCallback((stableKey: string | null) => { + if (!stableKey) { + return -1; + } + + return virtualItems.findIndex(item => getVirtualItemStableKey(item) === stableKey); + }, [virtualItems]); + + const captureVisibleVirtualItemAnchor = useCallback(() => { + const scroller = scrollerElementRef.current; + if (!scroller) { + return { anchorKey: null, anchorOffsetTopPx: 0 }; + } + + const scrollerRect = scroller.getBoundingClientRect(); + const nodes = Array.from( + scroller.querySelectorAll('.virtual-item-wrapper[data-virtual-index]'), + ); + const anchorNode = nodes.find(node => { + const rect = node.getBoundingClientRect(); + return rect.bottom > scrollerRect.top && rect.top < scrollerRect.bottom; + }); + const anchorIndex = Number(anchorNode?.dataset.virtualIndex); + const anchorItem = Number.isInteger(anchorIndex) ? virtualItems[anchorIndex] : undefined; + if (!anchorNode || !anchorItem) { + return { anchorKey: null, anchorOffsetTopPx: 0 }; + } + + return { + anchorKey: getVirtualItemStableKey(anchorItem), + anchorOffsetTopPx: anchorNode.getBoundingClientRect().top - scrollerRect.top, + }; + }, [virtualItems]); + + const isHistoryProjectionHandoffAnchorReady = useCallback((snapshot: HistoryProjectionHandoffSnapshot) => { + const scroller = scrollerElementRef.current; + const anchorIndex = findVirtualItemIndexByStableKey(snapshot.anchorKey); + if (!scroller || anchorIndex < 0) { + return false; + } + + const anchorElement = getRenderedVirtualItemElement(anchorIndex); + if (!anchorElement) { + return false; + } + + const scrollerRect = scroller.getBoundingClientRect(); + const anchorRect = anchorElement.getBoundingClientRect(); + const visible = anchorRect.bottom > scrollerRect.top && anchorRect.top < scrollerRect.bottom; + const offsetDelta = Math.abs((anchorRect.top - scrollerRect.top) - snapshot.anchorOffsetTopPx); + return visible && offsetDelta <= 8; + }, [findVirtualItemIndexByStableKey, getRenderedVirtualItemElement]); + + const isHistoryProjectionHandoffTargetReady = useCallback((snapshot: HistoryProjectionHandoffSnapshot) => { + if (snapshot.mode === 'scroll-position' && snapshot.anchorKey) { + return isHistoryProjectionHandoffAnchorReady(snapshot); + } + + if (snapshot.targetTurnId) { + return isTurnTextRenderedInViewportOutsideHandoff(snapshot.targetTurnId); + } + + return hasVisibleRenderedVirtualItem(); + }, [ + hasVisibleRenderedVirtualItem, + isHistoryProjectionHandoffAnchorReady, + isTurnTextRenderedInViewportOutsideHandoff, + ]); + + const clearHistoryProjectionHandoff = useCallback((reason: string) => { + if (historyProjectionHandoffReleaseFrameRef.current !== null) { + cancelAnimationFrame(historyProjectionHandoffReleaseFrameRef.current); + historyProjectionHandoffReleaseFrameRef.current = null; + } + + const snapshot = historyProjectionHandoffRef.current; + pendingHistoryProjectionHandoffRef.current = null; + historyProjectionHandoffRef.current = null; + setHistoryProjectionHandoff(null); + + if (snapshot) { + startupTrace.markPhase('flowchat_history_projection_handoff_cleared', { + sessionId: snapshot.sessionId, + reason, + handoffReason: snapshot.reason, + durationMs: Math.round(performance.now() - snapshot.createdAtMs), + }); + } }, []); + const scheduleHistoryProjectionHandoffRelease = useCallback((frames = 1) => { + if (historyProjectionHandoffReleaseFrameRef.current !== null) { + cancelAnimationFrame(historyProjectionHandoffReleaseFrameRef.current); + historyProjectionHandoffReleaseFrameRef.current = null; + } + + const run = (remainingFrames: number) => { + historyProjectionHandoffReleaseFrameRef.current = requestAnimationFrame(() => { + historyProjectionHandoffReleaseFrameRef.current = null; + + const snapshot = historyProjectionHandoffRef.current; + if (!snapshot) { + return; + } + + if (remainingFrames > 1) { + run(remainingFrames - 1); + return; + } + + if (activeSessionIdRef.current !== snapshot.sessionId) { + clearHistoryProjectionHandoff('session-changed'); + return; + } + + if (isHistoryProjectionHandoffTargetReady(snapshot)) { + clearHistoryProjectionHandoff('target-content-ready'); + return; + } + + if (performance.now() - snapshot.createdAtMs >= HISTORY_PROJECTION_HANDOFF_MAX_DURATION_MS) { + clearHistoryProjectionHandoff('timeout'); + return; + } + + run(1); + }); + }; + + run(Math.max(1, frames)); + }, [clearHistoryProjectionHandoff, isHistoryProjectionHandoffTargetReady]); + + const captureHistoryProjectionHandoffSnapshot = useCallback((reason: string) => { + const sessionId = activeSession?.sessionId; + const scroller = scrollerElementRef.current; + if ( + !sessionId || + activeSession?.isPartial !== true || + !scroller || + virtualItems.length === 0 + ) { + return; + } + + const { anchorKey, anchorOffsetTopPx } = captureVisibleVirtualItemAnchor(); + const snapshot: HistoryProjectionHandoffSnapshot = { + sessionId, + reason, + createdAtMs: performance.now(), + items: virtualItems, + mode: 'scroll-position', + targetTurnId: latestTurnId, + anchorKey, + anchorOffsetTopPx, + scrollTop: scroller.scrollTop, + scrollHeight: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + footerHeightPx: getFooterHeightPx(getTotalBottomCompensationPx()), + }; + + pendingHistoryProjectionHandoffRef.current = snapshot; + historyProjectionHandoffRef.current = snapshot; + setHistoryProjectionHandoff(snapshot); + startupTrace.markPhase('flowchat_history_projection_handoff_captured', { + sessionId, + reason, + anchorKey, + anchorOffsetTopPx: Math.round(anchorOffsetTopPx), + itemCount: snapshot.items.length, + scrollTop: Math.round(snapshot.scrollTop), + scrollHeight: Math.round(snapshot.scrollHeight), + clientHeight: Math.round(snapshot.clientHeight), + }); + scheduleHistoryProjectionHandoffRelease(2); + }, [ + activeSession?.isPartial, + activeSession?.sessionId, + captureVisibleVirtualItemAnchor, + getFooterHeightPx, + getTotalBottomCompensationPx, + latestTurnId, + scheduleHistoryProjectionHandoffRelease, + virtualItems, + ]); + const buildPinReservation = useCallback(( turnId: string, pinMode: FlowChatPinTurnToTopMode, @@ -1273,6 +1561,107 @@ export const VirtualMessageList = forwardRef((_, ref) => setScrollerElement(null); }, []); + const requestFullHistoryProjectionForUserIntent = useCallback((reason: string) => { + const sessionId = activeSession?.sessionId; + if ( + !sessionId || + activeSession.historyState !== 'ready' || + activeSession.isPartial !== true + ) { + return; + } + + const hasFullHistoryWork = + flowChatStore.hasPendingSessionHistoryCompletion(sessionId) || + flowChatStore.hasDeferredSessionHistoryProjection(sessionId); + if (!hasFullHistoryWork) { + return; + } + + captureHistoryProjectionHandoffSnapshot(reason); + flowChatStore.requestSessionFullHistoryProjection(sessionId, reason); + }, [ + activeSession?.historyState, + activeSession?.isPartial, + activeSession?.sessionId, + captureHistoryProjectionHandoffSnapshot, + ]); + + const shouldRequestFullHistoryProjectionForUserIntent = useCallback((options?: { force?: boolean }) => { + if (options?.force === true) { + return true; + } + + const scroller = scrollerElementRef.current; + if (!scroller) { + return false; + } + + const topThresholdPx = Math.max( + PARTIAL_HISTORY_FULL_PROJECTION_TOP_THRESHOLD_PX, + scroller.clientHeight * 1.5, + ); + return scroller.scrollTop <= topThresholdPx; + }, []); + + const scheduleFullHistoryProjectionForUserIntent = useCallback((reason: string, options?: { force?: boolean }) => { + const scheduledSessionId = activeSession?.sessionId ?? null; + pendingFullHistoryProjectionReasonRef.current = reason; + if (fullHistoryProjectionIntentFrameRef.current !== null) { + cancelAnimationFrame(fullHistoryProjectionIntentFrameRef.current); + fullHistoryProjectionIntentFrameRef.current = null; + } + + fullHistoryProjectionIntentFrameRef.current = requestAnimationFrame(() => { + fullHistoryProjectionIntentFrameRef.current = requestAnimationFrame(() => { + fullHistoryProjectionIntentFrameRef.current = null; + const pendingReason = pendingFullHistoryProjectionReasonRef.current; + pendingFullHistoryProjectionReasonRef.current = null; + if (!pendingReason || activeSessionIdRef.current !== scheduledSessionId) { + return; + } + + if (!shouldRequestFullHistoryProjectionForUserIntent(options)) { + return; + } + + requestFullHistoryProjectionForUserIntent(pendingReason); + }); + }); + }, [ + activeSession?.sessionId, + requestFullHistoryProjectionForUserIntent, + shouldRequestFullHistoryProjectionForUserIntent, + ]); + + useLayoutEffect(() => { + const snapshot = pendingHistoryProjectionHandoffRef.current; + if ( + !snapshot || + activeSession?.sessionId !== snapshot.sessionId || + activeSession?.isPartial === true || + virtualItems.length <= snapshot.items.length + ) { + return; + } + + pendingHistoryProjectionHandoffRef.current = null; + historyProjectionHandoffRef.current = snapshot; + setHistoryProjectionHandoff(snapshot); + startupTrace.markPhase('flowchat_history_projection_handoff_activated', { + sessionId: snapshot.sessionId, + reason: snapshot.reason, + previousItemCount: snapshot.items.length, + nextItemCount: virtualItems.length, + }); + scheduleHistoryProjectionHandoffRelease(2); + }, [ + activeSession?.isPartial, + activeSession?.sessionId, + scheduleHistoryProjectionHandoffRelease, + virtualItems.length, + ]); + const shouldSuspendAutoFollow = useCallback(() => { const collapseIntent = pendingCollapseIntentRef.current; return ( @@ -1312,8 +1701,17 @@ export const VirtualMessageList = forwardRef((_, ref) => baseTotalCompensationPx: 0, cumulativeShrinkPx: 0, }; + const currentSessionId = activeSession?.sessionId ?? null; + const activeHandoff = historyProjectionHandoffRef.current; + if (!activeHandoff || activeHandoff.sessionId !== currentSessionId) { + clearHistoryProjectionHandoff('session-reset'); + } + const pendingHandoff = pendingHistoryProjectionHandoffRef.current; + if (pendingHandoff && pendingHandoff.sessionId !== currentSessionId) { + pendingHistoryProjectionHandoffRef.current = null; + } resetBottomReservations(); - }, [activeSession?.sessionId, cancelLatestEndAnchorStabilization, clearTurnPinRequest, resetBottomReservations]); + }, [activeSession?.sessionId, cancelLatestEndAnchorStabilization, clearHistoryProjectionHandoff, clearTurnPinRequest, resetBottomReservations]); useEffect(() => { previousIsStreamingOutputRef.current = false; @@ -1331,6 +1729,10 @@ export const VirtualMessageList = forwardRef((_, ref) => useEffect(() => { return () => { cancelLatestEndAnchorStabilization(); + if (historyProjectionHandoffReleaseFrameRef.current !== null) { + cancelAnimationFrame(historyProjectionHandoffReleaseFrameRef.current); + historyProjectionHandoffReleaseFrameRef.current = null; + } }; }, [cancelLatestEndAnchorStabilization]); @@ -1356,6 +1758,7 @@ export const VirtualMessageList = forwardRef((_, ref) => schedulePinReservationReconcile(2); scheduleTransientTurnPinStabilization(2); scheduleFollowToLatestWithViewportState('resize-observer'); + scheduleHistoryProjectionHandoffRelease(1); }); resizeObserverRef.current.observe(resizeTarget); @@ -1383,6 +1786,7 @@ export const VirtualMessageList = forwardRef((_, ref) => schedulePinReservationReconcile(2); scheduleTransientTurnPinStabilization(2); scheduleFollowToLatestWithViewportState('mutation-observer'); + scheduleHistoryProjectionHandoffRelease(1); }); }); mutationObserverRef.current.observe(scrollerElement, { @@ -1410,6 +1814,7 @@ export const VirtualMessageList = forwardRef((_, ref) => scheduleVisibleTurnMeasure(2); schedulePinReservationReconcile(2); scheduleTransientTurnPinStabilization(2); + scheduleHistoryProjectionHandoffRelease(1); if (layoutTransitionCountRef.current === 0 && pendingCollapseIntentRef.current.active) { pendingCollapseIntentRef.current = { active: false, @@ -1517,6 +1922,7 @@ export const VirtualMessageList = forwardRef((_, ref) => previousScrollTopRef.current = scrollerElement.scrollTop; scheduleVisibleTurnMeasure(); followOutputControllerRef.current.handleScroll(); + scheduleFullHistoryProjectionForUserIntent('scroll-near-partial-history-boundary'); if (anchorLockRef.current.active && performance.now() > anchorLockRef.current.lockUntilMs && layoutTransitionCountRef.current === 0) { releaseAnchorLock('expired-after-scroll'); @@ -1533,6 +1939,7 @@ export const VirtualMessageList = forwardRef((_, ref) => if (event.deltaY < 0) { userInitiatedUpwardScrollUntilMsRef.current = performance.now() + USER_UPWARD_SCROLL_INTENT_WINDOW_MS; + scheduleFullHistoryProjectionForUserIntent('wheel-up'); followOutputControllerRef.current.handleUserScrollIntent(); releaseAnchorLock('wheel-up'); } @@ -1558,6 +1965,7 @@ export const VirtualMessageList = forwardRef((_, ref) => touchScrollIntentStartYRef.current = currentY; userInitiatedUpwardScrollUntilMsRef.current = performance.now() + USER_UPWARD_SCROLL_INTENT_WINDOW_MS; + scheduleFullHistoryProjectionForUserIntent('touch-scroll-up'); followOutputControllerRef.current.handleUserScrollIntent(); releaseAnchorLock('touch-scroll-up'); } @@ -1576,6 +1984,7 @@ export const VirtualMessageList = forwardRef((_, ref) => cancelLatestEndAnchorStabilization(); userInitiatedUpwardScrollUntilMsRef.current = performance.now() + USER_UPWARD_SCROLL_INTENT_WINDOW_MS; + scheduleFullHistoryProjectionForUserIntent('keyboard-scroll-up', { force: event.key === 'Home' }); followOutputControllerRef.current.handleUserScrollIntent(); releaseAnchorLock('keyboard-scroll-up'); }; @@ -1594,6 +2003,7 @@ export const VirtualMessageList = forwardRef((_, ref) => cancelLatestEndAnchorStabilization(); userInitiatedUpwardScrollUntilMsRef.current = performance.now() + USER_UPWARD_SCROLL_INTENT_WINDOW_MS; + scheduleFullHistoryProjectionForUserIntent('scrollbar-pointer-down'); followOutputControllerRef.current.handleUserScrollIntent(); releaseAnchorLock('scrollbar-pointer-down'); }; @@ -1612,6 +2022,7 @@ export const VirtualMessageList = forwardRef((_, ref) => cancelLatestEndAnchorStabilization(); userInitiatedUpwardScrollUntilMsRef.current = performance.now() + USER_UPWARD_SCROLL_INTENT_WINDOW_MS; + scheduleFullHistoryProjectionForUserIntent('scrollbar-pointer-move'); followOutputControllerRef.current.handleUserScrollIntent(); releaseAnchorLock('scrollbar-pointer-move'); }; @@ -1635,6 +2046,7 @@ export const VirtualMessageList = forwardRef((_, ref) => scheduleHeightMeasure(2); scheduleVisibleTurnMeasure(2); schedulePinReservationReconcile(2); + scheduleHistoryProjectionHandoffRelease(1); }; const handleToolCardCollapseIntent = (event: Event) => { @@ -1743,6 +2155,12 @@ export const VirtualMessageList = forwardRef((_, ref) => cancelAnimationFrame(turnPinStabilizationFrameRef.current); turnPinStabilizationFrameRef.current = null; } + + if (fullHistoryProjectionIntentFrameRef.current !== null) { + cancelAnimationFrame(fullHistoryProjectionIntentFrameRef.current); + fullHistoryProjectionIntentFrameRef.current = null; + } + pendingFullHistoryProjectionReasonRef.current = null; }; }, [ activateAnchorLock, @@ -1757,6 +2175,8 @@ export const VirtualMessageList = forwardRef((_, ref) => releaseAnchorLock, scheduleHeightMeasure, scheduleFollowToLatestWithViewportState, + scheduleFullHistoryProjectionForUserIntent, + scheduleHistoryProjectionHandoffRelease, schedulePinReservationReconcile, scheduleTransientTurnPinStabilization, scheduleVisibleTurnMeasure, @@ -1784,7 +2204,7 @@ export const VirtualMessageList = forwardRef((_, ref) => const scroller = scrollerElementRef.current; const virtuoso = virtuosoRef.current; - if (!scroller || !virtuoso) { + if (!scroller) { request.attempts += 1; if (request.attempts >= LATEST_END_ANCHOR_STABILIZATION_MAX_ATTEMPTS) { cancelLatestEndAnchorStabilization(); @@ -1793,7 +2213,7 @@ export const VirtualMessageList = forwardRef((_, ref) => reason, targetIndex: request.targetIndex, turnId: request.turnId, - cause: !scroller ? 'missing_scroller' : 'missing_virtuoso', + cause: 'missing_scroller', }); return false; } @@ -1820,7 +2240,7 @@ export const VirtualMessageList = forwardRef((_, ref) => } request.attempts += 1; - const targetElement = getRenderedUserMessageElement(request.turnId); + const targetElement = getRenderedVirtualItemElement(targetIndex); const scrollerRect = scroller.getBoundingClientRect(); const inputOverlayInsetPx = Math.max( 0, @@ -1833,7 +2253,7 @@ export const VirtualMessageList = forwardRef((_, ref) => ); const isTargetVisible = () => { - const currentTargetElement = getRenderedUserMessageElement(request.turnId); + const currentTargetElement = getRenderedVirtualItemElement(targetIndex); if (!currentTargetElement) { return false; } @@ -1842,7 +2262,7 @@ export const VirtualMessageList = forwardRef((_, ref) => }; const settleIfStableVisible = () => { - const currentTargetElement = getRenderedUserMessageElement(request.turnId); + const currentTargetElement = getRenderedVirtualItemElement(targetIndex); if (!currentTargetElement) { request.visibleFrames = 0; request.stableVisibleFrames = 0; @@ -1917,14 +2337,17 @@ export const VirtualMessageList = forwardRef((_, ref) => previousScrollTopRef.current = nextScrollTop; previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(scroller); } - } else { + } else if (virtuoso) { request.visibleFrames = 0; virtuoso.scrollToIndex({ index: toVirtuosoIndex(targetIndex), align: 'end', behavior: 'auto', }); - if (request.attempts >= 3) { + const shouldUseTailFallback = + targetIndex >= virtualItems.length - 1 || + request.turnId === latestTurnId; + if (shouldUseTailFallback) { const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); if (Math.abs(maxScrollTop - scroller.scrollTop) > COMPENSATION_EPSILON_PX) { scroller.scrollTop = maxScrollTop; @@ -1932,6 +2355,14 @@ export const VirtualMessageList = forwardRef((_, ref) => previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(scroller); } } + } else { + request.visibleFrames = 0; + const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); + if (Math.abs(maxScrollTop - scroller.scrollTop) > COMPENSATION_EPSILON_PX) { + scroller.scrollTop = maxScrollTop; + previousScrollTopRef.current = maxScrollTop; + previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(scroller); + } } if (isTargetVisible()) { @@ -1955,7 +2386,8 @@ export const VirtualMessageList = forwardRef((_, ref) => return false; }, [ cancelLatestEndAnchorStabilization, - getRenderedUserMessageElement, + getRenderedVirtualItemElement, + latestTurnId, scheduleVisibleTurnMeasure, snapshotMeasuredContentHeight, toVirtuosoIndex, @@ -1971,9 +2403,11 @@ export const VirtualMessageList = forwardRef((_, ref) => schedulePinReservationReconcile(2); scheduleTransientTurnPinStabilization(2); scheduleFollowToLatestWithViewportState('range-changed'); + scheduleHistoryProjectionHandoffRelease(1); }, [ resolveLatestEndAnchorStabilization, scheduleFollowToLatestWithViewportState, + scheduleHistoryProjectionHandoffRelease, schedulePinReservationReconcile, scheduleTransientTurnPinStabilization, scheduleVisibleTurnMeasure, @@ -2158,6 +2592,19 @@ export const VirtualMessageList = forwardRef((_, ref) => return lastDialogTurn.modelRounds.some(round => round.isStreaming); }, [activeSession, isProcessing]); const initialTopMostItemIndex = React.useMemo(() => { + if ( + historyProjectionHandoff?.mode === 'scroll-position' && + historyProjectionHandoff.sessionId === activeSessionId + ) { + const anchorIndex = findVirtualItemIndexByStableKey(historyProjectionHandoff.anchorKey); + if (anchorIndex >= 0) { + return { + index: toVirtuosoIndex(anchorIndex), + align: 'start' as const, + }; + } + } + if (isStreamingOutput) { return toVirtuosoIndex(latestUserMessageIndex); } @@ -2166,7 +2613,17 @@ export const VirtualMessageList = forwardRef((_, ref) => index: toVirtuosoIndex(Math.max(0, virtualItems.length - 1)), align: 'end' as const, }; - }, [isStreamingOutput, latestUserMessageIndex, toVirtuosoIndex, virtualItems.length]); + }, [ + activeSessionId, + findVirtualItemIndexByStableKey, + historyProjectionHandoff?.anchorKey, + historyProjectionHandoff?.mode, + historyProjectionHandoff?.sessionId, + isStreamingOutput, + latestUserMessageIndex, + toVirtuosoIndex, + virtualItems.length, + ]); useEffect(() => { const wasStreaming = previousIsStreamingOutputRef.current; @@ -2738,6 +3195,91 @@ export const VirtualMessageList = forwardRef((_, ref) => }, [lastItemInfo.isTurnProcessing, lastItemInfo.lastItem, isProcessing, processingPhase, isContentGrowing]); const footerHeightPx = getFooterHeightPx(getTotalBottomCompensationPx(bottomReservationState)); + const useStaticInitialHistoryList = useInitialHistoryRenderBudget; + const sessionOpenProjectionHandoff = React.useMemo(() => { + const previousActiveSessionId = previousActiveSessionIdForOpenHandoffRef.current; + const isSessionSwitch = ( + previousActiveSessionId !== undefined && + previousActiveSessionId !== activeSessionId + ); + + if ( + !activeSessionId || + !isSessionSwitch || + activeSession?.historyState !== 'ready' || + activeSession?.isPartial === true || + useStaticInitialHistoryList || + !latestTurnId || + virtualItems.length < SESSION_OPEN_HANDOFF_ITEM_BUDGET || + sessionOpenHandoffSessionIdRef.current === activeSessionId || + historyProjectionHandoff?.sessionId === activeSessionId + ) { + return null; + } + + const scroller = scrollerElementRef.current; + const budgetStartIndex = Math.max(0, virtualItems.length - SESSION_OPEN_HANDOFF_ITEM_BUDGET); + const latestStartIndex = Math.max(0, Math.min(latestUserMessageIndex, virtualItems.length - 1)); + const startIndex = Math.min(budgetStartIndex, latestStartIndex); + const items = virtualItems.slice(startIndex); + if (items.length === 0) { + return null; + } + + const clientHeight = scroller?.clientHeight ?? 0; + return { + sessionId: activeSessionId, + reason: 'session-open', + createdAtMs: performance.now(), + items, + mode: 'bottom-tail', + targetTurnId: latestTurnId, + anchorKey: null, + anchorOffsetTopPx: 0, + scrollTop: 0, + scrollHeight: clientHeight, + clientHeight, + footerHeightPx, + }; + }, [ + activeSession?.historyState, + activeSession?.isPartial, + activeSessionId, + footerHeightPx, + historyProjectionHandoff?.sessionId, + latestTurnId, + latestUserMessageIndex, + useStaticInitialHistoryList, + virtualItems, + ]); + useLayoutEffect(() => { + if ( + !sessionOpenProjectionHandoff || + sessionOpenHandoffSessionIdRef.current === sessionOpenProjectionHandoff.sessionId + ) { + return; + } + + pendingHistoryProjectionHandoffRef.current = null; + historyProjectionHandoffRef.current = sessionOpenProjectionHandoff; + sessionOpenHandoffSessionIdRef.current = sessionOpenProjectionHandoff.sessionId; + setHistoryProjectionHandoff(sessionOpenProjectionHandoff); + startupTrace.markPhase('flowchat_history_projection_handoff_activated', { + sessionId: sessionOpenProjectionHandoff.sessionId, + reason: sessionOpenProjectionHandoff.reason, + previousItemCount: 0, + nextItemCount: virtualItems.length, + }); + scheduleHistoryProjectionHandoffRelease(2); + }, [ + scheduleHistoryProjectionHandoffRelease, + sessionOpenProjectionHandoff, + virtualItems.length, + ]); + useLayoutEffect(() => { + previousActiveSessionIdForOpenHandoffRef.current = activeSessionId; + }, [activeSessionId]); + const activeHistoryProjectionHandoff = historyProjectionHandoff ?? sessionOpenProjectionHandoff; const hasCompactHistoricalProjection = virtualItems.length >= 6 && virtualItems .slice(-16) .every(item => @@ -2745,14 +3287,6 @@ export const VirtualMessageList = forwardRef((_, ref) => item.type === 'user-steering-message' || item.type === 'explore-group' ); - const hasPendingHistoryCompletion = activeSession?.sessionId - ? flowChatStore.hasPendingSessionHistoryCompletion(activeSession.sessionId) - : false; - const hasPartialHistoryInitialViewport = - activeSession?.historyState === 'ready' && - activeSession.contextRestoreState === 'pending' && - (activeSession.dialogTurns.length ?? 0) <= PARTIAL_HISTORY_INITIAL_TAIL_TURN_BUDGET; - const useInitialHistoryRenderBudget = hasPendingHistoryCompletion || hasPartialHistoryInitialViewport; const hasInitialHistoryModelRoundProjection = useInitialHistoryRenderBudget && virtualItems.slice(-16).some(item => item.type === 'model-round'); @@ -2761,14 +3295,86 @@ export const VirtualMessageList = forwardRef((_, ref) => hasCompactHistoricalProjection, hasInitialHistoryModelRoundProjection, }); - const virtuosoOverscan = useInitialHistoryRenderBudget - ? { main: 0, reverse: 0 } - : { main: 600, reverse: 600 }; - const virtuosoViewportIncrease = useInitialHistoryRenderBudget - ? { top: 0, bottom: 0 } - : { top: 600, bottom: 600 }; + const initialHistoryHeightEstimates = React.useMemo( + () => useInitialHistoryRenderBudget + ? virtualItems.map(estimateVirtualMessageItemHeight) + : undefined, + [useInitialHistoryRenderBudget, virtualItems], + ); + const virtuosoOverscan = { main: 600, reverse: 600 }; + const virtuosoViewportIncrease = { top: 600, bottom: 600 }; + useLayoutEffect(() => { + if (!useStaticInitialHistoryList) { + return; + } + const scroller = scrollerElementRef.current; + if (!scroller) { + return; + } + + const nextScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); + scroller.scrollTop = nextScrollTop; + previousScrollTopRef.current = nextScrollTop; + previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(scroller); + scheduleVisibleTurnMeasure(1); + }, [ + activeSessionId, + footerHeightPx, + latestTurnId, + scheduleVisibleTurnMeasure, + snapshotMeasuredContentHeight, + useStaticInitialHistoryList, + virtualItems.length, + ]); // ── Render ──────────────────────────────────────────────────────────── + useLayoutEffect(() => { + const snapshot = historyProjectionHandoffRef.current; + if ( + !snapshot || + snapshot.mode !== 'scroll-position' || + snapshot.sessionId !== activeSessionId || + !snapshot.anchorKey + ) { + return; + } + + const scroller = scrollerElementRef.current; + const anchorIndex = findVirtualItemIndexByStableKey(snapshot.anchorKey); + if (!scroller || anchorIndex < 0) { + return; + } + + const anchorElement = getRenderedVirtualItemElement(anchorIndex); + if (!anchorElement) { + return; + } + + const scrollerRect = scroller.getBoundingClientRect(); + const anchorRect = anchorElement.getBoundingClientRect(); + const offsetDelta = (anchorRect.top - scrollerRect.top) - snapshot.anchorOffsetTopPx; + if (Math.abs(offsetDelta) <= COMPENSATION_EPSILON_PX) { + return; + } + + const nextScrollTop = Math.max(0, scroller.scrollTop + offsetDelta); + scroller.scrollTop = nextScrollTop; + previousScrollTopRef.current = nextScrollTop; + previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(scroller); + scheduleHistoryProjectionHandoffRelease(2); + }, [ + activeSessionId, + findVirtualItemIndexByStableKey, + getRenderedVirtualItemElement, + historyProjectionHandoff?.anchorKey, + historyProjectionHandoff?.anchorOffsetTopPx, + historyProjectionHandoff?.mode, + historyProjectionHandoff?.sessionId, + scheduleHistoryProjectionHandoffRelease, + snapshotMeasuredContentHeight, + virtualItems, + ]); + if (virtualItems.length === 0) { return (
@@ -2780,58 +3386,131 @@ export const VirtualMessageList = forwardRef((_, ref) => } return ( -
- getVirtualItemStableKey(item)} - itemContent={(index, item) => ( - - )} - followOutput={false} - - alignToBottom={false} - firstItemIndex={virtuosoFirstItemIndex} - // New mounts start near the latest user turn to avoid flashing older - // content before sticky pin logic can finish. - initialTopMostItemIndex={initialTopMostItemIndex} - - overscan={virtuosoOverscan} - - atBottomThreshold={50} - atBottomStateChange={handleAtBottomStateChange} - - rangeChanged={handleRangeChanged} - - // Historical sessions often restore into compact user/explore rows. - // Keep live sessions on the legacy estimate because active assistant - // output can be much taller while streaming. - defaultItemHeight={defaultItemHeight} - - increaseViewportBy={virtuosoViewportIncrease} - - scrollerRef={handleScrollerRef} - - components={{ - Header: () =>
, - Footer: () => ( - <> - -
+ {useStaticInitialHistoryList ? ( +
+
+
+ {virtualItems.map((item, index) => ( + - - ), - }} - /> + ))} +
+ +
+
+ ) : ( + `${activeSessionId ?? 'no-active-session'}:${getVirtualItemStableKey(item)}`} + itemContent={(index, item) => ( + + )} + followOutput={false} + + alignToBottom={false} + firstItemIndex={virtuosoFirstItemIndex} + // New mounts start near the latest user turn to avoid flashing older + // content before sticky pin logic can finish. + initialTopMostItemIndex={initialTopMostItemIndex} + overscan={virtuosoOverscan} + + atBottomThreshold={50} + atBottomStateChange={handleAtBottomStateChange} + + rangeChanged={handleRangeChanged} + + // Historical sessions often restore into compact user/explore rows. + // Keep live sessions on the legacy estimate because active assistant + // output can be much taller while streaming. + defaultItemHeight={defaultItemHeight} + heightEstimates={initialHistoryHeightEstimates} + + increaseViewportBy={virtuosoViewportIncrease} + + scrollerRef={handleScrollerRef} + + components={{ + Header: () =>
, + Footer: () => ( + <> + +
+ + ), + }} + /> + )} + + {activeHistoryProjectionHandoff ? ( +