From 8b90809dc695b95e26d1fbfc44acf8ad8f1bcd91 Mon Sep 17 00:00:00 2001 From: limityan Date: Tue, 2 Jun 2026 12:28:33 +0800 Subject: [PATCH] perf(web-ui): reduce post-interactive session pressure --- src/apps/desktop/src/api/acp_client_api.rs | 56 +- src/apps/desktop/src/api/agentic_api.rs | 308 ++-- src/apps/desktop/src/api/git_api.rs | 128 +- src/apps/desktop/src/api/mcp_api.rs | 68 +- src/apps/desktop/src/api/miniapp_api.rs | 26 +- src/apps/desktop/src/api/session_api.rs | 163 +- src/apps/desktop/src/theme.rs | 36 +- .../src/agentic/coordination/coordinator.rs | 80 +- .../core/src/agentic/persistence/manager.rs | 468 +++++- .../core/src/agentic/persistence/mod.rs | 2 +- .../src/agentic/session/session_manager.rs | 149 +- src/web-ui/src/app/App.tsx | 6 + .../sections/workspaces/WorkspaceItem.tsx | 33 +- .../workspaceGitRefreshOptions.test.ts | 52 + .../workspaces/workspaceGitRefreshOptions.ts | 18 + .../startupPerformanceContract.test.ts | 51 + .../Markdown/AsyncPrismSyntaxHighlighter.tsx | 165 +- .../components/Markdown/Markdown.tsx | 78 +- .../components/Markdown/index.ts | 2 +- .../src/flow_chat/components/ChatInput.tsx | 2 +- .../flow_chat/components/FlowTextBlock.tsx | 8 +- .../components/modern/FlowChatHeader.tsx | 11 +- .../components/modern/ModelRoundItem.tsx | 133 +- ...rnFlowChatContainer.history-state.test.tsx | 640 +++++++- .../modern/ModernFlowChatContainer.scss | 8 + .../modern/ModernFlowChatContainer.tsx | 386 ++++- .../modern/UserMessageItem.test.tsx | 41 + .../components/modern/UserMessageItem.tsx | 5 + .../modern/VirtualMessageList.layout.test.ts | 28 + .../components/modern/VirtualMessageList.tsx | 734 ++++++++- .../modern/virtualMessageListLayout.ts | 19 + .../src/flow_chat/store/FlowChatStore.test.ts | 449 +++++- .../src/flow_chat/store/FlowChatStore.ts | 257 +++- .../store/modernFlowChatStore.test.ts | 145 +- .../flow_chat/store/modernFlowChatStore.ts | 113 +- src/web-ui/src/flow_chat/types/flow-chat.ts | 9 + .../src/infrastructure/api/adapters/base.ts | 8 +- .../api/adapters/tauri-adapter.test.ts | 39 +- .../api/adapters/tauri-adapter.ts | 18 +- .../api/service-api/AgentAPI.ts | 26 + .../api/service-api/ApiClient.test.ts | 72 +- .../api/service-api/ApiClient.ts | 82 +- .../contexts/WorkspaceProvider.tsx | 8 + .../theme/core/ThemeService.test.ts | 122 ++ .../infrastructure/theme/core/ThemeService.ts | 117 +- .../infrastructure/theme/store/themeStore.ts | 1 + src/web-ui/src/main.tsx | 20 +- src/web-ui/src/shared/utils/logger.test.ts | 40 + src/web-ui/src/shared/utils/logger.ts | 39 +- .../src/shared/utils/startupTrace.test.ts | 144 ++ src/web-ui/src/shared/utils/startupTrace.ts | 111 +- src/web-ui/src/tools/git/hooks/useGitState.ts | 19 +- tests/e2e/helpers/performance-trace.ts | 63 +- .../scripts/generate-long-session-fixture.mjs | 302 +++- .../performance/startup-session-perf.spec.ts | 1324 ++++++++++++++++- 55 files changed, 6738 insertions(+), 694 deletions(-) create mode 100644 src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.test.ts create mode 100644 src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/VirtualMessageList.layout.test.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/virtualMessageListLayout.ts create mode 100644 src/web-ui/src/shared/utils/logger.test.ts diff --git a/src/apps/desktop/src/api/acp_client_api.rs b/src/apps/desktop/src/api/acp_client_api.rs index 11c67437c..845f02040 100644 --- a/src/apps/desktop/src/api/acp_client_api.rs +++ b/src/apps/desktop/src/api/acp_client_api.rs @@ -2,12 +2,14 @@ use crate::api::app_state::AppState; use crate::api::session_storage_path::desktop_effective_session_storage_path; +use crate::startup_trace::DesktopStartupTrace; use bitfun_acp::client::{ AcpAvailableCommand, AcpClientInfo, AcpClientPermissionResponse, AcpClientRequirementProbe, AcpClientStreamEvent, AcpSessionOptions, CreateAcpFlowSessionRecordResponse, SetAcpSessionModelRequest, SubmitAcpPermissionResponseRequest, }; use serde::{Deserialize, Serialize}; +use std::time::Instant; use tauri::{AppHandle, Emitter, State}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -88,12 +90,21 @@ pub struct ProbeAcpClientRequirementsRequest { } #[tauri::command] -pub async fn initialize_acp_clients(state: State<'_, AppState>) -> Result<(), String> { - let service = state - .acp_client_service - .as_ref() - .ok_or_else(|| "ACP client service not initialized".to_string())?; - service.initialize_all().await.map_err(|e| e.to_string()) +pub async fn initialize_acp_clients( + state: State<'_, AppState>, + startup_trace: State<'_, DesktopStartupTrace>, +) -> Result<(), String> { + let trace_started = Instant::now(); + let result = async { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service.initialize_all().await.map_err(|e| e.to_string()) + } + .await; + startup_trace.record_tauri_command_elapsed("initialize_acp_clients", None, trace_started); + result } #[tauri::command] @@ -108,19 +119,30 @@ pub async fn get_acp_clients(state: State<'_, AppState>) -> Result, + startup_trace: State<'_, DesktopStartupTrace>, request: ProbeAcpClientRequirementsRequest, ) -> Result, String> { - let service = state - .acp_client_service - .as_ref() - .ok_or_else(|| "ACP client service not initialized".to_string())?; - service - .probe_client_requirements( - request.remote_connection_id.as_deref(), - request.force_refresh, - ) - .await - .map_err(|e| e.to_string()) + let trace_started = Instant::now(); + let result = async { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + service + .probe_client_requirements( + request.remote_connection_id.as_deref(), + request.force_refresh, + ) + .await + .map_err(|e| e.to_string()) + } + .await; + startup_trace.record_tauri_command_elapsed( + "probe_acp_client_requirements", + None, + trace_started, + ); + result } #[tauri::command] diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 11ded7164..f1425797e 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -4,10 +4,12 @@ use log::{debug, warn}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; use std::sync::Arc; +use std::time::Instant; use tauri::{AppHandle, State}; use crate::api::app_state::AppState; use crate::api::session_storage_path::desktop_effective_session_storage_path; +use crate::startup_trace::DesktopStartupTrace; use bitfun_core::agentic::coordination::{ AssistantBootstrapBlockReason, AssistantBootstrapEnsureOutcome, AssistantBootstrapSkipReason, ConversationCoordinator, DialogScheduler, DialogSubmissionPolicy, DialogTriggerSource, @@ -20,6 +22,7 @@ use bitfun_core::agentic::deep_review_policy::{ }; use bitfun_core::agentic::goal_mode::{ThreadGoal, ThreadGoalStatus}; use bitfun_core::agentic::image_analysis::ImageContextData; +use bitfun_core::agentic::session::SessionViewRestoreTiming; use bitfun_core::agentic::tools::image_context::get_image_context; use bitfun_core::service::session::{DialogTurnData, SessionRelationship}; @@ -278,6 +281,7 @@ pub struct RestoreSessionViewResponse { pub is_partial: bool, pub loaded_turn_count: usize, pub total_turn_count: usize, + pub timings: SessionViewRestoreTiming, } #[derive(Debug, Default)] @@ -995,30 +999,67 @@ async fn ensure_session_for_thread_goal( .ok_or_else(|| format!("Session workspace_path is missing: {session_id}")) } +async fn resolve_session_workspace_path_for_thread_goal_read( + coordinator: &Arc, + app_state: &AppState, + session_id: &str, + workspace_path: Option<&str>, + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, +) -> Result { + if let Some(workspace_path) = coordinator + .get_session_manager() + .get_session(session_id) + .and_then(|session| session.config.workspace_path.clone()) + { + return Ok(PathBuf::from(workspace_path)); + } + + let workspace_path = workspace_path + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "workspace_path is required when the session is not loaded".to_string())?; + + Ok(desktop_effective_session_storage_path( + app_state, + workspace_path, + remote_connection_id, + remote_ssh_host, + ) + .await) +} + #[tauri::command] pub async fn get_session_thread_goal( coordinator: State<'_, Arc>, app_state: State<'_, AppState>, + startup_trace: State<'_, DesktopStartupTrace>, request: GetSessionThreadGoalRequest, ) -> Result { - let session_id = request.session_id.trim(); - if session_id.is_empty() { - return Err("session_id is required".to_string()); + let trace_started = Instant::now(); + let result = async { + let session_id = request.session_id.trim(); + if session_id.is_empty() { + return Err("session_id is required".to_string()); + } + let workspace_path = resolve_session_workspace_path_for_thread_goal_read( + coordinator.inner(), + app_state.inner(), + session_id, + request.workspace_path.as_deref(), + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await?; + let goal = coordinator + .get_thread_goal(session_id, workspace_path.as_path()) + .await + .map_err(|error| error.to_string())?; + Ok(GetSessionThreadGoalResponse { goal }) } - let workspace_path = ensure_session_for_thread_goal( - coordinator.inner(), - app_state.inner(), - session_id, - request.workspace_path.as_deref(), - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - ) - .await?; - let goal = coordinator - .get_thread_goal(session_id, workspace_path.as_path()) - .await - .map_err(|error| error.to_string())?; - Ok(GetSessionThreadGoalResponse { goal }) + .await; + startup_trace.record_tauri_command_elapsed("get_session_thread_goal", None, trace_started); + result } #[tauri::command] @@ -1505,108 +1546,120 @@ pub async fn restore_session( pub async fn restore_session_view( coordinator: State<'_, Arc>, app_state: State<'_, AppState>, + startup_trace: State<'_, DesktopStartupTrace>, request: RestoreSessionRequest, ) -> Result { - let started_at = std::time::Instant::now(); - let trace_id = request.trace_id.as_deref().unwrap_or("none"); - debug!( - "restore_session_view request received: trace_id={}, session_id={}", - trace_id, request.session_id - ); - let path_started_at = std::time::Instant::now(); - let effective_path = desktop_effective_session_storage_path( - &app_state, - &request.workspace_path, - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - ) + let started_at = Instant::now(); + let result = async { + let trace_id = request.trace_id.as_deref().unwrap_or("none"); + debug!( + "restore_session_view request received: trace_id={}, session_id={}", + trace_id, request.session_id + ); + let path_started_at = Instant::now(); + let effective_path = desktop_effective_session_storage_path( + &app_state, + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) .await; - debug!( - "restore_session_view storage path resolved: trace_id={}, session_id={}, duration_ms={}", - trace_id, - request.session_id, - path_started_at.elapsed().as_millis() - ); - - let tail_turn_count = request.tail_turn_count.filter(|count| *count > 0); - let (session, mut turns, total_turn_count) = if let Some(tail_turn_count) = tail_turn_count { - let tail_turn_count = tail_turn_count.min(16); - if request.include_internal { - coordinator - .restore_internal_session_view_tail( - &effective_path, - &request.session_id, - tail_turn_count, - ) - .await - } else { - coordinator - .restore_session_view_tail(&effective_path, &request.session_id, tail_turn_count) - .await + debug!( + "restore_session_view storage path resolved: trace_id={}, session_id={}, duration_ms={}", + trace_id, + request.session_id, + path_started_at.elapsed().as_millis() + ); + + let tail_turn_count = request.tail_turn_count.filter(|count| *count > 0); + let (session, mut turns, total_turn_count, timings) = + if let Some(tail_turn_count) = tail_turn_count { + let tail_turn_count = tail_turn_count.min(16); + if request.include_internal { + coordinator + .restore_internal_session_view_tail_timed( + &effective_path, + &request.session_id, + tail_turn_count, + ) + .await + } else { + coordinator + .restore_session_view_tail_timed( + &effective_path, + &request.session_id, + tail_turn_count, + ) + .await + } + } else if request.include_internal { + coordinator + .restore_internal_session_view_timed(&effective_path, &request.session_id) + .await + .map(|(session, turns, timings)| { + let total_turn_count = turns.len(); + (session, turns, total_turn_count, timings) + }) + } else { + coordinator + .restore_session_view_timed(&effective_path, &request.session_id) + .await + .map(|(session, turns, timings)| { + let total_turn_count = turns.len(); + (session, turns, total_turn_count, timings) + }) + } + .map_err(|e| format!("Failed to restore session view: {}", e))?; + let loaded_turn_count = turns.len(); + let is_partial = loaded_turn_count < total_turn_count; + + if log::log_enabled!(log::Level::Debug) { + let payload_stats = restore_turn_payload_stats(&turns); + if payload_stats.raw_result_string_chars >= 1024 * 1024 + || payload_stats.result_for_assistant_chars >= 1024 * 1024 + { + debug!( + "restore_session_view payload diagnostics: trace_id={}, session_id={}, turn_count={}, total_turn_count={}, is_partial={}, tool_result_count={}, raw_result_string_chars={}, result_for_assistant_chars={}, largest_raw_result_chars={}, largest_raw_result_path={}, top_raw_results={}", + trace_id, + request.session_id, + turns.len(), + total_turn_count, + is_partial, + payload_stats.tool_result_count, + payload_stats.raw_result_string_chars, + payload_stats.result_for_assistant_chars, + payload_stats.largest_raw_result_chars, + payload_stats.largest_raw_result_path, + format_top_raw_results(&payload_stats.top_raw_results) + ); + } } - } else if request.include_internal { - coordinator - .restore_internal_session_view(&effective_path, &request.session_id) - .await - .map(|(session, turns)| { - let total_turn_count = turns.len(); - (session, turns, total_turn_count) - }) - } else { - coordinator - .restore_session_view(&effective_path, &request.session_id) - .await - .map(|(session, turns)| { - let total_turn_count = turns.len(); - (session, turns, total_turn_count) - }) - } - .map_err(|e| format!("Failed to restore session view: {}", e))?; - let loaded_turn_count = turns.len(); - let is_partial = loaded_turn_count < total_turn_count; - if log::log_enabled!(log::Level::Debug) { - let payload_stats = restore_turn_payload_stats(&turns); - if payload_stats.raw_result_string_chars >= 1024 * 1024 - || payload_stats.result_for_assistant_chars >= 1024 * 1024 - { - debug!( - "restore_session_view payload diagnostics: trace_id={}, session_id={}, turn_count={}, total_turn_count={}, is_partial={}, tool_result_count={}, raw_result_string_chars={}, result_for_assistant_chars={}, largest_raw_result_chars={}, largest_raw_result_path={}, top_raw_results={}", - trace_id, - request.session_id, - turns.len(), - total_turn_count, - is_partial, - payload_stats.tool_result_count, - payload_stats.raw_result_string_chars, - payload_stats.result_for_assistant_chars, - payload_stats.largest_raw_result_chars, - payload_stats.largest_raw_result_path, - format_top_raw_results(&payload_stats.top_raw_results) - ); - } + compact_tool_results_for_session_view(&mut turns); + + debug!( + "restore_session_view completed: trace_id={}, session_id={}, turn_count={}, total_turn_count={}, is_partial={}, context_restore_state=pending, duration_ms={}", + trace_id, + request.session_id, + turns.len(), + total_turn_count, + is_partial, + started_at.elapsed().as_millis() + ); + + Ok(RestoreSessionViewResponse { + session: session_to_response_with_turn_count(session, total_turn_count), + turns, + context_restore_state: "pending".to_string(), + is_partial, + loaded_turn_count, + total_turn_count, + timings, + }) } - - compact_tool_results_for_session_view(&mut turns); - - debug!( - "restore_session_view completed: trace_id={}, session_id={}, turn_count={}, total_turn_count={}, is_partial={}, context_restore_state=pending, duration_ms={}", - trace_id, - request.session_id, - turns.len(), - total_turn_count, - is_partial, - started_at.elapsed().as_millis() - ); - - Ok(RestoreSessionViewResponse { - session: session_to_response(session), - turns, - context_restore_state: "pending".to_string(), - is_partial, - loaded_turn_count, - total_turn_count, - }) + .await; + startup_trace.record_tauri_command_elapsed("restore_session_view", None, started_at); + result } #[tauri::command] @@ -1757,7 +1810,11 @@ pub async fn generate_session_title( } #[tauri::command] -pub async fn get_available_modes(state: State<'_, AppState>) -> Result, String> { +pub async fn get_available_modes( + state: State<'_, AppState>, + startup_trace: State<'_, DesktopStartupTrace>, +) -> Result, String> { + let trace_started = Instant::now(); let mode_infos = state.agent_registry.get_modes_info().await; let dtos: Vec = mode_infos @@ -1782,6 +1839,7 @@ pub async fn get_available_modes(state: State<'_, AppState>) -> Result SessionResponse { + let turn_count = session.dialog_turn_ids.len(); + session_to_response_with_turn_count(session, turn_count) +} + +fn session_to_response_with_turn_count(session: Session, turn_count: usize) -> SessionResponse { SessionResponse { session_id: session.session_id, session_name: session.session_name, @@ -1866,7 +1929,7 @@ fn session_to_response(session: Session) -> SessionResponse { last_user_dialog_agent_type: session.last_user_dialog_agent_type, last_submitted_agent_type: session.last_submitted_agent_type, state: format!("{:?}", session.state), - turn_count: session.dialog_turn_ids.len(), + turn_count, created_at: system_time_to_unix_secs(session.created_at), } } @@ -1988,6 +2051,21 @@ mod tests { assert!(!stats.top_raw_results[0].path.contains(&"x".repeat(20))); } + #[test] + fn session_view_response_can_report_total_turn_count_for_tail_view() { + let mut session = Session::new_with_id( + "session-1".to_string(), + "Tail view".to_string(), + "agentic".to_string(), + SessionConfig::default(), + ); + session.dialog_turn_ids = vec!["turn-49".to_string(), "turn-50".to_string()]; + + let response = session_to_response_with_turn_count(session, 50); + + assert_eq!(response.turn_count, 50); + } + #[test] fn omit_assistant_only_tool_results_preserves_visible_results() { let mut turns = vec![DialogTurnData { diff --git a/src/apps/desktop/src/api/git_api.rs b/src/apps/desktop/src/api/git_api.rs index ed7c6a239..81b11baa7 100644 --- a/src/apps/desktop/src/api/git_api.rs +++ b/src/apps/desktop/src/api/git_api.rs @@ -1,6 +1,7 @@ //! Git API use crate::api::app_state::AppState; +use crate::startup_trace::DesktopStartupTrace; use bitfun_core::infrastructure::storage::StorageOptions; use bitfun_core::service::git::{ build_git_changed_files_args, build_git_diff_args, GitAddParams, GitChangedFile, @@ -11,8 +12,9 @@ use bitfun_core::service::git::{ GitBranch, GitCommit, GitOperationResult, GitRepository, GitStatus, }; use bitfun_core::service::remote_ssh::{lookup_remote_connection, normalize_remote_workspace_path}; -use log::{debug, error, info}; +use log::{error, info}; use serde::{Deserialize, Serialize}; +use std::time::Instant; use tauri::State; #[derive(Debug, Clone)] @@ -503,27 +505,32 @@ pub struct GitRemoveWorktreeRequest { #[tauri::command] pub async fn git_is_repository( state: State<'_, AppState>, + startup_trace: State<'_, DesktopStartupTrace>, request: GitRepositoryRequest, ) -> Result { - if let Some(target) = resolve_remote_git_target(&request.repository_path).await { - let output = execute_remote_git_command( + let trace_started = Instant::now(); + let result = if let Some(target) = resolve_remote_git_target(&request.repository_path).await { + execute_remote_git_command( &state, &target, &["rev-parse".to_string(), "--is-inside-work-tree".to_string()], ) - .await?; - return Ok(output.exit_code == 0 && output.stdout.trim() == "true"); - } - - GitService::is_repository(&request.repository_path) .await - .map_err(|e| { - error!( - "Failed to check Git repository: path={}, error={}", - request.repository_path, e - ); - format!("Failed to check Git repository: {}", e) - }) + .map(|output| output.exit_code == 0 && output.stdout.trim() == "true") + } else { + GitService::is_repository(&request.repository_path) + .await + .map_err(|e| { + error!( + "Failed to check Git repository: path={}, error={}", + request.repository_path, e + ); + format!("Failed to check Git repository: {}", e) + }) + }; + + startup_trace.record_tauri_command_elapsed("git_is_repository", None, trace_started); + result } #[tauri::command] @@ -591,60 +598,57 @@ pub async fn git_get_repository( #[tauri::command] pub async fn git_get_repository_basic( state: State<'_, AppState>, + startup_trace: State<'_, DesktopStartupTrace>, request: GitRepositoryRequest, ) -> Result { - let started_at = std::time::Instant::now(); - let result = if let Some(target) = resolve_remote_git_target(&request.repository_path).await { - let current_branch = execute_remote_git_success( - &state, - &target, - &["branch".to_string(), "--show-current".to_string()], - ) - .await - .map(|s| { - let branch = s.trim(); - if branch.is_empty() { - "HEAD".to_string() - } else { - branch.to_string() - } - })?; + let trace_started = Instant::now(); + let result = async { + if let Some(target) = resolve_remote_git_target(&request.repository_path).await { + let current_branch = execute_remote_git_success( + &state, + &target, + &["branch".to_string(), "--show-current".to_string()], + ) + .await + .map(|s| { + let branch = s.trim(); + if branch.is_empty() { + "HEAD".to_string() + } else { + branch.to_string() + } + })?; - let name = target - .repository_path - .rsplit('/') - .find(|part| !part.is_empty()) - .unwrap_or("/") - .to_string(); + let name = target + .repository_path + .rsplit('/') + .find(|part| !part.is_empty()) + .unwrap_or("/") + .to_string(); - Ok(GitRepository { - path: target.repository_path, - name, - current_branch, - is_bare: false, - has_changes: false, - remotes: Vec::new(), - }) - } else { - GitService::get_repository_basic(&request.repository_path) - .await - .map_err(|e| { - error!( - "Failed to get basic Git repository info: path={}, error={}", - request.repository_path, e - ); - format!("Failed to get basic Git repository info: {}", e) + Ok(GitRepository { + path: target.repository_path, + name, + current_branch, + is_bare: false, + has_changes: false, + remotes: Vec::new(), }) - }; - - let duration_ms = started_at.elapsed().as_millis(); - if duration_ms >= 80 { - debug!( - "Git basic repository lookup completed: path={}, duration_ms={}", - request.repository_path, duration_ms - ); + } else { + GitService::get_repository_basic(&request.repository_path) + .await + .map_err(|e| { + error!( + "Failed to get basic Git repository info: path={}, error={}", + request.repository_path, e + ); + format!("Failed to get basic Git repository info: {}", e) + }) + } } + .await; + startup_trace.record_tauri_command_elapsed("git_get_repository_basic", None, trace_started); result } diff --git a/src/apps/desktop/src/api/mcp_api.rs b/src/apps/desktop/src/api/mcp_api.rs index 8d8af547f..b0cb9a777 100644 --- a/src/apps/desktop/src/api/mcp_api.rs +++ b/src/apps/desktop/src/api/mcp_api.rs @@ -1,6 +1,7 @@ //! MCP API use crate::api::app_state::AppState; +use crate::startup_trace::DesktopStartupTrace; use bitfun_core::service::mcp::auth::{ has_stored_oauth_credentials, MCPRemoteOAuthSessionSnapshot, }; @@ -12,6 +13,7 @@ use bitfun_core::service::mcp::MCPServerType; use bitfun_core::service::runtime::{RuntimeManager, RuntimeSource}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use std::time::Instant; use tauri::State; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -120,37 +122,57 @@ async fn load_mcp_prompts( } #[tauri::command] -pub async fn initialize_mcp_servers(state: State<'_, AppState>) -> Result<(), String> { - let mcp_service = state - .mcp_service - .as_ref() - .ok_or_else(|| "MCP service not initialized".to_string())?; - - mcp_service - .server_manager() - .initialize_all() - .await - .map_err(|e| e.to_string())?; +pub async fn initialize_mcp_servers( + state: State<'_, AppState>, + startup_trace: State<'_, DesktopStartupTrace>, +) -> Result<(), String> { + let trace_started = Instant::now(); + let result = async { + let mcp_service = state + .mcp_service + .as_ref() + .ok_or_else(|| "MCP service not initialized".to_string())?; + + mcp_service + .server_manager() + .initialize_all() + .await + .map_err(|e| e.to_string())?; - Ok(()) + Ok(()) + } + .await; + startup_trace.record_tauri_command_elapsed("initialize_mcp_servers", None, trace_started); + result } #[tauri::command] pub async fn initialize_mcp_servers_non_destructive( state: State<'_, AppState>, + startup_trace: State<'_, DesktopStartupTrace>, ) -> Result<(), String> { - let mcp_service = state - .mcp_service - .as_ref() - .ok_or_else(|| "MCP service not initialized".to_string())?; - - mcp_service - .server_manager() - .initialize_non_destructive() - .await - .map_err(|e| e.to_string())?; + let trace_started = Instant::now(); + let result = async { + let mcp_service = state + .mcp_service + .as_ref() + .ok_or_else(|| "MCP service not initialized".to_string())?; + + mcp_service + .server_manager() + .initialize_non_destructive() + .await + .map_err(|e| e.to_string())?; - Ok(()) + Ok(()) + } + .await; + startup_trace.record_tauri_command_elapsed( + "initialize_mcp_servers_non_destructive", + None, + trace_started, + ); + result } #[tauri::command] diff --git a/src/apps/desktop/src/api/miniapp_api.rs b/src/apps/desktop/src/api/miniapp_api.rs index 2a65fb2d2..322379b96 100644 --- a/src/apps/desktop/src/api/miniapp_api.rs +++ b/src/apps/desktop/src/api/miniapp_api.rs @@ -1,6 +1,7 @@ //! MiniApp API — Tauri commands for MiniApp CRUD, JS Worker, and dialog. use crate::api::app_state::AppState; +use crate::startup_trace::DesktopStartupTrace; use bitfun_core::infrastructure::events::{emit_global_event, BackendEvent}; use bitfun_core::miniapp::{ dispatch_host, is_host_primitive, InstallResult as CoreInstallResult, MiniApp, @@ -16,7 +17,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; use tauri::{AppHandle, Emitter, State}; // ============== Request/Response DTOs ============== @@ -352,12 +353,18 @@ async fn ensure_worker_dependencies( // ============== App management commands ============== #[tauri::command] -pub async fn list_miniapps(state: State<'_, AppState>) -> Result, String> { - state +pub async fn list_miniapps( + state: State<'_, AppState>, + startup_trace: State<'_, DesktopStartupTrace>, +) -> Result, String> { + let trace_started = Instant::now(); + let result = state .miniapp_manager .list() .await - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string()); + startup_trace.record_tauri_command_elapsed("list_miniapps", None, trace_started); + result } #[tauri::command] @@ -680,11 +687,16 @@ pub async fn miniapp_worker_stop(state: State<'_, AppState>, app_id: String) -> #[tauri::command] pub async fn miniapp_worker_list_running( state: State<'_, AppState>, + startup_trace: State<'_, DesktopStartupTrace>, ) -> Result, String> { - let Some(ref pool) = state.js_worker_pool else { - return Ok(vec![]); + let trace_started = Instant::now(); + let result = if let Some(ref pool) = state.js_worker_pool { + Ok(pool.list_running().await) + } else { + Ok(vec![]) }; - Ok(pool.list_running().await) + startup_trace.record_tauri_command_elapsed("miniapp_worker_list_running", None, trace_started); + result } #[tauri::command] diff --git a/src/apps/desktop/src/api/session_api.rs b/src/apps/desktop/src/api/session_api.rs index 2065220cb..db091f957 100644 --- a/src/apps/desktop/src/api/session_api.rs +++ b/src/apps/desktop/src/api/session_api.rs @@ -2,6 +2,7 @@ use crate::api::app_state::AppState; use crate::api::session_storage_path::desktop_effective_session_storage_path; +use crate::startup_trace::DesktopStartupTrace; use bitfun_core::agentic::persistence::{ PersistenceManager, SessionBranchRequest, SessionBranchResult, SessionMetadataPage, }; @@ -15,6 +16,7 @@ use bitfun_core::service::session_usage::{ }; use serde::{Deserialize, Serialize}; use std::sync::Arc; +use std::time::Instant; use tauri::State; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -210,21 +212,28 @@ pub async fn list_persisted_sessions_page( request: ListPersistedSessionsPageRequest, app_state: State<'_, AppState>, path_manager: State<'_, Arc>, + startup_trace: State<'_, DesktopStartupTrace>, ) -> Result { - let workspace_path = desktop_effective_session_storage_path( - &app_state, - &request.workspace_path, - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - ) - .await; - let manager = PersistenceManager::new(path_manager.inner().clone()) - .map_err(|e| format!("Failed to create persistence manager: {}", e))?; + let trace_started = Instant::now(); + let result = async { + let workspace_path = desktop_effective_session_storage_path( + &app_state, + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await; + let manager = PersistenceManager::new(path_manager.inner().clone()) + .map_err(|e| format!("Failed to create persistence manager: {}", e))?; - manager - .list_session_metadata_page(&workspace_path, request.cursor.as_deref(), request.limit) - .await - .map_err(|e| format!("Failed to list persisted session page: {}", e)) + manager + .list_session_metadata_page(&workspace_path, request.cursor.as_deref(), request.limit) + .await + .map_err(|e| format!("Failed to list persisted session page: {}", e)) + } + .await; + startup_trace.record_tauri_command_elapsed("list_persisted_sessions_page", None, trace_started); + result } #[tauri::command] @@ -232,28 +241,44 @@ pub async fn load_session_turns( request: LoadSessionTurnsRequest, app_state: State<'_, AppState>, path_manager: State<'_, Arc>, + startup_trace: State<'_, DesktopStartupTrace>, ) -> Result, String> { - let workspace_path = desktop_effective_session_storage_path( - &app_state, - &request.workspace_path, - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - ) - .await; - let manager = PersistenceManager::new(path_manager.inner().clone()) - .map_err(|e| format!("Failed to create persistence manager: {}", e))?; - - let turns = if let Some(limit) = request.limit { - manager - .load_recent_turns(&workspace_path, &request.session_id, limit) - .await + let trace_started = Instant::now(); + let trace_target = if request.limit.is_some() { + "recent" } else { - manager - .load_session_turns(&workspace_path, &request.session_id) - .await + "full" }; + let result = async { + let workspace_path = desktop_effective_session_storage_path( + &app_state, + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await; + let manager = PersistenceManager::new(path_manager.inner().clone()) + .map_err(|e| format!("Failed to create persistence manager: {}", e))?; - turns.map_err(|e| format!("Failed to load session turns: {}", e)) + let turns = if let Some(limit) = request.limit { + manager + .load_recent_turns(&workspace_path, &request.session_id, limit) + .await + } else { + manager + .load_session_turns(&workspace_path, &request.session_id) + .await + }; + + turns.map_err(|e| format!("Failed to load session turns: {}", e)) + } + .await; + startup_trace.record_tauri_command_elapsed( + "load_session_turns", + Some(trace_target), + trace_started, + ); + result } #[tauri::command] @@ -395,21 +420,28 @@ pub async fn touch_session_activity( request: TouchSessionActivityRequest, app_state: State<'_, AppState>, path_manager: State<'_, Arc>, + startup_trace: State<'_, DesktopStartupTrace>, ) -> Result<(), String> { - let workspace_path = desktop_effective_session_storage_path( - &app_state, - &request.workspace_path, - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - ) - .await; - let manager = PersistenceManager::new(path_manager.inner().clone()) - .map_err(|e| format!("Failed to create persistence manager: {}", e))?; + let trace_started = Instant::now(); + let result = async { + let workspace_path = desktop_effective_session_storage_path( + &app_state, + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await; + let manager = PersistenceManager::new(path_manager.inner().clone()) + .map_err(|e| format!("Failed to create persistence manager: {}", e))?; - manager - .touch_session(&workspace_path, &request.session_id) - .await - .map_err(|e| format!("Failed to update session activity: {}", e)) + manager + .touch_session(&workspace_path, &request.session_id) + .await + .map_err(|e| format!("Failed to update session activity: {}", e)) + } + .await; + startup_trace.record_tauri_command_elapsed("touch_session_activity", None, trace_started); + result } #[tauri::command] @@ -417,25 +449,36 @@ pub async fn load_persisted_session_metadata( request: LoadPersistedSessionMetadataRequest, app_state: State<'_, AppState>, path_manager: State<'_, Arc>, + startup_trace: State<'_, DesktopStartupTrace>, ) -> Result, String> { - let workspace_path = desktop_effective_session_storage_path( - &app_state, - &request.workspace_path, - request.remote_connection_id.as_deref(), - request.remote_ssh_host.as_deref(), - ) - .await; - let manager = PersistenceManager::new(path_manager.inner().clone()) - .map_err(|e| format!("Failed to create persistence manager: {}", e))?; + let trace_started = Instant::now(); + let result = async { + let workspace_path = desktop_effective_session_storage_path( + &app_state, + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await; + let manager = PersistenceManager::new(path_manager.inner().clone()) + .map_err(|e| format!("Failed to create persistence manager: {}", e))?; - let metadata = manager - .load_session_metadata(&workspace_path, &request.session_id) - .await - .map_err(|e| format!("Failed to load persisted session metadata: {}", e))?; + let metadata = manager + .load_session_metadata(&workspace_path, &request.session_id) + .await + .map_err(|e| format!("Failed to load persisted session metadata: {}", e))?; - // Direct metadata lookups are used by persistence flows that must be able - // to read hidden subagent sessions without list-level visibility filtering. - Ok(metadata) + // Direct metadata lookups are used by persistence flows that must be able + // to read hidden subagent sessions without list-level visibility filtering. + Ok(metadata) + } + .await; + startup_trace.record_tauri_command_elapsed( + "load_persisted_session_metadata", + None, + trace_started, + ); + result } #[tauri::command] diff --git a/src/apps/desktop/src/theme.rs b/src/apps/desktop/src/theme.rs index 443be4952..bcafd437b 100644 --- a/src/apps/desktop/src/theme.rs +++ b/src/apps/desktop/src/theme.rs @@ -102,6 +102,7 @@ fn clamp_agent_companion_window_position( #[derive(Debug, Clone)] pub struct ThemeConfig { pub id: String, + pub selection_id: Option, pub bg_primary: String, pub bg_secondary: String, pub bg_scene: String, @@ -113,8 +114,9 @@ pub struct ThemeConfig { impl Default for ThemeConfig { fn default() -> Self { - Self::get_builtin_theme("bitfun-light").unwrap_or_else(|| Self { + let mut theme = Self::get_builtin_theme("bitfun-light").unwrap_or_else(|| Self { id: "bitfun-light".to_string(), + selection_id: None, bg_primary: "#f3f3f5".to_string(), bg_secondary: "#ffffff".to_string(), bg_scene: "#ffffff".to_string(), @@ -122,7 +124,9 @@ impl Default for ThemeConfig { text_primary: "#1e293b".to_string(), text_muted: "#64748b".to_string(), accent_color: "#64748b".to_string(), - }) + }); + theme.selection_id = None; + theme } } @@ -131,6 +135,7 @@ impl ThemeConfig { match theme_id { "bitfun-slate" => Some(Self { id: theme_id.to_string(), + selection_id: Some(theme_id.to_string()), bg_primary: "#1a1c1e".to_string(), bg_secondary: "#1a1c1e".to_string(), bg_scene: "#1d2023".to_string(), @@ -141,6 +146,7 @@ impl ThemeConfig { }), "bitfun-dark" => Some(Self { id: theme_id.to_string(), + selection_id: Some(theme_id.to_string()), bg_primary: "#121214".to_string(), bg_secondary: "#18181a".to_string(), bg_scene: "#16161a".to_string(), @@ -151,6 +157,7 @@ impl ThemeConfig { }), "bitfun-midnight" => Some(Self { id: theme_id.to_string(), + selection_id: Some(theme_id.to_string()), bg_primary: "#2b2d30".to_string(), bg_secondary: "#1e1f22".to_string(), bg_scene: "#27292c".to_string(), @@ -161,6 +168,7 @@ impl ThemeConfig { }), "bitfun-cyber" => Some(Self { id: theme_id.to_string(), + selection_id: Some(theme_id.to_string()), bg_primary: "#101010".to_string(), bg_secondary: "#151515".to_string(), bg_scene: "#141414".to_string(), @@ -171,6 +179,7 @@ impl ThemeConfig { }), "bitfun-tokyo-night" => Some(Self { id: theme_id.to_string(), + selection_id: Some(theme_id.to_string()), bg_primary: "#1a1b26".to_string(), bg_secondary: "#16161e".to_string(), bg_scene: "#1a1b26".to_string(), @@ -181,6 +190,7 @@ impl ThemeConfig { }), "bitfun-china-night" => Some(Self { id: theme_id.to_string(), + selection_id: Some(theme_id.to_string()), bg_primary: "#1a1814".to_string(), bg_secondary: "#141210".to_string(), bg_scene: "#1e1c17".to_string(), @@ -191,6 +201,7 @@ impl ThemeConfig { }), "bitfun-light" => Some(Self { id: theme_id.to_string(), + selection_id: Some(theme_id.to_string()), bg_primary: "#f3f3f5".to_string(), bg_secondary: "#ffffff".to_string(), bg_scene: "#ffffff".to_string(), @@ -201,6 +212,7 @@ impl ThemeConfig { }), "bitfun-china-style" => Some(Self { id: theme_id.to_string(), + selection_id: Some(theme_id.to_string()), bg_primary: "#faf8f0".to_string(), bg_secondary: "#f5f3e8".to_string(), bg_scene: "#fdfcf6".to_string(), @@ -254,7 +266,10 @@ impl ThemeConfig { let resolved_id = Self::resolve_builtin_theme_id(theme_id); match Self::get_builtin_theme(resolved_id) { - Some(config) => config, + Some(mut config) => { + config.selection_id = Some(theme_id.to_string()); + config + } None => { warn!("Unknown theme ID: {}, using default theme", theme_id); default @@ -334,18 +349,32 @@ impl ThemeConfig { let show_startup_window_controls = !cfg!(target_os = "macos"); let startup_trace_id_json = serde_json::to_string(startup_trace_id) .unwrap_or_else(|_| "\"desktop-unknown\"".to_string()); + let bootstrap_log_level_json = serde_json::to_string(crate::logging::level_to_str( + crate::logging::current_runtime_log_level(), + )) + .unwrap_or_else(|_| "\"warn\"".to_string()); let perf_trace_enabled = cfg!(debug_assertions) || ((cfg!(feature = "devtools") || std::env::var_os("BITFUN_PERF_TRACE").is_some()) && std::env::var_os("BITFUN_WEBDRIVER_PORT").is_some()); + let bootstrap_theme_id_json = + serde_json::to_string(&self.id).unwrap_or_else(|_| "\"bitfun-light\"".to_string()); + let bootstrap_theme_selection_json = self + .selection_id + .as_ref() + .and_then(|selection| serde_json::to_string(selection).ok()) + .unwrap_or_else(|| "null".to_string()); format!( r#" (function() {{ window.__BITFUN_STARTUP_TRACE_ID__ = {startup_trace_id_json}; window.__BITFUN_PERF_TRACE_ENABLED__ = {perf_trace_enabled}; + window.__BITFUN_BOOTSTRAP_LOG_LEVEL__ = {bootstrap_log_level_json}; window.__BITFUN_BOOTSTRAP_LOCALE__ = {startup_locale_json}; window.__BITFUN_BOOTSTRAP_MESSAGES__ = {startup_messages_json}; window.__BITFUN_SHOW_STARTUP_WINDOW_CONTROLS__ = {show_startup_window_controls}; + window.__BITFUN_BOOTSTRAP_THEME_ID__ = {bootstrap_theme_id_json}; + window.__BITFUN_BOOTSTRAP_THEME_SELECTION__ = {bootstrap_theme_selection_json}; function applyTheme() {{ var root = document.documentElement; if (!root) return false; @@ -390,6 +419,7 @@ impl ThemeConfig { text_primary = self.text_primary, startup_trace_id_json = startup_trace_id_json, perf_trace_enabled = perf_trace_enabled, + bootstrap_log_level_json = bootstrap_log_level_json, startup_locale_json = startup_locale_json, startup_messages_json = startup_messages_json, show_startup_window_controls = show_startup_window_controls, diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index 3ace704bd..2886246f3 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -3728,6 +3728,20 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await } + pub async fn restore_session_view_timed( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<( + Session, + Vec, + crate::agentic::session::session_manager::SessionViewRestoreTiming, + )> { + self.session_manager + .restore_session_view_timed(workspace_path, session_id) + .await + } + pub async fn restore_session_view_tail( &self, workspace_path: &Path, @@ -3739,6 +3753,22 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await } + pub async fn restore_session_view_tail_timed( + &self, + workspace_path: &Path, + session_id: &str, + tail_turn_count: usize, + ) -> BitFunResult<( + Session, + Vec, + usize, + crate::agentic::session::session_manager::SessionViewRestoreTiming, + )> { + self.session_manager + .restore_session_view_tail_timed(workspace_path, session_id, tail_turn_count) + .await + } + pub async fn restore_internal_session_view( &self, workspace_path: &Path, @@ -3749,6 +3779,20 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await } + pub async fn restore_internal_session_view_timed( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<( + Session, + Vec, + crate::agentic::session::session_manager::SessionViewRestoreTiming, + )> { + self.session_manager + .restore_internal_session_view_timed(workspace_path, session_id) + .await + } + pub async fn restore_internal_session_view_tail( &self, workspace_path: &Path, @@ -3760,6 +3804,22 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await } + pub async fn restore_internal_session_view_tail_timed( + &self, + workspace_path: &Path, + session_id: &str, + tail_turn_count: usize, + ) -> BitFunResult<( + Session, + Vec, + usize, + crate::agentic::session::session_manager::SessionViewRestoreTiming, + )> { + self.session_manager + .restore_internal_session_view_tail_timed(workspace_path, session_id, tail_turn_count) + .await + } + /// List all sessions pub async fn list_sessions(&self, workspace_path: &Path) -> BitFunResult> { self.session_manager.list_sessions(workspace_path).await @@ -4972,10 +5032,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet parent_session_id, child_session.session_id, copied ); self.session_manager - .seed_forked_skill_agent_listing_baselines( - parent_session_id, - &child_session.session_id, - ) + .seed_forked_skill_agent_listing_baselines(parent_session_id, &child_session.session_id) .await; self.session_manager @@ -6034,10 +6091,8 @@ mod tests { #[tokio::test] async fn hidden_btw_session_seeds_forked_listing_baselines() { let (coordinator, session_manager) = test_coordinator(); - let workspace_path = std::env::temp_dir().join(format!( - "bitfun-btw-baseline-test-{}", - uuid::Uuid::new_v4() - )); + let workspace_path = + std::env::temp_dir().join(format!("bitfun-btw-baseline-test-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&workspace_path).expect("workspace dir should exist"); let parent_session = session_manager .create_session( @@ -6053,7 +6108,9 @@ mod tests { session_manager .replace_context_messages( &parent_session.session_id, - vec![crate::agentic::core::Message::user("parent context".to_string())], + vec![crate::agentic::core::Message::user( + "parent context".to_string(), + )], ) .await; @@ -6095,7 +6152,10 @@ mod tests { .await .expect("btw child session should be created"); - assert_eq!(child_session.kind, crate::agentic::core::SessionKind::EphemeralChild); + assert_eq!( + child_session.kind, + crate::agentic::core::SessionKind::EphemeralChild + ); assert_eq!( session_manager .cached_system_prompt(&child_session.session_id, &system_prompt_identity) diff --git a/src/crates/core/src/agentic/persistence/manager.rs b/src/crates/core/src/agentic/persistence/manager.rs index edbcf263c..99a01cc48 100644 --- a/src/crates/core/src/agentic/persistence/manager.rs +++ b/src/crates/core/src/agentic/persistence/manager.rs @@ -20,6 +20,8 @@ use crate::service::session::{ }; use crate::service::workspace_runtime::WorkspaceRuntimeService; use crate::util::errors::{BitFunError, BitFunResult}; +use crate::util::timing::elapsed_ms_u64; +use futures::{stream, StreamExt}; use log::{debug, info, warn}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -35,6 +37,7 @@ const TRANSCRIPT_SCHEMA_VERSION: u32 = 1; const JSON_WRITE_MAX_RETRIES: usize = 5; const JSON_WRITE_RETRY_BASE_DELAY_MS: u64 = 30; const SESSION_TRANSCRIPT_PREVIEW_CHAR_LIMIT: usize = 120; +const SESSION_TURN_READ_CONCURRENCY: usize = 4; static JSON_FILE_WRITE_LOCKS: OnceLock>>>> = OnceLock::new(); static SESSION_INDEX_LOCKS: OnceLock>>>> = OnceLock::new(); @@ -48,6 +51,30 @@ struct StoredDialogTurnFile { turn: DialogTurnData, } +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionTurnLoadTiming { + pub requested_tail_turn_count: Option, + pub loaded_turn_count: usize, + pub total_turn_count: usize, + pub turn_file_count: usize, + pub missing_turn_file_count: usize, + pub fast_path: bool, + pub metadata_duration_ms: u64, + pub state_duration_ms: u64, + pub scan_duration_ms: u64, + pub read_duration_ms: u64, + pub max_turn_read_duration_ms: u64, + pub build_session_duration_ms: u64, + pub total_duration_ms: u64, +} + +struct ReadTurnPathsResult { + turns: Vec, + missing_turn_file_count: usize, + max_turn_read_duration_ms: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct StoredSessionStateFile { schema_version: u32, @@ -2228,7 +2255,8 @@ impl PersistenceManager { snapshot: &TurnSkillAgentSnapshot, ) -> BitFunResult<()> { self.ensure_runtime_for_write(workspace_path).await?; - self.ensure_snapshots_dir(workspace_path, session_id).await?; + self.ensure_snapshots_dir(workspace_path, session_id) + .await?; self.write_json_atomic( &self.skill_agent_baseline_override_path(workspace_path, session_id), @@ -2407,19 +2435,85 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult<(Session, Vec)> { + self.load_session_with_turns_timed(workspace_path, session_id) + .await + .map(|(session, turns, _)| (session, turns)) + } + + pub async fn load_session_with_turns_timed( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<(Session, Vec, SessionTurnLoadTiming)> { + let started_at = Instant::now(); + let metadata_started_at = Instant::now(); let metadata = self .load_session_metadata(workspace_path, session_id) .await? .ok_or_else(|| { BitFunError::NotFound(format!("Session metadata not found: {}", session_id)) })?; + let metadata_duration_ms = elapsed_ms_u64(metadata_started_at); + + let state_started_at = Instant::now(); let stored_state = self .load_stored_session_state(workspace_path, session_id) .await?; - let turns = self.load_session_turns(workspace_path, session_id).await?; + let state_duration_ms = elapsed_ms_u64(state_started_at); + + let scan_started_at = Instant::now(); + let indexed_paths = self + .list_indexed_turn_paths(workspace_path, session_id) + .await?; + let scan_duration_ms = elapsed_ms_u64(scan_started_at); + + let read_started_at = Instant::now(); + let turn_file_count = indexed_paths.len(); + let read_result = self.read_turn_paths(indexed_paths).await?; + let read_duration_ms = elapsed_ms_u64(read_started_at); + let missing_turn_file_count = read_result.missing_turn_file_count; + let max_turn_read_duration_ms = read_result.max_turn_read_duration_ms; + let turns = read_result.turns; + + let build_started_at = Instant::now(); let session = Self::build_session_from_persisted_parts(metadata, stored_state, &turns); + let build_session_duration_ms = elapsed_ms_u64(build_started_at); + let total_duration_ms = elapsed_ms_u64(started_at); + + if total_duration_ms >= 80 || turn_file_count >= 50 { + debug!( + "Loaded session turns: session_id={} turn_count={} turn_file_count={} missing_turn_file_count={} metadata_duration_ms={} state_duration_ms={} scan_duration_ms={} read_duration_ms={} max_turn_read_duration_ms={} build_session_duration_ms={} total_duration_ms={}", + session_id, + turns.len(), + turn_file_count, + missing_turn_file_count, + metadata_duration_ms, + state_duration_ms, + scan_duration_ms, + read_duration_ms, + max_turn_read_duration_ms, + build_session_duration_ms, + total_duration_ms + ); + } - Ok((session, turns)) + let timing = SessionTurnLoadTiming { + requested_tail_turn_count: None, + loaded_turn_count: turns.len(), + total_turn_count: turn_file_count, + turn_file_count, + missing_turn_file_count, + fast_path: false, + metadata_duration_ms, + state_duration_ms, + scan_duration_ms, + read_duration_ms, + max_turn_read_duration_ms, + build_session_duration_ms, + total_duration_ms, + }; + + Ok((session, turns, timing)) } pub async fn load_session_with_tail_turns( @@ -2428,25 +2522,127 @@ impl PersistenceManager { session_id: &str, tail_turn_count: usize, ) -> BitFunResult<(Session, Vec, usize)> { + self.load_session_with_tail_turns_timed(workspace_path, session_id, tail_turn_count) + .await + .map(|(session, turns, total_turn_count, _)| (session, turns, total_turn_count)) + } + + pub async fn load_session_with_tail_turns_timed( + &self, + workspace_path: &Path, + session_id: &str, + tail_turn_count: usize, + ) -> BitFunResult<(Session, Vec, usize, SessionTurnLoadTiming)> { + let started_at = Instant::now(); + let metadata_started_at = Instant::now(); let metadata = self .load_session_metadata(workspace_path, session_id) .await? .ok_or_else(|| { BitFunError::NotFound(format!("Session metadata not found: {}", session_id)) })?; + let metadata_duration = metadata_started_at.elapsed(); + + let state_started_at = Instant::now(); let stored_state = self .load_stored_session_state(workspace_path, session_id) .await?; - let indexed_paths = self - .list_indexed_turn_paths(workspace_path, session_id) + let state_duration = state_started_at.elapsed(); + + let fast_path_started_at = Instant::now(); + let fast_path_turns = self + .read_metadata_tail_turns( + workspace_path, + session_id, + metadata.turn_count, + tail_turn_count, + ) .await?; - let total_turn_count = indexed_paths.len(); - let start = indexed_paths.len().saturating_sub(tail_turn_count); - let selected_paths = indexed_paths.into_iter().skip(start).collect::>(); - let turns = self.read_turn_paths(selected_paths).await?; + let fast_path_duration = fast_path_started_at.elapsed(); + + let ( + turns, + total_turn_count, + scan_duration, + read_duration, + fast_path, + missing_turn_file_count, + max_turn_read_duration_ms, + ) = if let Some(turns) = fast_path_turns { + ( + turns.turns, + metadata.turn_count, + Duration::ZERO, + fast_path_duration, + true, + turns.missing_turn_file_count, + turns.max_turn_read_duration_ms, + ) + } else { + let scan_started_at = Instant::now(); + let indexed_paths = self + .list_indexed_turn_paths(workspace_path, session_id) + .await?; + let scan_duration = scan_started_at.elapsed(); + let total_turn_count = indexed_paths.len(); + let start = indexed_paths.len().saturating_sub(tail_turn_count); + let selected_paths = indexed_paths.into_iter().skip(start).collect::>(); + + let read_started_at = Instant::now(); + let read_result = self.read_turn_paths(selected_paths).await?; + let read_duration = read_started_at.elapsed(); + + ( + read_result.turns, + total_turn_count, + scan_duration, + read_duration, + false, + read_result.missing_turn_file_count, + read_result.max_turn_read_duration_ms, + ) + }; + let build_started_at = Instant::now(); let session = Self::build_session_from_persisted_parts(metadata, stored_state, &turns); + let build_session_duration_ms = elapsed_ms_u64(build_started_at); + let total_duration = started_at.elapsed(); + + if total_duration >= Duration::from_millis(40) || total_turn_count >= 50 { + debug!( + "Loaded session tail view: session_id={} turn_count={} requested_count={} total_turn_count={} missing_turn_file_count={} fast_path={} metadata_duration_ms={} state_duration_ms={} scan_duration_ms={} read_duration_ms={} max_turn_read_duration_ms={} build_session_duration_ms={} total_duration_ms={}", + session_id, + turns.len(), + tail_turn_count, + total_turn_count, + missing_turn_file_count, + fast_path, + metadata_duration.as_millis(), + state_duration.as_millis(), + scan_duration.as_millis(), + read_duration.as_millis(), + max_turn_read_duration_ms, + build_session_duration_ms, + total_duration.as_millis() + ); + } + + let timing = SessionTurnLoadTiming { + requested_tail_turn_count: Some(tail_turn_count), + loaded_turn_count: turns.len(), + total_turn_count, + turn_file_count: total_turn_count, + missing_turn_file_count, + fast_path, + metadata_duration_ms: metadata_duration.as_millis() as u64, + state_duration_ms: state_duration.as_millis() as u64, + scan_duration_ms: scan_duration.as_millis() as u64, + read_duration_ms: read_duration.as_millis() as u64, + max_turn_read_duration_ms, + build_session_duration_ms, + total_duration_ms: total_duration.as_millis() as u64, + }; - Ok((session, turns, total_turn_count)) + Ok((session, turns, total_turn_count, timing)) } /// Save session state @@ -2757,17 +2953,68 @@ impl PersistenceManager { async fn read_turn_paths( &self, indexed_paths: Vec<(usize, PathBuf)>, - ) -> BitFunResult> { + ) -> BitFunResult { let mut turns = Vec::with_capacity(indexed_paths.len()); - for (_, path) in indexed_paths { - if let Some(file) = self - .read_json_optional::(&path) - .await? - { + let mut missing_turn_file_count = 0usize; + let mut max_turn_read_duration_ms = 0u64; + let reads = stream::iter(indexed_paths.into_iter().map(|(_, path)| { + let manager = self; + async move { + let started_at = Instant::now(); + let result = manager + .read_json_optional::(&path) + .await; + (result, elapsed_ms_u64(started_at)) + } + })) + .buffered(SESSION_TURN_READ_CONCURRENCY) + .collect::>() + .await; + + for (result, duration_ms) in reads { + max_turn_read_duration_ms = max_turn_read_duration_ms.max(duration_ms); + if let Some(file) = result? { turns.push(file.turn); + } else { + missing_turn_file_count += 1; } } - Ok(turns) + + Ok(ReadTurnPathsResult { + turns, + missing_turn_file_count, + max_turn_read_duration_ms, + }) + } + + async fn read_metadata_tail_turns( + &self, + workspace_path: &Path, + session_id: &str, + total_turn_count: usize, + requested_count: usize, + ) -> BitFunResult> { + if requested_count == 0 { + return Ok(Some(ReadTurnPathsResult { + turns: Vec::new(), + missing_turn_file_count: 0, + max_turn_read_duration_ms: 0, + })); + } + if total_turn_count == 0 { + return Ok(None); + } + + let start = total_turn_count.saturating_sub(requested_count); + let indexed_paths = (start..total_turn_count) + .map(|index| (index, self.turn_path(workspace_path, session_id, index))) + .collect::>(); + let result = self.read_turn_paths(indexed_paths).await?; + if result.missing_turn_file_count > 0 { + return Ok(None); + } + + Ok(Some(result)) } pub async fn load_session_turns( @@ -2784,17 +3031,22 @@ impl PersistenceManager { let read_started_at = Instant::now(); let turn_file_count = indexed_paths.len(); - let turns = self.read_turn_paths(indexed_paths).await?; + let read_result = self.read_turn_paths(indexed_paths).await?; let read_duration = read_started_at.elapsed(); + let missing_turn_file_count = read_result.missing_turn_file_count; + let max_turn_read_duration_ms = read_result.max_turn_read_duration_ms; + let turns = read_result.turns; let total_duration = started_at.elapsed(); if total_duration >= Duration::from_millis(80) || turn_file_count >= 50 { debug!( - "Loaded session turns: session_id={} turn_count={} turn_file_count={} scan_duration_ms={} read_duration_ms={} total_duration_ms={}", + "Loaded session turns: session_id={} turn_count={} turn_file_count={} missing_turn_file_count={} scan_duration_ms={} read_duration_ms={} max_turn_read_duration_ms={} total_duration_ms={}", session_id, turns.len(), turn_file_count, + missing_turn_file_count, scan_duration.as_millis(), read_duration.as_millis(), + max_turn_read_duration_ms, total_duration.as_millis() ); } @@ -2813,28 +3065,81 @@ impl PersistenceManager { } let started_at = Instant::now(); - let scan_started_at = Instant::now(); - let indexed_paths = self - .list_indexed_turn_paths(workspace_path, session_id) + let metadata_started_at = Instant::now(); + let metadata = self + .load_session_metadata(workspace_path, session_id) .await?; - let scan_duration = scan_started_at.elapsed(); - let turn_file_count = indexed_paths.len(); - let start = indexed_paths.len().saturating_sub(count); - let selected_paths = indexed_paths.into_iter().skip(start).collect::>(); + let metadata_duration = metadata_started_at.elapsed(); - let read_started_at = Instant::now(); - let turns = self.read_turn_paths(selected_paths).await?; - let read_duration = read_started_at.elapsed(); + let fast_path_started_at = Instant::now(); + let fast_path_turns = if let Some(metadata) = metadata.as_ref() { + self.read_metadata_tail_turns(workspace_path, session_id, metadata.turn_count, count) + .await? + } else { + None + }; + let fast_path_duration = fast_path_started_at.elapsed(); + + let ( + turns, + turn_file_count, + scan_duration, + read_duration, + fast_path, + missing_turn_file_count, + max_turn_read_duration_ms, + ) = if let Some(turns) = fast_path_turns { + let turn_file_count = metadata + .as_ref() + .map(|metadata| metadata.turn_count) + .unwrap_or(turns.turns.len()); + ( + turns.turns, + turn_file_count, + Duration::ZERO, + fast_path_duration, + true, + turns.missing_turn_file_count, + turns.max_turn_read_duration_ms, + ) + } else { + let scan_started_at = Instant::now(); + let indexed_paths = self + .list_indexed_turn_paths(workspace_path, session_id) + .await?; + let scan_duration = scan_started_at.elapsed(); + let turn_file_count = indexed_paths.len(); + let start = indexed_paths.len().saturating_sub(count); + let selected_paths = indexed_paths.into_iter().skip(start).collect::>(); + + let read_started_at = Instant::now(); + let read_result = self.read_turn_paths(selected_paths).await?; + let read_duration = read_started_at.elapsed(); + + ( + read_result.turns, + turn_file_count, + scan_duration, + read_duration, + false, + read_result.missing_turn_file_count, + read_result.max_turn_read_duration_ms, + ) + }; let total_duration = started_at.elapsed(); if total_duration >= Duration::from_millis(40) || turn_file_count >= 50 { debug!( - "Loaded session tail turns: session_id={} turn_count={} requested_count={} turn_file_count={} scan_duration_ms={} read_duration_ms={} total_duration_ms={}", + "Loaded session tail turns: session_id={} turn_count={} requested_count={} turn_file_count={} missing_turn_file_count={} fast_path={} metadata_duration_ms={} scan_duration_ms={} read_duration_ms={} max_turn_read_duration_ms={} total_duration_ms={}", session_id, turns.len(), count, turn_file_count, + missing_turn_file_count, + fast_path, + metadata_duration.as_millis(), scan_duration.as_millis(), read_duration.as_millis(), + max_turn_read_duration_ms, total_duration.as_millis() ); } @@ -3203,7 +3508,7 @@ impl PersistenceManager { #[cfg(test)] mod tests { - use super::{context_snapshot_payload_stats, PersistenceManager}; + use super::{context_snapshot_payload_stats, PersistenceManager, StoredDialogTurnFile}; use crate::agentic::core::{Message, Session, SessionConfig, SessionKind, ToolResult}; use crate::agentic::skill_agent_snapshot::{ AgentSnapshotEntry, SkillSnapshotEntry, TurnSkillAgentSnapshot, @@ -3387,6 +3692,107 @@ mod tests { assert_eq!(turn_indices, vec![3, 4]); assert_eq!(prompts, vec!["prompt 3", "prompt 4"]); + + let (_session, view_tail, total_turn_count) = manager + .load_session_with_tail_turns(workspace.path(), &session_id, 2) + .await + .expect("tail view should load"); + let view_turn_indices = view_tail + .iter() + .map(|turn| turn.turn_index) + .collect::>(); + + assert_eq!(view_turn_indices, vec![3, 4]); + assert_eq!(total_turn_count, 5); + } + + #[tokio::test] + async fn load_session_tail_turns_uses_metadata_turn_count_as_normal_path_boundary() { + let workspace = TestWorkspace::new(); + let manager = + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"); + let session_id = Uuid::new_v4().to_string(); + let metadata = SessionMetadata::new( + session_id.clone(), + "Tail turns boundary test".to_string(), + "agent".to_string(), + "model".to_string(), + ); + manager + .save_session_metadata(workspace.path(), &metadata) + .await + .expect("metadata should save"); + + for index in 0..5 { + let user_message = UserMessageData { + id: format!("user-{index}"), + content: format!("prompt {index}"), + timestamp: index as u64, + metadata: None, + }; + let mut turn = DialogTurnData::new( + format!("turn-{index}"), + index, + session_id.clone(), + user_message, + ); + turn.mark_completed(); + manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + } + + let orphan_user_message = UserMessageData { + id: "user-99".to_string(), + content: "orphan prompt".to_string(), + timestamp: 99, + metadata: None, + }; + let mut orphan_turn = DialogTurnData::new( + "turn-99".to_string(), + 99, + session_id.clone(), + orphan_user_message, + ); + orphan_turn.mark_completed(); + let orphan_file = StoredDialogTurnFile { + schema_version: super::SESSION_STORAGE_SCHEMA_VERSION, + turn: orphan_turn, + }; + let orphan_json = + serde_json::to_string_pretty(&orphan_file).expect("orphan turn should serialize"); + std::fs::write( + manager.turn_path(workspace.path(), &session_id, 99), + orphan_json, + ) + .expect("orphan turn should be written"); + + let tail = manager + .load_session_tail_turns(workspace.path(), &session_id, 2) + .await + .expect("tail turns should load"); + + let turn_indices = tail.iter().map(|turn| turn.turn_index).collect::>(); + let prompts = tail + .iter() + .map(|turn| turn.user_message.content.as_str()) + .collect::>(); + + assert_eq!(turn_indices, vec![3, 4]); + assert_eq!(prompts, vec!["prompt 3", "prompt 4"]); + + let (_session, view_tail, total_turn_count) = manager + .load_session_with_tail_turns(workspace.path(), &session_id, 2) + .await + .expect("tail view should load"); + let view_turn_indices = view_tail + .iter() + .map(|turn| turn.turn_index) + .collect::>(); + + assert_eq!(view_turn_indices, vec![3, 4]); + assert_eq!(total_turn_count, 5); } #[tokio::test] diff --git a/src/crates/core/src/agentic/persistence/mod.rs b/src/crates/core/src/agentic/persistence/mod.rs index 467fcb5e2..e60726040 100644 --- a/src/crates/core/src/agentic/persistence/mod.rs +++ b/src/crates/core/src/agentic/persistence/mod.rs @@ -5,5 +5,5 @@ pub mod manager; pub mod session_branch; -pub use manager::{PersistenceManager, SessionMetadataPage}; +pub use manager::{PersistenceManager, SessionMetadataPage, SessionTurnLoadTiming}; pub use session_branch::{SessionBranchRequest, SessionBranchResult}; diff --git a/src/crates/core/src/agentic/session/session_manager.rs b/src/crates/core/src/agentic/session/session_manager.rs index cc612509a..fdf381aa6 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/core/src/agentic/session/session_manager.rs @@ -8,7 +8,7 @@ use crate::agentic::core::{ SessionSummary, TurnStats, }; use crate::agentic::image_analysis::ImageContextData; -use crate::agentic::persistence::PersistenceManager; +use crate::agentic::persistence::{PersistenceManager, SessionTurnLoadTiming}; use crate::agentic::session::{ CachedSystemPrompt, CachedUserContext, EvidenceLedgerCheckpoint, EvidenceLedgerEvent, EvidenceLedgerEventStatus, EvidenceLedgerSummary, EvidenceLedgerTargetKind, FileReadState, @@ -33,6 +33,7 @@ use crate::util::sanitize_plain_model_output; use crate::util::timing::elapsed_ms_u64; use dashmap::DashMap; use log::{debug, error, info, warn}; +use serde::Serialize; use serde_json::json; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; @@ -51,6 +52,17 @@ pub struct SessionManagerConfig { pub prompt_cache_policy: PromptCachePolicy, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionViewRestoreTiming { + pub resolve_storage_path_duration_ms: u64, + pub visibility_metadata_duration_ms: u64, + pub load_session_with_turns_duration_ms: u64, + pub normalize_turn_ids_duration_ms: u64, + pub total_duration_ms: u64, + pub turn_load: SessionTurnLoadTiming, +} + impl Default for SessionManagerConfig { fn default() -> Self { Self { @@ -1499,11 +1511,7 @@ impl SessionManager { match self .persistence_manager - .save_skill_agent_baseline_override_snapshot( - &workspace_path, - session_id, - &snapshot, - ) + .save_skill_agent_baseline_override_snapshot(&workspace_path, session_id, &snapshot) .await { Err(error) => { @@ -1613,7 +1621,7 @@ impl SessionManager { session_id, latest_snapshot.clone(), ) - .await; + .await; } self.recover_first_turn_skill_agent_snapshot(session_id, latest_snapshot) @@ -2383,27 +2391,63 @@ impl SessionManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult<(Session, Vec)> { - self.restore_session_view_internal(workspace_path, session_id, false, None) + self.restore_session_view_timed(workspace_path, session_id) .await .map(|(session, turns, _)| (session, turns)) } + pub async fn restore_session_view_timed( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<(Session, Vec, SessionViewRestoreTiming)> { + self.restore_session_view_internal(workspace_path, session_id, false, None) + .await + .map(|(session, turns, _, timing)| (session, turns, timing)) + } + pub async fn restore_internal_session_view( &self, workspace_path: &Path, session_id: &str, ) -> BitFunResult<(Session, Vec)> { - self.restore_session_view_internal(workspace_path, session_id, true, None) + self.restore_internal_session_view_timed(workspace_path, session_id) .await .map(|(session, turns, _)| (session, turns)) } + pub async fn restore_internal_session_view_timed( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<(Session, Vec, SessionViewRestoreTiming)> { + self.restore_session_view_internal(workspace_path, session_id, true, None) + .await + .map(|(session, turns, _, timing)| (session, turns, timing)) + } + pub async fn restore_session_view_tail( &self, workspace_path: &Path, session_id: &str, tail_turn_count: usize, ) -> BitFunResult<(Session, Vec, usize)> { + self.restore_session_view_tail_timed(workspace_path, session_id, tail_turn_count) + .await + .map(|(session, turns, total_turn_count, _)| (session, turns, total_turn_count)) + } + + pub async fn restore_session_view_tail_timed( + &self, + workspace_path: &Path, + session_id: &str, + tail_turn_count: usize, + ) -> BitFunResult<( + Session, + Vec, + usize, + SessionViewRestoreTiming, + )> { self.restore_session_view_internal(workspace_path, session_id, false, Some(tail_turn_count)) .await } @@ -2414,6 +2458,22 @@ impl SessionManager { session_id: &str, tail_turn_count: usize, ) -> BitFunResult<(Session, Vec, usize)> { + self.restore_internal_session_view_tail_timed(workspace_path, session_id, tail_turn_count) + .await + .map(|(session, turns, total_turn_count, _)| (session, turns, total_turn_count)) + } + + pub async fn restore_internal_session_view_tail_timed( + &self, + workspace_path: &Path, + session_id: &str, + tail_turn_count: usize, + ) -> BitFunResult<( + Session, + Vec, + usize, + SessionViewRestoreTiming, + )> { self.restore_session_view_internal(workspace_path, session_id, true, Some(tail_turn_count)) .await } @@ -2424,7 +2484,12 @@ impl SessionManager { session_id: &str, include_internal: bool, tail_turn_count: Option, - ) -> BitFunResult<(Session, Vec, usize)> { + ) -> BitFunResult<( + Session, + Vec, + usize, + SessionViewRestoreTiming, + )> { let restore_started_at = Instant::now(); let storage_path_started_at = Instant::now(); let session_storage_path = { @@ -2437,10 +2502,11 @@ impl SessionManager { .await .unwrap_or_else(|| workspace_path.to_path_buf()) }; + let resolve_storage_path_duration_ms = elapsed_ms_u64(storage_path_started_at); debug!( "Session view restore phase completed: session_id={}, phase=resolve_storage_path, duration_ms={}", session_id, - elapsed_ms_u64(storage_path_started_at) + resolve_storage_path_duration_ms ); let metadata_started_at = Instant::now(); @@ -2455,34 +2521,39 @@ impl SessionManager { session_id ))); } + let visibility_metadata_duration_ms = elapsed_ms_u64(metadata_started_at); debug!( "Session view restore phase completed: session_id={}, phase=load_metadata, duration_ms={}", session_id, - elapsed_ms_u64(metadata_started_at) + visibility_metadata_duration_ms ); let session_started_at = Instant::now(); - let (mut session, persisted_turns, total_turn_count) = if let Some(tail_turn_count) = - tail_turn_count - { - self.persistence_manager - .load_session_with_tail_turns(&session_storage_path, session_id, tail_turn_count) - .await? - } else { - let (session, turns) = self - .persistence_manager - .load_session_with_turns(&session_storage_path, session_id) - .await?; - let total_turn_count = turns.len(); - (session, turns, total_turn_count) - }; + let (mut session, persisted_turns, total_turn_count, turn_load) = + if let Some(tail_turn_count) = tail_turn_count { + self.persistence_manager + .load_session_with_tail_turns_timed( + &session_storage_path, + session_id, + tail_turn_count, + ) + .await? + } else { + let (session, turns, timing) = self + .persistence_manager + .load_session_with_turns_timed(&session_storage_path, session_id) + .await?; + let total_turn_count = turns.len(); + (session, turns, total_turn_count, timing) + }; + let load_session_with_turns_duration_ms = elapsed_ms_u64(session_started_at); debug!( "Session view restore phase completed: session_id={}, phase=load_session_with_turns, turn_count={}, total_turn_count={}, tail_turn_count={:?}, duration_ms={}", session_id, persisted_turns.len(), total_turn_count, tail_turn_count, - elapsed_ms_u64(session_started_at) + load_session_with_turns_duration_ms ); if !matches!(session.state, SessionState::Idle) { @@ -2494,6 +2565,7 @@ impl SessionManager { ); } + let normalize_started_at = Instant::now(); let persisted_turn_ids: Vec = persisted_turns .iter() .map(|turn| turn.turn_id.clone()) @@ -2507,16 +2579,27 @@ impl SessionManager { ); session.dialog_turn_ids = persisted_turn_ids; } + let normalize_turn_ids_duration_ms = elapsed_ms_u64(normalize_started_at); + let total_duration_ms = elapsed_ms_u64(restore_started_at); debug!( "Session view restored: session_id={}, session_name={}, turn_count={}, total_duration_ms={}", session_id, session.session_name, persisted_turns.len(), - elapsed_ms_u64(restore_started_at) + total_duration_ms ); - Ok((session, persisted_turns, total_turn_count)) + let timing = SessionViewRestoreTiming { + resolve_storage_path_duration_ms, + visibility_metadata_duration_ms, + load_session_with_turns_duration_ms, + normalize_turn_ids_duration_ms, + total_duration_ms, + turn_load, + }; + + Ok((session, persisted_turns, total_turn_count, timing)) } /// Restore session and return the persisted turns read during restore. @@ -3064,8 +3147,7 @@ impl SessionManager { (_, value) => value, }); - self - .persistence_manager + self.persistence_manager .save_session_metadata(&workspace_path, &metadata) .await } @@ -6362,10 +6444,7 @@ mod tests { assert_eq!(metadata.custom_metadata, None); assert_eq!( persistence_manager - .load_skill_agent_baseline_override_snapshot( - workspace.path(), - &session.session_id, - ) + .load_skill_agent_baseline_override_snapshot(workspace.path(), &session.session_id,) .await .expect("override snapshot load should succeed"), Some(baseline.clone()) diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index bed34e4c1..957b237f4 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -81,6 +81,7 @@ function App() { const [interactiveShellReady, setInteractiveShellReady] = useState(false); const [appLayoutReady, setAppLayoutReady] = useState(false); const handleAppLayoutReady = useCallback(() => { + startupTrace.markPhase('app_layout_ready_state_update_requested'); setAppLayoutReady(true); }, []); @@ -171,6 +172,11 @@ function App() { }, [showMainWindow]); useEffect(() => { + startupTrace.markPhase('interactive_shell_ready_gate_check', { + workspaceLoading, + appLayoutReady, + alreadyReady: interactiveShellReadyRef.current, + }); if (workspaceLoading || !appLayoutReady || interactiveShellReadyRef.current) { return; } diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index 0826e804f..61f7ef73d 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -37,6 +37,7 @@ import WorkspaceRelatedPathsDialog from './WorkspaceRelatedPathsDialog'; import { sessionAPI } from '@/infrastructure/api/service-api/SessionAPI'; import { confirmWarning } from '@/component-library/components/ConfirmDialog/confirmService'; import { scheduleAfterStartupSignal } from '@/shared/utils/startupTaskScheduling'; +import { getWorkspaceGitBasicInfoOptions } from './workspaceGitRefreshOptions'; interface WorkspaceItemProps { @@ -76,7 +77,15 @@ const WorkspaceItem: React.FC = ({ } = useWorkspaceContext(); const { switchLeftPanelTab } = useApp(); const openNavScene = useNavSceneStore(s => s.openNavScene); - const { isRepository } = useGitBasicInfo(workspace.rootPath); + const { + isRepository, + isLoading: isGitBasicInfoLoading, + state: gitBasicInfoState, + refreshBasic: refreshGitBasicInfo, + } = useGitBasicInfo( + workspace.rootPath, + getWorkspaceGitBasicInfoOptions(workspace, isActive) + ); const [menuOpen, setMenuOpen] = useState(false); const [worktreeModalOpen, setWorktreeModalOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -117,6 +126,12 @@ const WorkspaceItem: React.FC = ({ workspace.workspaceKind === WorkspaceKind.Normal || workspace.workspaceKind === WorkspaceKind.Remote ); + const shouldRefreshGitBasicInfoOnMenuOpen = + !isActive && + !isRemoteWorkspace(workspace) && + !gitBasicInfoState && + !isGitBasicInfoLoading; + const isWorktreeActionDisabled = isGitBasicInfoLoading || !isRepository; const workspaceSearchIndex = useWorkspaceSearchIndex({ workspacePath: canShowSearchIndex ? workspace.rootPath : undefined, enabled: canShowSearchIndex, @@ -305,6 +320,14 @@ const WorkspaceItem: React.FC = ({ requestAnimationFrame(apply); }, []); + const handleMenuTriggerClick = useCallback(() => { + const nextOpen = !menuOpen; + setMenuOpen(nextOpen); + if (nextOpen && shouldRefreshGitBasicInfoOnMenuOpen) { + void refreshGitBasicInfo(); + } + }, [menuOpen, refreshGitBasicInfo, shouldRefreshGitBasicInfoOnMenuOpen]); + useEffect(() => { if (!menuOpen) return; const handleOutside = (event: MouseEvent) => { @@ -779,7 +802,7 @@ const WorkspaceItem: React.FC = ({ @@ -1086,7 +1109,7 @@ const WorkspaceItem: React.FC = ({ @@ -1169,9 +1192,9 @@ const WorkspaceItem: React.FC = ({ setMenuOpen(false); setWorktreeModalOpen(true); }} - disabled={!isRepository} + disabled={isWorktreeActionDisabled} > - + {isGitBasicInfoLoading ? : } {t('nav.workspaces.actions.newWorktree')} )} diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.test.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.test.ts new file mode 100644 index 000000000..67e301d0b --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { + WorkspaceKind, + WorkspaceType, + type WorkspaceInfo, +} from '@/shared/types/global-state'; +import { getWorkspaceGitBasicInfoOptions } from './workspaceGitRefreshOptions'; + +const createWorkspace = (workspaceKind: WorkspaceKind): WorkspaceInfo => ({ + id: `${workspaceKind}-workspace`, + name: 'BitFun', + rootPath: '/workspace/BitFun', + workspaceType: WorkspaceType.SingleProject, + workspaceKind, + languages: [], + openedAt: '2026-06-02T00:00:00Z', + lastAccessed: '2026-06-02T00:00:00Z', + tags: [], + ...(workspaceKind === WorkspaceKind.Remote + ? { + connectionId: 'remote-connection', + sshHost: 'remote.example.com', + } + : {}), +}); + +describe('getWorkspaceGitBasicInfoOptions', () => { + it('refreshes active local workspace rows on mount', () => { + expect(getWorkspaceGitBasicInfoOptions(createWorkspace(WorkspaceKind.Normal), true)) + .toEqual({ + isActive: true, + refreshOnMount: true, + refreshOnActive: true, + participateInWindowFocusRefresh: false, + }); + }); + + it('defers inactive local workspace row refresh until activation', () => { + expect(getWorkspaceGitBasicInfoOptions(createWorkspace(WorkspaceKind.Normal), false)) + .toEqual({ + isActive: false, + refreshOnMount: false, + refreshOnActive: true, + participateInWindowFocusRefresh: false, + }); + }); + + it('keeps remote workspace rows on the existing default git refresh behavior', () => { + expect(getWorkspaceGitBasicInfoOptions(createWorkspace(WorkspaceKind.Remote), false)) + .toBeUndefined(); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.ts new file mode 100644 index 000000000..21967765b --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceGitRefreshOptions.ts @@ -0,0 +1,18 @@ +import { isRemoteWorkspace, type WorkspaceInfo } from '@/shared/types'; +import type { GitBasicInfoOptions } from '@/tools/git/hooks/useGitState'; + +export function getWorkspaceGitBasicInfoOptions( + workspace: WorkspaceInfo, + isActive: boolean +): GitBasicInfoOptions | undefined { + if (isRemoteWorkspace(workspace)) { + return undefined; + } + + return { + isActive, + refreshOnMount: isActive, + refreshOnActive: true, + participateInWindowFocusRefresh: false, + }; +} diff --git a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts index 0345ca643..a767cee30 100644 --- a/src/web-ui/src/app/startup/startupPerformanceContract.test.ts +++ b/src/web-ui/src/app/startup/startupPerformanceContract.test.ts @@ -50,6 +50,37 @@ describe('startup performance contract', () => { expect(source).toContain("import('./shared/context-menu-system')"); }); + it('does not block first React render on frontend log-level config reads', () => { + const mainSource = readSource('../../main.tsx'); + const loggerSource = readSource('../../shared/utils/logger.ts'); + const themeSource = readSource('../../../../apps/desktop/src/theme.rs'); + + expect(mainSource).not.toContain("before_render_step', 'initialize_frontend_log_level_sync'"); + expect(mainSource).not.toContain('before_render_step", "initialize_frontend_log_level_sync"'); + expect(mainSource).toContain('initializeFrontendLogLevelSync'); + expect(mainSource).toContain('installFrontendLogLevelConfigWatcher'); + expect(loggerSource).toContain('__BITFUN_BOOTSTRAP_LOG_LEVEL__'); + expect(themeSource).toContain('__BITFUN_BOOTSTRAP_LOG_LEVEL__'); + }); + + it('keeps built-in theme startup on the bootstrap path without pre-render config writes', () => { + const mainSource = readSource('../../main.tsx'); + const themeServiceSource = readSource('../../infrastructure/theme/core/ThemeService.ts'); + const desktopThemeSource = readSource('../../../../apps/desktop/src/theme.rs'); + + expect(desktopThemeSource).toContain('__BITFUN_BOOTSTRAP_THEME_ID__'); + expect(desktopThemeSource).toContain('__BITFUN_BOOTSTRAP_THEME_SELECTION__'); + expect(mainSource).toContain("before_render_step', 'theme_service_initialize'"); + expect(themeServiceSource).toContain('getBootstrapThemeSelection'); + expect(themeServiceSource).toContain('applyThemeSelection(bootstrapSelection, { persist: false })'); + expect(themeServiceSource).toContain('applyThemeSelection(saved, { persist: false })'); + expect(themeServiceSource).toContain('ensureUserThemesLoaded'); + expect(mainSource).toContain('themeService.ensureUserThemesLoaded()'); + expect(mainSource.indexOf('themeService.ensureUserThemesLoaded()')).toBeGreaterThan( + mainSource.indexOf('async function initializeAfterRender()'), + ); + }); + it('starts non-critical work after the shell is interactive', () => { const source = readSource('../../main.tsx'); @@ -121,6 +152,26 @@ describe('startup performance contract', () => { expect(source).toContain('scheduleMonacoStartupWarmup()'); }); + it('does not remount the historical message list when full hydration prepends older turns', () => { + const source = readSource('../../flow_chat/components/modern/VirtualMessageList.tsx'); + + expect(source).not.toContain('firstVirtualItemTurnId'); + expect(source).not.toMatch(/key=\{`\$\{activeSession\?\.sessionId[^`]+firstVirtualItemTurnId/); + }); + + it('keeps read-only thread goal access metadata-only for unloaded sessions', () => { + const source = readSource('../../../../apps/desktop/src/api/agentic_api.rs'); + const getStart = source.indexOf('pub async fn get_session_thread_goal'); + const clearStart = source.indexOf('pub async fn clear_session_thread_goal'); + const getSource = source.slice(getStart, clearStart); + + expect(getStart).toBeGreaterThan(-1); + expect(clearStart).toBeGreaterThan(getStart); + expect(getSource).toContain('resolve_session_workspace_path_for_thread_goal_read'); + expect(getSource).not.toContain('ensure_session_for_thread_goal'); + expect(getSource).not.toContain('restore_session'); + }); + it('keeps Git diff editor from importing the broad editor barrel', () => { const source = readSource('../../tools/git/components/GitDiffEditor/GitDiffEditor.tsx'); diff --git a/src/web-ui/src/component-library/components/Markdown/AsyncPrismSyntaxHighlighter.tsx b/src/web-ui/src/component-library/components/Markdown/AsyncPrismSyntaxHighlighter.tsx index 5fb3b2a5e..1bbe48e36 100644 --- a/src/web-ui/src/component-library/components/Markdown/AsyncPrismSyntaxHighlighter.tsx +++ b/src/web-ui/src/component-library/components/Markdown/AsyncPrismSyntaxHighlighter.tsx @@ -1,6 +1,12 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useLayoutEffect, useState } from 'react'; import { getLoadedPrismSyntaxHighlighter, loadPrismSyntaxHighlighter } from '@/shared/utils/syntaxHighlighterLoader'; -import type { FlowCodeBlockFallbackProps } from './Markdown'; +import { scheduleAfterStartupPaint } from '@/shared/utils/startupTaskScheduling'; +import { + isStartupRenderTraceEnabled, + recordReactRenderProfile, + startupTrace, +} from '@/shared/utils/startupTrace'; +import type { FlowCodeBlockFallbackProps, MarkdownTraceContext } from './Markdown'; interface AsyncPrismSyntaxHighlighterProps { language: string; @@ -11,9 +17,39 @@ interface AsyncPrismSyntaxHighlighterProps { lineNumberStyle?: React.CSSProperties; fallback?: React.ComponentType; fallbackProps?: FlowCodeBlockFallbackProps; + traceContext?: MarkdownTraceContext; children: string; } +interface PrismRenderTraceProps { + startedAtMs: number; + renderPhase: 'fallback_commit' | 'highlighted_commit'; + contentLength: number; + traceContext?: MarkdownTraceContext; +} + +const PrismRenderTrace: React.FC = ({ + startedAtMs, + renderPhase, + contentLength, + traceContext, +}) => { + useLayoutEffect(() => { + recordReactRenderProfile(startupTrace, { + component: 'AsyncPrismSyntaxHighlighter', + phase: renderPhase, + actualDurationMs: performance.now() - startedAtMs, + contentLength, + turnId: traceContext?.turnId, + roundId: traceContext?.roundId, + itemId: traceContext?.itemId, + hasCodeBlock: true, + }); + }); + + return null; +}; + export const AsyncPrismSyntaxHighlighter: React.FC = ({ language, style, @@ -23,54 +59,123 @@ export const AsyncPrismSyntaxHighlighter: React.FC { const [Highlighter, setHighlighter] = useState | null>(() => getLoadedPrismSyntaxHighlighter()); + const renderTraceEnabled = isStartupRenderTraceEnabled(); + const renderTraceStartedAtMs = renderTraceEnabled ? performance.now() : null; useEffect(() => { + if (Highlighter) { + return; + } + let cancelled = false; - void loadPrismSyntaxHighlighter() - .then((component) => { - if (!cancelled) { - setHighlighter(() => component); - } - }) - .catch(() => { - if (!cancelled) { - setHighlighter(null); + let idleHandle: number | null = null; + let timeoutHandle: number | null = null; + + const clearScheduledLoad = () => { + if (idleHandle !== null) { + const cancelIdle = (globalThis as { + cancelIdleCallback?: (handle: number) => void; + }).cancelIdleCallback; + if (typeof cancelIdle === 'function') { + cancelIdle(idleHandle); + } else { + globalThis.clearTimeout(idleHandle); } - }); + idleHandle = null; + } + + if (timeoutHandle !== null) { + globalThis.clearTimeout(timeoutHandle); + timeoutHandle = null; + } + }; + + const startLoad = () => { + clearScheduledLoad(); + void loadPrismSyntaxHighlighter() + .then((component) => { + if (!cancelled) { + setHighlighter(() => component); + } + }) + .catch(() => { + if (!cancelled) { + setHighlighter(null); + } + }); + }; + + const scheduleIdleLoad = () => { + const requestIdle = (globalThis as { + requestIdleCallback?: (callback: () => void, options?: { timeout?: number }) => number; + }).requestIdleCallback; + + if (typeof requestIdle === 'function') { + idleHandle = requestIdle(startLoad, { timeout: 1500 }); + return; + } + + timeoutHandle = globalThis.setTimeout(startLoad, 200) as unknown as number; + }; + + const cancelAfterPaint = scheduleAfterStartupPaint(scheduleIdleLoad, { frameCount: 2 }); return () => { cancelled = true; + cancelAfterPaint(); + clearScheduledLoad(); }; - }, []); + }, [Highlighter]); + + const traceMarker = renderTraceEnabled && renderTraceStartedAtMs !== null ? ( + + ) : null; if (!Highlighter) { if (Fallback && fallbackProps) { - return ; + return ( + <> + {traceMarker} + + + ); } return ( -
-        {children}
-      
+ <> + {traceMarker} +
+          {children}
+        
+ ); } return ( - - {children} - + <> + {traceMarker} + + {children} + + ); }; diff --git a/src/web-ui/src/component-library/components/Markdown/Markdown.tsx b/src/web-ui/src/component-library/components/Markdown/Markdown.tsx index 57425ec05..91402846f 100644 --- a/src/web-ui/src/component-library/components/Markdown/Markdown.tsx +++ b/src/web-ui/src/component-library/components/Markdown/Markdown.tsx @@ -3,7 +3,7 @@ * Used to render Markdown-formatted text */ -import React, { useState, useMemo, useCallback, useEffect, Component, type ReactNode } from 'react'; +import React, { useState, useMemo, useCallback, useEffect, useLayoutEffect, Component, type ReactNode } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import remarkMath from 'remark-math'; @@ -23,6 +23,11 @@ import { useTheme } from '@/infrastructure/theme'; import { contextMenuController } from '@/shared/context-menu-system/core/ContextMenuController'; import { ContextType, type CustomContext, type MenuItem } from '@/shared/context-menu-system/types'; import { createLogger } from '@/shared/utils/logger'; +import { + isStartupRenderTraceEnabled, + recordReactRenderProfile, + startupTrace, +} from '@/shared/utils/startupTrace'; import path from 'path-browserify'; import 'katex/dist/katex.min.css'; import './Markdown.scss'; @@ -39,6 +44,47 @@ let _cachedWorkspacePathResult: string | undefined; let _cachedWorkspacePathAt = 0; const WORKSPACE_PATH_CACHE_MS = 5000; +export interface MarkdownTraceContext { + turnId?: string; + roundId?: string; + itemId?: string; +} + +interface MarkdownRenderTraceProps { + startedAtMs: number; + contentLength: number; + hasCodeBlock: boolean; + hasTable: boolean; + isStreaming: boolean; + traceContext?: MarkdownTraceContext; +} + +const MarkdownRenderTrace: React.FC = ({ + startedAtMs, + contentLength, + hasCodeBlock, + hasTable, + isStreaming, + traceContext, +}) => { + useLayoutEffect(() => { + recordReactRenderProfile(startupTrace, { + component: 'MarkdownRenderer', + phase: 'commit', + actualDurationMs: performance.now() - startedAtMs, + contentLength, + turnId: traceContext?.turnId, + roundId: traceContext?.roundId, + itemId: traceContext?.itemId, + hasCodeBlock, + hasTable, + isStreaming, + }); + }); + + return null; +}; + async function getWorkspacePathCached(): Promise { const now = Date.now(); if (_cachedWorkspacePathResult !== undefined && now - _cachedWorkspacePathAt < WORKSPACE_PATH_CACHE_MS) { @@ -639,6 +685,7 @@ export interface MarkdownProps { onTabOpen?: (tabInfo: any) => void; onHttpLinkClick?: (url: string, event: React.MouseEvent) => boolean | void; onReproductionProceed?: () => void; + traceContext?: MarkdownTraceContext; } export const Markdown = React.memo(({ @@ -651,7 +698,8 @@ export const Markdown = React.memo(({ onFileViewRequest, onTabOpen, onHttpLinkClick, - onReproductionProceed + onReproductionProceed, + traceContext, }) => { const { isLight } = useTheme(); const { t } = useI18n('components'); @@ -660,6 +708,8 @@ export const Markdown = React.memo(({ const syntaxTheme = useMemo(() => buildMarkdownPrismStyle(isLight), [isLight]); const contentStr = typeof content === 'string' ? content : String(content || ''); + const renderTraceEnabled = isStartupRenderTraceEnabled(); + const renderTraceStartedAtMs = renderTraceEnabled ? performance.now() : null; useEffect(() => { let cancelled = false; @@ -708,7 +758,7 @@ export const Markdown = React.memo(({ return { markdownContent: body, reproductionSteps: steps }; }, [contentStr, isStreaming]); - + const linkMap = useMemo(() => { const map = new Map(); const linkMatches = contentStr.match(/\[([^\]]+)\]\(([^)]+)\)/g) || []; @@ -722,7 +772,13 @@ export const Markdown = React.memo(({ }); return map; }, [contentStr]); - + + const markdownFeatureProfile = useMemo(() => ({ + contentLength: markdownContent.length, + hasCodeBlock: /^[ \t]{0,3}(`{3,}|~{3,})/m.test(markdownContent), + hasTable: /^[ \t]*\|.+\|[ \t]*$/m.test(markdownContent), + }), [markdownContent]); + // Parse line ranges like #L42 / 1-20 const parseLineRange = useCallback((hash: string): LineRange | undefined => { const cleanHash = hash.replace(/^#/, ''); @@ -985,6 +1041,7 @@ export const Markdown = React.memo(({ codeTagStyle, gutterColor: isLight ? '#999' : '#666', }} + traceContext={traceContext} > {code} @@ -1240,13 +1297,24 @@ export const Markdown = React.memo(({ parseLineRange, syntaxTheme, isLight, - currentWorkspacePath + currentWorkspacePath, + traceContext, ]); const wrapperClassName = `markdown-renderer ${className}`.trim(); return (
+ {renderTraceEnabled && renderTraceStartedAtMs !== null && ( + + )} = ({ setRecommendationContext({ workspacePath, sessionId: effectiveTargetSessionId, - turnIndex: session.dialogTurns.length - 1, + turnIndex: lastTurn.backendTurnIndex ?? session.dialogTurns.length - 1, modifiedFiles: [...new Set(modifiedFiles)] }); } diff --git a/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx b/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx index 7d70090e0..81ac0177c 100644 --- a/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx +++ b/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx @@ -9,9 +9,11 @@ import React, { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { MarkdownRenderer } from '@/component-library'; import { DotMatrixLoader } from '@/component-library'; +import type { MarkdownTraceContext } from '@/component-library'; import type { FlowTextItem } from '../types/flow-chat'; import { useFlowChatContext } from './modern/FlowChatContext'; import { useTypewriter } from '../hooks/useTypewriter'; +import { isStartupRenderTraceEnabled } from '@/shared/utils/startupTrace'; import './FlowTextBlock.scss'; // Idle timeout (ms) after content stops growing. @@ -21,6 +23,7 @@ interface FlowTextBlockProps { textItem: FlowTextItem; className?: string; replayStreamingOnMount?: boolean; + traceContext?: MarkdownTraceContext; } const RuntimeStatusBlock: React.FC> = ({ textItem, className = '' }) => { @@ -49,7 +52,8 @@ const RuntimeStatusBlock: React.FC(({ textItem, className = '', - replayStreamingOnMount = true + replayStreamingOnMount = true, + traceContext, }) => { const { onFileViewRequest, onTabOpen, onHttpLinkClick, onOpenVisualization } = useFlowChatContext(); @@ -99,6 +103,7 @@ export const FlowTextBlock = React.memo(({ const isActivelyStreaming = textItem.isStreaming && (textItem.status === 'streaming' || textItem.status === 'running') && isContentGrowing; + const markdownTraceContext = isStartupRenderTraceEnabled() ? traceContext : undefined; if (textItem.runtimeStatus) { return ; @@ -123,6 +128,7 @@ export const FlowTextBlock = React.memo(({ onOpenVisualization={(visualization) => { onOpenVisualization?.(visualization?.type, visualization?.data); }} + traceContext={markdownTraceContext} /> ) : (
diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx index 46d0a60b9..02ecd2c5b 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx @@ -16,6 +16,7 @@ import './FlowChatHeader.scss'; export interface FlowChatHeaderTurnSummary { turnId: string; turnIndex: number; + backendTurnIndex?: number; title: string; } @@ -47,6 +48,10 @@ export interface FlowChatHeaderProps { onJumpToPreviousTurn?: () => void; /** Jump to the next turn. */ onJumpToNextTurn?: () => void; + /** Whether the previous-turn action can navigate within the loaded turn range. */ + canJumpToPreviousTurn?: boolean; + /** Whether the next-turn action can navigate within the loaded turn range. */ + canJumpToNextTurn?: boolean; /** Current search query string. */ searchQuery?: string; /** Called when the user types in the search box. */ @@ -79,6 +84,8 @@ export const FlowChatHeader: React.FC = ({ onJumpToCurrentTurn, onJumpToPreviousTurn, onJumpToNextTurn, + canJumpToPreviousTurn, + canJumpToNextTurn, searchQuery = '', onSearchChange, searchMatchCount = 0, @@ -109,8 +116,8 @@ export const FlowChatHeader: React.FC = ({ const turnBadgeLabel = t('flowChatHeader.turnBadge', { current: currentTurn }); - const previousTurnDisabled = currentTurn <= 1; - const nextTurnDisabled = currentTurn <= 0 || currentTurn >= totalTurns; + const previousTurnDisabled = !(canJumpToPreviousTurn ?? currentTurn > 1); + const nextTurnDisabled = !(canJumpToNextTurn ?? (currentTurn > 0 && currentTurn < totalTurns)); const hasTurnNavigation = turns.length > 0 && !!onJumpToTurn; const hasBackgroundSubagents = backgroundSubagents.length > 0; const displayTurns = useMemo(() => ( 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 c3b879b27..e2f7d4d3f 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx @@ -7,7 +7,7 @@ * and this component only renders rounds with critical output. */ -import React, { useMemo, useState, useCallback, useEffect, useRef } from 'react'; +import React, { useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Copy, Check } from 'lucide-react'; import type { ModelRound, FlowItem, FlowTextItem, FlowToolItem, FlowThinkingItem } from '../../types/flow-chat'; @@ -20,7 +20,7 @@ import { FlowChatStore } from '../../store/FlowChatStore'; import { taskCollapseStateManager } from '../../store/TaskCollapseStateManager'; import { ExportImageButton } from './ExportImageButton'; import { ForkSessionButton } from './ForkSessionButton'; -import { buildModelRoundItemGroups, COMPLETED_TOOL_TRANSIENT_MS } from './modelRoundItemGrouping'; +import { buildModelRoundItemGroups, COMPLETED_TOOL_TRANSIENT_MS, type ModelRoundItemGroup } from './modelRoundItemGrouping'; import { MODEL_ROUND_GROUP_RENDER_CHUNK_DELAY_MS, getInitialModelRoundGroupRenderCount, @@ -31,6 +31,11 @@ import { } from './modelRoundProgressiveRender'; import { Tooltip } from '@/component-library'; import { createLogger } from '@/shared/utils/logger'; +import { + isStartupRenderTraceEnabled, + recordReactRenderProfile, + startupTrace, +} from '@/shared/utils/startupTrace'; import { SubagentProjectionView } from '../subagent/SubagentProjectionView'; import { formatSessionViewPreviewText } from '../../utils/sessionViewPreview'; import './ModelRoundItem.scss'; @@ -38,6 +43,92 @@ import './SubagentItems.scss'; const log = createLogger('ModelRoundItem'); +interface ModelRoundGroupSummary { + textItemCount: number; + toolItemCount: number; + criticalGroupCount: number; + exploreGroupCount: number; +} + +function summarizeModelRoundItemGroups(groups: ModelRoundItemGroup[]): ModelRoundGroupSummary { + return groups.reduce((summary, group) => { + if (group.type === 'explore') { + summary.exploreGroupCount += 1; + for (const item of group.items) { + if (item.type === 'text') { + summary.textItemCount += 1; + } else if (item.type === 'tool') { + summary.toolItemCount += 1; + } + } + return summary; + } + + summary.criticalGroupCount += 1; + if (group.item.type === 'text') { + summary.textItemCount += 1; + } else if (group.item.type === 'tool') { + summary.toolItemCount += 1; + } + return summary; + }, { + textItemCount: 0, + toolItemCount: 0, + criticalGroupCount: 0, + exploreGroupCount: 0, + }); +} + +interface ModelRoundRenderTraceProps { + startedAtMs: number; + turnId: string; + round: ModelRound; + itemCount: number; + groupCount: number; + renderedCount: number; + visibleGroupStartIndex: number; + visibleGroupEndIndex: number; + allGroupSummary: ModelRoundGroupSummary; + visibleGroupSummary: ModelRoundGroupSummary; +} + +const ModelRoundRenderTrace: React.FC = ({ + startedAtMs, + turnId, + round, + itemCount, + groupCount, + renderedCount, + visibleGroupStartIndex, + visibleGroupEndIndex, + allGroupSummary, + visibleGroupSummary, +}) => { + useLayoutEffect(() => { + recordReactRenderProfile(startupTrace, { + component: 'ModelRoundItem', + phase: 'commit', + actualDurationMs: performance.now() - startedAtMs, + turnId, + roundId: round.id, + itemCount, + groupCount, + renderedCount, + visibleGroupStartIndex, + visibleGroupEndIndex, + textItemCount: allGroupSummary.textItemCount, + toolItemCount: allGroupSummary.toolItemCount, + visibleTextItemCount: visibleGroupSummary.textItemCount, + visibleToolItemCount: visibleGroupSummary.toolItemCount, + criticalGroupCount: allGroupSummary.criticalGroupCount, + exploreGroupCount: allGroupSummary.exploreGroupCount, + isStreaming: round.isStreaming, + }); + }); + + return null; +}; + interface ModelRoundItemProps { round: ModelRound; turnId: string; @@ -71,6 +162,7 @@ interface TaskWithSubagentWrapperProps { parentSessionId?: string; directSubagentSessionId?: string; turnId: string; + roundId?: string; } const TaskWithSubagentWrapper: React.FC = React.memo(({ @@ -79,6 +171,7 @@ const TaskWithSubagentWrapper: React.FC = React.me parentSessionId, directSubagentSessionId, turnId, + roundId, }) => { const isCollapsed = useTaskCollapsed(parentTaskToolId); const isTaskRunning = @@ -98,6 +191,7 @@ const TaskWithSubagentWrapper: React.FC = React.me ( const { sessionId } = useFlowChatContext(); const [copied, setCopied] = useState(false); const copyButtonRef = useRef(null); + const renderTraceEnabled = isStartupRenderTraceEnabled(); + const renderTraceStartedAtMs = renderTraceEnabled ? performance.now() : null; useEffect(() => { if (!copied) return; @@ -252,6 +348,14 @@ export const ModelRoundItem = React.memo( () => groupedItems.slice(visibleGroupStartIndex, visibleGroupEndIndex), [groupedItems, visibleGroupEndIndex, visibleGroupStartIndex], ); + const allGroupSummary = useMemo( + () => renderTraceEnabled ? summarizeModelRoundItemGroups(groupedItems) : null, + [groupedItems, renderTraceEnabled], + ); + const visibleGroupSummary = useMemo( + () => renderTraceEnabled ? summarizeModelRoundItemGroups(visibleGroupedItems) : null, + [renderTraceEnabled, visibleGroupedItems], + ); const hasDeferredEarlierGroups = visibleGroupStartIndex > 0; const hasDeferredLaterGroups = visibleGroupEndIndex < groupedItems.length; @@ -346,6 +450,20 @@ export const ModelRoundItem = React.memo(
+ {renderTraceEnabled && renderTraceStartedAtMs !== null && allGroupSummary && visibleGroupSummary && ( + + )} {hasDeferredEarlierGroups && (
{t('modelRound.loadingMoreHistory')} @@ -362,6 +480,7 @@ export const ModelRoundItem = React.memo( key={item.id} item={item} turnId={turnId} + roundId={round.id} isLastItem={isLast && itemIdx === group.items.length - 1} /> )); @@ -379,6 +498,7 @@ export const ModelRoundItem = React.memo( parentSessionId={sessionId} directSubagentSessionId={projectedSubagent.subagentSessionId} turnId={turnId} + roundId={round.id} /> ); } @@ -387,6 +507,7 @@ export const ModelRoundItem = React.memo( key={group.item.id} item={group.item} turnId={turnId} + roundId={round.id} isLastItem={isLast} /> ); @@ -447,11 +568,12 @@ ModelRoundItem.displayName = 'ModelRoundItem'; interface FlowItemRendererProps { item: FlowItem; turnId: string; + roundId?: string; isLastItem?: boolean; } // Do not memoize: streaming content updates frequently. -const FlowItemRenderer: React.FC = ({ item, turnId, isLastItem }) => { +const FlowItemRenderer: React.FC = ({ item, turnId, roundId, isLastItem }) => { const { onToolConfirm, onToolReject, @@ -465,6 +587,11 @@ const FlowItemRenderer: React.FC = ({ item, turnId, isLas return ( ); 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 afc5f10e0..bf156d73a 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 @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createRoot, type Root } from 'react-dom/client'; import { ModernFlowChatContainer } from './ModernFlowChatContainer'; import type { Session } from '../../types/flow-chat'; +import { flowChatStore } from '../../store/FlowChatStore'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -15,6 +16,34 @@ const stateMocks = vi.hoisted(() => ({ })); const switchChatSessionMock = vi.hoisted(() => vi.fn()); +const virtualListMock = vi.hoisted(() => ({ + scrollToTurn: vi.fn(), + scrollToIndex: vi.fn(), + scrollToPhysicalBottomAndClearPin: vi.fn(), + scrollToTurnEndAndClearPin: vi.fn(() => true), + scrollToLatestEndPosition: vi.fn(), + isTurnRenderedInViewport: vi.fn(() => false), + isTurnTextRenderedInViewport: vi.fn(() => false), + pinTurnToTop: vi.fn(() => true), +})); +const virtualListActionClickMock = vi.hoisted(() => vi.fn()); +const startupTraceMock = vi.hoisted(() => ({ + markPhase: vi.fn(), +})); +const searchStateMock = vi.hoisted(() => ({ + searchQuery: '', + onSearchChange: vi.fn(), + matches: [] as unknown[], + matchIndices: [] as number[], + currentMatchIndex: -1, + currentMatchVirtualIndex: -1, + goToNext: vi.fn(), + goToPrev: vi.fn(), + clearSearch: vi.fn(), +})); +const headerPropsMock = vi.hoisted(() => ({ + latest: null as Record | null, +})); vi.mock('react-i18next', () => ({ initReactI18next: { @@ -74,11 +103,27 @@ vi.mock('../../store/modernFlowChatStore', () => ({ })); vi.mock('./VirtualMessageList', () => ({ - VirtualMessageList: React.forwardRef(() =>
), + VirtualMessageList: React.forwardRef((_, ref) => { + React.useImperativeHandle(ref, () => virtualListMock); + return ( +
+ +
+ ); + }), +})); + +vi.mock('@/shared/utils/startupTrace', () => ({ + startupTrace: startupTraceMock, })); vi.mock('./FlowChatHeader', () => ({ - FlowChatHeader: () =>
, + FlowChatHeader: (props: Record) => { + headerPropsMock.latest = props; + return
; + }, })); vi.mock('../WelcomePanel', () => ({ @@ -121,17 +166,7 @@ vi.mock('./useFlowChatToolActions', () => ({ })); vi.mock('./useFlowChatSearch', () => ({ - useFlowChatSearch: () => ({ - searchQuery: '', - onSearchChange: vi.fn(), - matches: [], - matchIndices: [], - currentMatchIndex: -1, - currentMatchVirtualIndex: -1, - goToNext: vi.fn(), - goToPrev: vi.fn(), - clearSearch: vi.fn(), - }), + useFlowChatSearch: () => searchStateMock, })); function createSession(overrides: Partial = {}): Session { @@ -153,17 +188,70 @@ function createSession(overrides: Partial = {}): Session { }; } +function createTurn(id: string, content: string, status: 'completed' | 'processing' = 'completed') { + return { + id, + turnId: id, + sessionId: 'session-1', + timestamp: 1, + userMessage: { id: `user-${id}`, content, timestamp: 1 }, + modelRounds: [], + startTime: 1, + status, + }; +} + +let rafCallbacks: FrameRequestCallback[] = []; + +function flushAnimationFrame() { + const callbacks = rafCallbacks; + rafCallbacks = []; + act(() => { + callbacks.forEach(callback => callback(performance.now())); + }); +} + describe('ModernFlowChatContainer historical empty state', () => { let container: HTMLDivElement; let root: Root; beforeEach(() => { + rafCallbacks = []; + vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => { + rafCallbacks.push(callback); + return rafCallbacks.length; + })); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); stateMocks.virtualItems = []; stateMocks.visibleTurnInfo = null; switchChatSessionMock.mockReset(); + virtualListMock.scrollToTurn.mockReset(); + virtualListMock.scrollToIndex.mockReset(); + virtualListMock.scrollToPhysicalBottomAndClearPin.mockReset(); + virtualListMock.scrollToTurnEndAndClearPin.mockReset(); + virtualListMock.scrollToTurnEndAndClearPin.mockReturnValue(true); + virtualListMock.scrollToLatestEndPosition.mockReset(); + virtualListMock.isTurnRenderedInViewport.mockReset(); + virtualListMock.isTurnRenderedInViewport.mockReturnValue(false); + virtualListMock.isTurnTextRenderedInViewport.mockReset(); + virtualListMock.isTurnTextRenderedInViewport.mockReturnValue(false); + virtualListMock.pinTurnToTop.mockReset(); + virtualListMock.pinTurnToTop.mockReturnValue(true); + virtualListActionClickMock.mockReset(); + startupTraceMock.markPhase.mockReset(); + searchStateMock.searchQuery = ''; + searchStateMock.onSearchChange.mockReset(); + searchStateMock.matches = []; + searchStateMock.matchIndices = []; + searchStateMock.currentMatchIndex = -1; + searchStateMock.currentMatchVirtualIndex = -1; + searchStateMock.goToNext.mockReset(); + searchStateMock.goToPrev.mockReset(); + searchStateMock.clearSearch.mockReset(); + headerPropsMock.latest = null; }); afterEach(() => { @@ -174,6 +262,7 @@ describe('ModernFlowChatContainer historical empty state', () => { } container?.remove(); stateMocks.activeSession = null; + vi.unstubAllGlobals(); }); it('shows a history loading shell for metadata-only sessions instead of the new-session welcome', () => { @@ -198,6 +287,179 @@ describe('ModernFlowChatContainer historical empty state', () => { expect(container.querySelector('[data-testid="welcome-panel"]')).toBeNull(); }); + it('does not show the new-session welcome while a restored session is waiting for virtual items', () => { + stateMocks.activeSession = createSession({ + isHistorical: false, + historyState: 'ready', + dialogTurns: [{ + id: 'turn-1', + turnId: 'turn-1', + sessionId: 'session-1', + timestamp: 1, + userMessage: { id: 'user-1', content: 'Saved prompt', timestamp: 1 }, + modelRounds: [], + startTime: 1, + status: 'completed', + }], + } as Partial); + + act(() => { + root.render(); + }); + + expect(container.textContent).toContain('Loading saved session'); + expect(container.querySelector('[data-testid="welcome-panel"]')).toBeNull(); + }); + + it('keeps a history loading overlay until restored latest text is visible', 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).toContain('Loading saved session'); + + flushAnimationFrame(); + expect(container.textContent).toContain('Loading saved session'); + + virtualListMock.isTurnTextRenderedInViewport.mockReturnValue(true); + flushAnimationFrame(); + + expect(container.textContent).not.toContain('Loading saved session'); + }); + + it('blocks pointer activation behind the history loading overlay', 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(); + }); + + const hiddenAction = container.querySelector('[data-testid="virtual-list-action"]') as HTMLButtonElement; + expect(hiddenAction).not.toBeNull(); + expect(container.textContent).toContain('Loading saved session'); + + act(() => { + hiddenAction.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + }); + + expect(virtualListActionClickMock).not.toHaveBeenCalled(); + + virtualListMock.isTurnTextRenderedInViewport.mockReturnValue(true); + flushAnimationFrame(); + + act(() => { + hiddenAction.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); + }); + + expect(virtualListActionClickMock).toHaveBeenCalledTimes(1); + }); + + it('releases pending full history hydration when latest text visibility signal is missed', async () => { + const releaseSpy = vi + .spyOn(flowChatStore, 'releaseSessionHistoryCompletionAfterInitialPaint') + .mockReturnValue(true); + + 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(); + }); + + for (let index = 0; index < 120; index += 1) { + flushAnimationFrame(); + } + + expect(container.textContent).not.toContain('Loading saved session'); + expect(releaseSpy).toHaveBeenCalledWith('session-1'); + expect(startupTraceMock.markPhase).toHaveBeenCalledWith( + 'historical_session_initial_content_paint_signal_missed', + expect.objectContaining({ released: true }), + ); + + releaseSpy.mockRestore(); + }); + + it('releases pending full history hydration when searching a partially loaded session', async () => { + const pendingSpy = vi + .spyOn(flowChatStore, 'hasPendingSessionHistoryCompletion') + .mockReturnValue(true); + const releaseSpy = vi + .spyOn(flowChatStore, 'releaseSessionHistoryCompletionAfterInitialPaint') + .mockReturnValue(true); + + searchStateMock.searchQuery = 'older prompt'; + stateMocks.activeSession = createSession({ + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'ready', + dialogTurns: [ + createTurn('turn-2', 'Latest restored prompt'), + ], + } as Partial); + stateMocks.virtualItems = [ + { 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(releaseSpy).toHaveBeenCalledWith('session-1'); + expect(startupTraceMock.markPhase).toHaveBeenCalledWith( + 'historical_session_full_hydrate_released_for_search', + expect.objectContaining({ + queryLength: 'older prompt'.length, + turnCount: 1, + }), + ); + + releaseSpy.mockRestore(); + pendingSpy.mockRestore(); + }); + it('keeps the new-session welcome for genuinely new empty sessions', () => { stateMocks.activeSession = createSession({ isHistorical: false, @@ -229,4 +491,356 @@ describe('ModernFlowChatContainer historical empty state', () => { expect(switchChatSessionMock).toHaveBeenCalledWith('session-1'); }); + + it('shows global turn numbers for partial tail history while navigation stays within loaded turns', async () => { + stateMocks.activeSession = createSession({ + isHistorical: false, + historyState: 'ready', + isPartial: true, + loadedTurnCount: 2, + totalTurnCount: 100, + dialogTurns: [ + createTurn('turn-99', 'Recent restored prompt'), + createTurn('turn-100', 'Latest restored prompt'), + ], + } as Partial); + stateMocks.virtualItems = [ + { type: 'user-message', turnId: 'turn-99', data: { id: 'user-turn-99', content: 'Recent restored prompt' } }, + { type: 'user-message', turnId: 'turn-100', data: { id: 'user-turn-100', content: 'Latest restored prompt' } }, + ]; + stateMocks.visibleTurnInfo = { + turnId: 'turn-100', + turnIndex: 2, + totalTurns: 2, + userMessage: 'Latest restored prompt', + }; + + await act(async () => { + root.render(); + }); + + expect(headerPropsMock.latest).toMatchObject({ + currentTurn: 100, + totalTurns: 100, + canJumpToPreviousTurn: true, + canJumpToNextTurn: false, + }); + expect(headerPropsMock.latest?.turns).toMatchObject([ + { turnId: 'turn-99', turnIndex: 99 }, + { turnId: 'turn-100', turnIndex: 100 }, + ]); + + act(() => { + (headerPropsMock.latest?.onJumpToPreviousTurn as (() => void) | undefined)?.(); + }); + + expect(virtualListMock.pinTurnToTop).toHaveBeenLastCalledWith('turn-99', { + behavior: 'smooth', + pinMode: 'transient', + }); + }); + + it('does not expose previous navigation before the loaded tail range in partial history', async () => { + stateMocks.activeSession = createSession({ + isHistorical: false, + historyState: 'ready', + isPartial: true, + loadedTurnCount: 2, + totalTurnCount: 100, + dialogTurns: [ + createTurn('turn-99', 'Recent restored prompt'), + createTurn('turn-100', 'Latest restored prompt'), + ], + } as Partial); + stateMocks.virtualItems = [ + { type: 'user-message', turnId: 'turn-99', data: { id: 'user-turn-99', content: 'Recent restored prompt' } }, + { type: 'user-message', turnId: 'turn-100', data: { id: 'user-turn-100', content: 'Latest restored prompt' } }, + ]; + stateMocks.visibleTurnInfo = { + turnId: 'turn-99', + turnIndex: 1, + totalTurns: 2, + userMessage: 'Recent restored prompt', + }; + + await act(async () => { + root.render(); + }); + + expect(headerPropsMock.latest).toMatchObject({ + currentTurn: 99, + totalTurns: 100, + canJumpToPreviousTurn: false, + canJumpToNextTurn: true, + }); + + act(() => { + (headerPropsMock.latest?.onJumpToPreviousTurn as (() => void) | undefined)?.(); + }); + + expect(virtualListMock.pinTurnToTop).not.toHaveBeenCalled(); + }); + + it('retries sticky latest-turn anchoring when the virtual list rejects the first frame', async () => { + stateMocks.activeSession = createSession({ + historyState: 'ready', + dialogTurns: [ + createTurn('turn-1', 'Older restored prompt'), + createTurn('turn-2', 'Latest restored prompt', 'processing'), + ], + } 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.pinTurnToTop + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + + await act(async () => { + root.render(); + }); + + 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', + }); + + flushAnimationFrame(); + expect(virtualListMock.pinTurnToTop).toHaveBeenCalledTimes(2); + expect(startupTraceMock.markPhase).toHaveBeenCalledWith( + 'historical_session_latest_anchor_attempt', + expect.objectContaining({ accepted: false, attempt: 1, mode: 'sticky-latest' }), + ); + expect(startupTraceMock.markPhase).toHaveBeenCalledWith( + 'historical_session_latest_anchor_attempt', + expect.objectContaining({ accepted: true, attempt: 2, mode: 'sticky-latest' }), + ); + }); + + it('scrolls completed restored history to the tail after hydration clears isHistorical', async () => { + stateMocks.activeSession = createSession({ + isHistorical: false, + historyState: 'ready', + 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' } }, + ]; + + await act(async () => { + root.render(); + }); + + flushAnimationFrame(); + + expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(1); + expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledWith('turn-2'); + expect(virtualListMock.pinTurnToTop).not.toHaveBeenCalled(); + expect(startupTraceMock.markPhase).toHaveBeenCalledWith( + 'historical_session_latest_anchor_attempt', + expect.objectContaining({ accepted: true, attempt: 1, mode: 'bottom' }), + ); + }); + + it('retries completed history tail anchoring when the virtual list is not ready on the first frame', async () => { + stateMocks.activeSession = createSession({ + isHistorical: false, + historyState: 'ready', + 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.scrollToTurnEndAndClearPin + .mockReturnValueOnce(false) + .mockReturnValueOnce(true); + + await act(async () => { + root.render(); + }); + + flushAnimationFrame(); + expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(1); + expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenLastCalledWith('turn-2'); + + flushAnimationFrame(); + expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(2); + expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenLastCalledWith('turn-2'); + expect(startupTraceMock.markPhase).toHaveBeenCalledWith( + 'historical_session_latest_anchor_attempt', + expect.objectContaining({ accepted: false, attempt: 1, mode: 'bottom' }), + ); + expect(startupTraceMock.markPhase).toHaveBeenCalledWith( + 'historical_session_latest_anchor_attempt', + expect.objectContaining({ accepted: true, attempt: 2, mode: 'bottom' }), + ); + }); + + it('re-anchors completed restored history after full hydration expands the same latest turn', async () => { + stateMocks.activeSession = createSession({ + isHistorical: false, + historyState: 'ready', + dialogTurns: [ + createTurn('turn-80', 'Latest restored prompt'), + ], + } as Partial); + stateMocks.virtualItems = [ + { type: 'user-message', turnId: 'turn-80', data: { id: 'user-turn-80', content: 'Latest restored prompt' } }, + ]; + + await act(async () => { + root.render(); + }); + + flushAnimationFrame(); + + expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(1); + expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenLastCalledWith('turn-80'); + + stateMocks.activeSession = createSession({ + isHistorical: false, + historyState: 'ready', + dialogTurns: [ + createTurn('turn-1', 'Older restored prompt'), + createTurn('turn-80', '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-80', data: { id: 'user-turn-80', content: 'Latest restored prompt' } }, + ]; + + await act(async () => { + root.render(); + }); + + flushAnimationFrame(); + + expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(2); + expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenLastCalledWith('turn-80'); + expect(virtualListMock.pinTurnToTop).not.toHaveBeenCalled(); + }); + + it('re-anchors full hydration even when the latest restored turn is already visible', async () => { + stateMocks.activeSession = createSession({ + isHistorical: false, + historyState: 'ready', + dialogTurns: [ + createTurn('turn-80', 'Latest restored prompt'), + ], + } as Partial); + stateMocks.virtualItems = [ + { type: 'user-message', turnId: 'turn-80', data: { id: 'user-turn-80', content: 'Latest restored prompt' } }, + ]; + + await act(async () => { + root.render(); + }); + + flushAnimationFrame(); + + expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(1); + expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenLastCalledWith('turn-80'); + + stateMocks.visibleTurnInfo = { + turnId: 'turn-80', + turnIndex: 1, + totalTurns: 1, + userMessage: 'Latest restored prompt', + }; + virtualListMock.isTurnRenderedInViewport.mockReturnValue(true); + stateMocks.activeSession = createSession({ + isHistorical: false, + historyState: 'ready', + dialogTurns: [ + createTurn('turn-1', 'Older restored prompt'), + createTurn('turn-80', '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-80', data: { id: 'user-turn-80', content: 'Latest restored prompt' } }, + ]; + + await act(async () => { + root.render(); + }); + + expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(2); + expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenLastCalledWith('turn-80'); + + flushAnimationFrame(); + + expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(2); + 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 () => { + stateMocks.activeSession = createSession({ + isHistorical: false, + historyState: 'ready', + dialogTurns: [ + createTurn('turn-80', 'Latest restored prompt'), + ], + } as Partial); + stateMocks.virtualItems = [ + { type: 'user-message', turnId: 'turn-80', data: { id: 'user-turn-80', content: 'Latest restored prompt' } }, + ]; + + await act(async () => { + root.render(); + }); + + flushAnimationFrame(); + + expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(1); + expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenLastCalledWith('turn-80'); + + stateMocks.visibleTurnInfo = { + turnId: 'turn-80', + turnIndex: 1, + totalTurns: 1, + userMessage: 'Latest restored prompt', + }; + virtualListMock.isTurnRenderedInViewport.mockReturnValue(false); + stateMocks.activeSession = createSession({ + isHistorical: false, + historyState: 'ready', + dialogTurns: [ + createTurn('turn-1', 'Older restored prompt'), + createTurn('turn-44', 'Middle restored prompt'), + createTurn('turn-80', '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-44', data: { id: 'user-turn-44', content: 'Middle restored prompt' } }, + { type: 'user-message', turnId: 'turn-80', data: { id: 'user-turn-80', content: 'Latest restored prompt' } }, + ]; + + await act(async () => { + root.render(); + }); + + flushAnimationFrame(); + + expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledTimes(2); + 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 b6b423e31..2a55540ae 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.scss @@ -36,6 +36,14 @@ background: transparent; } + &__history-overlay { + position: absolute; + inset: 0; + z-index: 10; + background: var(--color-bg-scene); + pointer-events: none; + } + &__input { flex-shrink: 0; 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 9f96a149f..8d1582cd2 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -3,7 +3,7 @@ * Uses virtual scrolling with Zustand and syncs legacy store state. */ -import React, { useMemo, useCallback, useRef, useEffect, useState } from 'react'; +import React, { useMemo, useCallback, useRef, useEffect, useLayoutEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useShortcut } from '@/infrastructure/hooks/useShortcut'; import { FlowChatManager } from '@/flow_chat/services/FlowChatManager'; @@ -35,6 +35,8 @@ import { findDialogTurn, shouldUseStickyLatestPin, } from '../../utils/flowChatTurnScrollPolicy'; +import { startupTrace } from '@/shared/utils/startupTrace'; +import { scheduleAfterStartupPaint } from '@/shared/utils/startupTaskScheduling'; import './ModernFlowChatContainer.scss'; interface ModernFlowChatContainerProps { @@ -60,6 +62,9 @@ type BackgroundSubagentSummary = { subagentType?: string; }; +const LATEST_TURN_AUTO_PIN_MAX_ATTEMPTS = 8; +const HISTORY_INITIAL_CONTENT_PAINT_MAX_ATTEMPTS = 120; + function isBackgroundTaskTool(item: FlowToolItem): boolean { const input = item.toolCall?.input; if (!input || typeof input !== 'object') { @@ -183,16 +188,23 @@ export const ModernFlowChatContainer: React.FC = ( }); }, []); const [backgroundSubagents, setBackgroundSubagents] = useState([]); - const autoPinnedSessionIdRef = useRef(null); + const autoPinnedTurnKeyRef = useRef(null); + const releasedHistoryCompletionKeyRef = useRef(null); const virtualListRef = useRef(null); const chatScopeRef = useRef(null); + const [historyInitialContentReadyKey, setHistoryInitialContentReadyKey] = useState(null); const { workspacePath } = useWorkspaceContext(); const allowUserMessageRollback = !isAcpFlowSession(activeSession); const historyState = activeSession?.historyState; + const hasRestoredTurnsPendingVirtualItems = + historyState === 'ready' && + (activeSession?.dialogTurns.length ?? 0) > 0 && + virtualItems.length === 0; const showHistoryPlaceholder = virtualItems.length === 0 && ( historyState === 'metadata-only' || historyState === 'hydrating' || - historyState === 'failed' + historyState === 'failed' || + hasRestoredTurnsPendingVirtualItems ); const { exploreGroupStates, @@ -311,14 +323,79 @@ export const ModernFlowChatContainer: React.FC = ( .map((turn, index) => ({ turnId: turn.id, turnIndex: index + 1, + backendTurnIndex: turn.backendTurnIndex, title: resolveLocalCommandHeaderTitle(turn.userMessage?.metadata) ?? turn.userMessage?.content ?? '', })); }, [activeSession?.dialogTurns, resolveLocalCommandHeaderTitle]); + const headerTotalTurns = activeSession?.isPartial === true + ? Math.max(activeSession.totalTurnCount ?? turnSummaries.length, turnSummaries.length) + : turnSummaries.length; + const headerTurnIndexOffset = activeSession?.isPartial === true + ? Math.max(0, headerTotalTurns - turnSummaries.length) + : 0; + const headerTurnSummaries = useMemo(() => { + if (headerTurnIndexOffset === 0 && activeSession?.isPartial !== true) { + return turnSummaries; + } + return turnSummaries.map(turn => ({ + ...turn, + turnIndex: typeof turn.backendTurnIndex === 'number' + ? turn.backendTurnIndex + 1 + : turn.turnIndex + headerTurnIndexOffset, + })); + }, [activeSession?.isPartial, headerTurnIndexOffset, turnSummaries]); + const headerTurnSummaryById = useMemo(() => { + return new Map(headerTurnSummaries.map(turn => [turn.turnId, turn])); + }, [headerTurnSummaries]); + const latestTurnId = turnSummaries[turnSummaries.length - 1]?.turnId; + const hasPendingHistoryCompletion = activeSession?.sessionId + ? flowChatStore.hasPendingSessionHistoryCompletion(activeSession.sessionId) + : false; + const historyInitialContentKey = + activeSession?.sessionId && + latestTurnId && + activeSession.historyState === 'ready' && + virtualItems.length > 0 && + ( + activeSession.contextRestoreState === 'pending' || + hasPendingHistoryCompletion + ) + ? `${activeSession.sessionId}:${latestTurnId}:${turnSummaries.length}` + : null; + const showHistoryInitialContentOverlay = + historyInitialContentKey !== null && + historyInitialContentReadyKey !== historyInitialContentKey; + const blockHistoryOverlayActivation = useCallback((event: React.SyntheticEvent) => { + if (!showHistoryInitialContentOverlay) { + return; + } - const effectiveVisibleTurnInfo = useMemo(() => { + event.preventDefault(); + event.stopPropagation(); + }, [showHistoryInitialContentOverlay]); + const latestTurn = useMemo( + () => findDialogTurn(activeSession?.dialogTurns, latestTurnId), + [activeSession?.dialogTurns, latestTurnId], + ); + const latestTurnUsesStickyPin = shouldUseStickyLatestPin(latestTurn); + + const navigationVisibleTurnInfo = useMemo(() => { if (!pendingHeaderTurnId) { - return visibleTurnInfo; + if (!visibleTurnInfo) { + return null; + } + + const localTurn = turnSummaries.find(turn => turn.turnId === visibleTurnInfo.turnId); + if (!localTurn) { + return visibleTurnInfo; + } + + return { + ...visibleTurnInfo, + turnIndex: localTurn.turnIndex, + totalTurns: turnSummaries.length, + }; } const targetTurn = turnSummaries.find(turn => turn.turnId === pendingHeaderTurnId); @@ -333,6 +410,22 @@ export const ModernFlowChatContainer: React.FC = ( userMessage: targetTurn.title, }; }, [pendingHeaderTurnId, turnSummaries, visibleTurnInfo]); + const effectiveVisibleTurnInfo = useMemo(() => { + if (!navigationVisibleTurnInfo) { + return null; + } + + return { + ...navigationVisibleTurnInfo, + turnIndex: headerTurnSummaryById.get(navigationVisibleTurnInfo.turnId)?.turnIndex + ?? navigationVisibleTurnInfo.turnIndex + headerTurnIndexOffset, + totalTurns: headerTotalTurns, + }; + }, [headerTotalTurns, headerTurnIndexOffset, headerTurnSummaryById, navigationVisibleTurnInfo]); + const canJumpToPreviousTurn = (navigationVisibleTurnInfo?.turnIndex ?? 0) > 1; + const canJumpToNextTurn = !!navigationVisibleTurnInfo && + navigationVisibleTurnInfo.turnIndex > 0 && + navigationVisibleTurnInfo.turnIndex < turnSummaries.length; const currentHeaderMessage = useMemo(() => { const turnId = effectiveVisibleTurnInfo?.turnId; @@ -362,45 +455,207 @@ export const ModernFlowChatContainer: React.FC = ( }, [pendingHeaderTurnId, turnSummaries, visibleTurnInfo?.turnId]); useEffect(() => { - autoPinnedSessionIdRef.current = null; + autoPinnedTurnKeyRef.current = null; + releasedHistoryCompletionKeyRef.current = null; + setHistoryInitialContentReadyKey(null); setPendingHeaderTurnId(null); }, [activeSession?.sessionId]); - useEffect(() => { + useLayoutEffect(() => { const sessionId = activeSession?.sessionId; - const latestTurnId = turnSummaries[turnSummaries.length - 1]?.turnId; - if (!sessionId || !latestTurnId || autoPinnedSessionIdRef.current === sessionId) { + const latestTurnKey = sessionId && latestTurnId + ? `${sessionId}:${latestTurnId}:${turnSummaries.length}` + : null; + if (!sessionId || !latestTurnId || autoPinnedTurnKeyRef.current === latestTurnKey) { return; } const resolvedLatestTurnId = latestTurnId; - const resolvedSessionId = sessionId; + const resolvedLatestTurnKey = latestTurnKey; + const pinMode = latestTurnUsesStickyPin + ? 'sticky-latest' + : null; + const previousAnchoredLatestTurnKeyPrefix = `${sessionId}:${latestTurnId}:`; + const hasPreviouslyAnchoredSameLatestTurn = + autoPinnedTurnKeyRef.current?.startsWith(previousAnchoredLatestTurnKeyPrefix) === true; + const latestTurnRenderedInViewport = virtualListRef.current?.isTurnRenderedInViewport(latestTurnId) === true; + const shouldForceLatestAnchorAfterTurnCountChange = + hasPreviouslyAnchoredSameLatestTurn && + autoPinnedTurnKeyRef.current !== resolvedLatestTurnKey; + if ( + !shouldForceLatestAnchorAfterTurnCountChange && + hasPreviouslyAnchoredSameLatestTurn && + visibleTurnInfo?.turnId === latestTurnId && + latestTurnRenderedInViewport + ) { + autoPinnedTurnKeyRef.current = resolvedLatestTurnKey; + startupTrace.markPhase('historical_session_latest_anchor_skipped', { + reason: 'latest_turn_already_visible', + mode: pinMode ?? 'bottom', + }); + return; + } + if ( + hasPreviouslyAnchoredSameLatestTurn && + visibleTurnInfo?.turnId === latestTurnId && + !latestTurnRenderedInViewport + ) { + startupTrace.markPhase('historical_session_latest_anchor_stale_visible_info', { + mode: pinMode ?? 'bottom', + }); + } - autoPinnedSessionIdRef.current = resolvedSessionId; setPendingHeaderTurnId(resolvedLatestTurnId); - const latestTurn = findDialogTurn(activeSession?.dialogTurns, resolvedLatestTurnId); - const frameId = requestAnimationFrame(() => { - if (shouldUseStickyLatestPin(latestTurn)) { - const accepted = virtualListRef.current?.pinTurnToTop(resolvedLatestTurnId, { + let frameId: number | null = null; + let cancelled = false; + let attempts = 0; + + const attemptLatestViewportAnchor = () => { + if (cancelled) { + return; + } + + attempts += 1; + let accepted = false; + const list = virtualListRef.current; + + if (pinMode) { + accepted = list?.pinTurnToTop(resolvedLatestTurnId, { behavior: 'auto', - pinMode: 'sticky-latest', + pinMode, }) ?? false; + } else if (list) { + accepted = list.scrollToTurnEndAndClearPin(resolvedLatestTurnId); + } - if (!accepted) { - autoPinnedSessionIdRef.current = null; - setPendingHeaderTurnId(null); - } + startupTrace.markPhase('historical_session_latest_anchor_attempt', { + accepted, + attempt: attempts, + mode: pinMode ?? 'bottom', + }); + + if (accepted) { + autoPinnedTurnKeyRef.current = resolvedLatestTurnKey; return; } - virtualListRef.current?.scrollToPhysicalBottomAndClearPin(); - }); + if (attempts >= LATEST_TURN_AUTO_PIN_MAX_ATTEMPTS) { + setPendingHeaderTurnId(null); + startupTrace.markPhase('historical_session_latest_anchor_failed', { + attempts, + mode: pinMode ?? 'bottom', + }); + return; + } + + frameId = requestAnimationFrame(attemptLatestViewportAnchor); + }; + + if (shouldForceLatestAnchorAfterTurnCountChange) { + attemptLatestViewportAnchor(); + } else { + frameId = requestAnimationFrame(attemptLatestViewportAnchor); + } return () => { - cancelAnimationFrame(frameId); + cancelled = true; + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + }; + }, [ + activeSession?.sessionId, + latestTurnId, + latestTurnUsesStickyPin, + turnSummaries.length, + visibleTurnInfo?.turnId, + ]); + + useEffect(() => { + const sessionId = activeSession?.sessionId; + if ( + !sessionId || + activeSession.historyState !== 'ready' || + ( + activeSession.contextRestoreState !== 'pending' && + !hasPendingHistoryCompletion + ) || + !latestTurnId + ) { + return; + } + + const releaseKey = `${sessionId}:${latestTurnId}:${turnSummaries.length}`; + if (releasedHistoryCompletionKeyRef.current === releaseKey) { + return; + } + + let cancelled = false; + let frameId: number | null = null; + let cancelAfterPaint: (() => void) | null = null; + let attempts = 0; + + const releaseAfterPaint = () => { + if (cancelled) { + return; + } + releasedHistoryCompletionKeyRef.current = releaseKey; + const released = flowChatStore.releaseSessionHistoryCompletionAfterInitialPaint(sessionId); + if (released) { + startupTrace.markPhase('historical_session_initial_content_painted', { + sessionId, + latestTurnId, + turnCount: turnSummaries.length, + }); + } + }; + + const checkLatestTextVisibility = () => { + if (cancelled) { + return; + } + + attempts += 1; + if (virtualListRef.current?.isTurnTextRenderedInViewport(latestTurnId) === true) { + setHistoryInitialContentReadyKey(releaseKey); + cancelAfterPaint = scheduleAfterStartupPaint(releaseAfterPaint, { frameCount: 2 }); + return; + } + + 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; + } + + frameId = requestAnimationFrame(checkLatestTextVisibility); }; - }, [activeSession?.dialogTurns, activeSession?.sessionId, turnSummaries]); + + frameId = requestAnimationFrame(checkLatestTextVisibility); + + return () => { + cancelled = true; + if (frameId !== null) { + cancelAnimationFrame(frameId); + } + cancelAfterPaint?.(); + }; + }, [ + activeSession?.historyState, + activeSession?.contextRestoreState, + activeSession?.sessionId, + hasPendingHistoryCompletion, + latestTurnId, + turnSummaries.length, + ]); useEffect(() => { if (searchCurrentMatchVirtualIndex < 0) return; @@ -412,6 +667,39 @@ export const ModernFlowChatContainer: React.FC = ( }; }, [searchCurrentMatchVirtualIndex]); + useEffect(() => { + const sessionId = activeSession?.sessionId; + const trimmedSearchQuery = searchQuery.trim(); + if ( + !sessionId || + activeSession.historyState !== 'ready' || + !hasPendingHistoryCompletion || + trimmedSearchQuery.length === 0 + ) { + return; + } + + if (latestTurnId) { + releasedHistoryCompletionKeyRef.current = `${sessionId}:${latestTurnId}:${turnSummaries.length}`; + } + + const released = flowChatStore.releaseSessionHistoryCompletionAfterInitialPaint(sessionId); + if (released) { + startupTrace.markPhase('historical_session_full_hydrate_released_for_search', { + sessionId, + queryLength: trimmedSearchQuery.length, + turnCount: turnSummaries.length, + }); + } + }, [ + activeSession?.historyState, + activeSession?.sessionId, + hasPendingHistoryCompletion, + latestTurnId, + searchQuery, + turnSummaries.length, + ]); + const handleJumpToTurn = useCallback((turnId: string) => { if (!turnId) return; @@ -430,18 +718,18 @@ export const ModernFlowChatContainer: React.FC = ( }, [activeSession?.dialogTurns, turnSummaries]); const handleJumpToPreviousTurn = useCallback(() => { - if (!effectiveVisibleTurnInfo || effectiveVisibleTurnInfo.turnIndex <= 1) return; - const previousTurn = turnSummaries[effectiveVisibleTurnInfo.turnIndex - 2]; + if (!navigationVisibleTurnInfo || navigationVisibleTurnInfo.turnIndex <= 1) return; + const previousTurn = turnSummaries[navigationVisibleTurnInfo.turnIndex - 2]; if (!previousTurn) return; handleJumpToTurn(previousTurn.turnId); - }, [effectiveVisibleTurnInfo, handleJumpToTurn, turnSummaries]); + }, [handleJumpToTurn, navigationVisibleTurnInfo, turnSummaries]); const handleJumpToNextTurn = useCallback(() => { - if (!effectiveVisibleTurnInfo || effectiveVisibleTurnInfo.turnIndex >= turnSummaries.length) return; - const nextTurn = turnSummaries[effectiveVisibleTurnInfo.turnIndex]; + if (!navigationVisibleTurnInfo || navigationVisibleTurnInfo.turnIndex >= turnSummaries.length) return; + const nextTurn = turnSummaries[navigationVisibleTurnInfo.turnIndex]; if (!nextTurn) return; handleJumpToTurn(nextTurn.turnId); - }, [effectiveVisibleTurnInfo, handleJumpToTurn, turnSummaries]); + }, [handleJumpToTurn, navigationVisibleTurnInfo, turnSummaries]); const handleRetryHistoryLoad = useCallback(() => { const sessionId = activeSession?.sessionId; @@ -537,7 +825,7 @@ export const ModernFlowChatContainer: React.FC = ( currentUserMessage={currentHeaderMessage} visible={virtualItems.length > 0} sessionId={activeSession?.sessionId} - turns={turnSummaries} + turns={headerTurnSummaries} onJumpToTurn={handleJumpToTurn} onJumpToCurrentTurn={() => { const turnId = effectiveVisibleTurnInfo?.turnId; @@ -545,6 +833,8 @@ export const ModernFlowChatContainer: React.FC = ( }} onJumpToPreviousTurn={handleJumpToPreviousTurn} onJumpToNextTurn={handleJumpToNextTurn} + canJumpToPreviousTurn={canJumpToPreviousTurn} + canJumpToNextTurn={canJumpToNextTurn} searchQuery={searchQuery} onSearchChange={onSearchChange} searchMatchCount={searchMatches.length} @@ -557,10 +847,21 @@ export const ModernFlowChatContainer: React.FC = ( onOpenBackgroundSubagent={handleOpenBackgroundSubagent} /> -
+
{showHistoryPlaceholder ? ( ) : virtualItems.length === 0 ? ( @@ -575,12 +876,19 @@ export const ModernFlowChatContainer: React.FC = ( }} /> ) : ( - + <> + + {showHistoryInitialContentOverlay && ( +
+ +
+ )} + )}
diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx index 59aa4568e..2a1994a83 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx @@ -242,4 +242,45 @@ describe('UserMessageItem steering tag', () => { expect(container.querySelector('.user-message-item__edit-btn')).toBeNull(); }); + + it('disables edit and rollback while a session only has a partial history view', () => { + activeSessionRef.current = { + sessionId: 'partial-session', + sessionKind: 'normal', + isPartial: true, + loadedTurnCount: 1, + totalTurnCount: 20, + dialogTurns: [ + { + id: 'turn-20', + status: 'completed', + backendTurnIndex: 19, + }, + ], + }; + + act(() => { + root.render( + + + , + ); + }); + + expect(container.querySelector('.user-message-item__edit-btn')?.disabled).toBe(true); + expect(container.querySelector('.user-message-item__rollback-btn')?.disabled).toBe(true); + }); }); diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx index 23bb2168a..2a24399ee 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx @@ -92,6 +92,7 @@ export const UserMessageItem = React.memo( const isFailed = dialogTurn?.status === 'error'; const isEditing = editingTurnId === turnId; const resolvedSessionId = sessionId ?? currentSession?.sessionId; + const historyActionsBlockedByPartialRestore = currentSession?.isPartial === true; const isSystemTriggered = Boolean( message?.metadata?.triggerSource && message.metadata.triggerSource !== 'desktop_ui', ); @@ -100,12 +101,14 @@ export const UserMessageItem = React.memo( canShowRollbackAction && !!resolvedSessionId && turnIndex >= 0 && + !historyActionsBlockedByPartialRestore && !isRollingBack && !isEditSubmitting; const canEditBase = allowUserMessageEdit && !!resolvedSessionId && turnIndex >= 0 && + !historyActionsBlockedByPartialRestore && !isThreadGoalSystemMessage && !isSystemTriggered && !steeringStatus; @@ -115,6 +118,8 @@ export const UserMessageItem = React.memo( ? t('message.cannotEdit') : steeringStatus ? t('message.cannotEdit') + : historyActionsBlockedByPartialRestore + ? t('message.editDisabledHistoryNotReady') : !resolvedSessionId || turnIndex < 0 ? t('message.editDisabledHistoryNotReady') : t('message.cannotEdit'); 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 new file mode 100644 index 000000000..b0dd44482 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.layout.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { getVirtualMessageDefaultItemHeight } from './virtualMessageListLayout'; + +describe('getVirtualMessageDefaultItemHeight', () => { + it('keeps compact historical projections on the small row estimate', () => { + expect(getVirtualMessageDefaultItemHeight({ + isHistorical: false, + hasCompactHistoricalProjection: true, + hasInitialHistoryModelRoundProjection: true, + })).toBe(72); + }); + + it('uses a taller initial estimate for partial historical model rounds', () => { + expect(getVirtualMessageDefaultItemHeight({ + isHistorical: false, + hasCompactHistoricalProjection: false, + hasInitialHistoryModelRoundProjection: true, + })).toBeGreaterThan(200); + }); + + it('keeps live sessions on the legacy estimate', () => { + expect(getVirtualMessageDefaultItemHeight({ + isHistorical: false, + hasCompactHistoricalProjection: false, + hasInitialHistoryModelRoundProjection: false, + })).toBe(200); + }); +}); 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 fe67c45f7..cd925e1eb 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -28,13 +28,19 @@ import { import { ScrollAnchor } from './ScrollAnchor'; import { useFlowChatFollowOutput } from './useFlowChatFollowOutput'; import type { FlowChatPinTurnToTopMode } from '../../events/flowchatNavigation'; -import { useVirtualItems, useActiveSession, useModernFlowChatStore, type VisibleTurnInfo } from '../../store/modernFlowChatStore'; +import { useVirtualItems, useActiveSession, useModernFlowChatStore, type VisibleTurnInfo, type VirtualItem } from '../../store/modernFlowChatStore'; import { useChatInputState } from '../../store/chatInputStateStore'; -import { computeFlowChatInputStackFooterPx } from '../../utils/flowChatScrollLayout'; +import { + computeFlowChatInputStackFooterPx, + FLOWCHAT_MESSAGE_TAIL_CLEARANCE_PX, +} from '../../utils/flowChatScrollLayout'; import { findDialogTurn, shouldUseStickyLatestPin, } from '../../utils/flowChatTurnScrollPolicy'; +import { flowChatStore } from '../../store/FlowChatStore'; +import { startupTrace } from '@/shared/utils/startupTrace'; +import { getVirtualMessageDefaultItemHeight } from './virtualMessageListLayout'; import './VirtualMessageList.scss'; const COMPENSATION_EPSILON_PX = 0.5; @@ -43,6 +49,19 @@ const ANCHOR_LOCK_DURATION_MS = 450; const PINNED_TURN_VIEWPORT_OFFSET_PX = 57; // Keep in sync with `.message-list-header`. const TOUCH_SCROLL_INTENT_EXIT_THRESHOLD_PX = 6; const USER_UPWARD_SCROLL_INTENT_WINDOW_MS = 800; +const LATEST_END_ANCHOR_STABILIZATION_MAX_ATTEMPTS = 120; +const LATEST_END_ANCHOR_STABILIZATION_MIN_ATTEMPTS = 12; +const LATEST_END_ANCHOR_STABLE_VISIBLE_FRAMES = 8; +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; + +type LatestEndAnchorResolveReason = + | 'raf' + | 'range-changed' + | 'resize-observer' + | 'transition-finish'; // Read `FLOWCHAT_SCROLL_STABILITY.md` before changing collapse compensation logic. @@ -54,6 +73,12 @@ export interface VirtualMessageListRef { scrollToIndex: (index: number) => void; // Clears pin reservation first, then scrolls to the physical bottom. scrollToPhysicalBottomAndClearPin: () => void; + // Clears pin reservation first, then keeps the target turn visible near the natural tail. + scrollToTurnEndAndClearPin: (turnId: string) => boolean; + // Checks the current rendered DOM instead of the possibly stale visible-turn store. + isTurnRenderedInViewport: (turnId: string) => boolean; + // Checks whether the current rendered DOM has visible, readable text for the turn. + isTurnTextRenderedInViewport: (turnId: string) => boolean; // Preserves any existing pin reservation and behaves like an End-key scroll. scrollToLatestEndPosition: () => void; // Aligns the target turn's user message to the viewport top. @@ -78,6 +103,18 @@ interface PendingCollapseIntentState { cumulativeShrinkPx: number; } +interface LatestEndAnchorRequestState { + turnId: string; + targetIndex: number; + attempts: number; + visibleFrames: number; + stableVisibleFrames: number; + lastScrollHeight: number | null; + lastScrollTop: number | null; + lastTargetTop: number | null; + lastTargetBottom: number | null; +} + type BottomReservationKind = 'collapse' | 'pin'; interface BottomReservationBase { @@ -215,6 +252,35 @@ function areBottomReservationStatesEqual(left: BottomReservationState, right: Bo ); } +function getVirtualItemStableKey(item: VirtualItem): string { + switch (item.type) { + case 'user-message': + case 'user-steering-message': + return `${item.type}:${item.turnId}:${item.data.id}`; + case 'model-round': + return `${item.type}:${item.turnId}:${item.data.id}`; + case 'explore-group': + return `${item.type}:${item.turnId}:${item.data.groupId}`; + case 'image-analyzing': + return `${item.type}:${item.turnId}`; + } +} + +function getPrependedVirtualItemCount(previousItems: VirtualItem[], nextItems: VirtualItem[]): number { + if (previousItems.length === 0 || nextItems.length <= previousItems.length) { + return 0; + } + + const prependedCount = nextItems.length - previousItems.length; + for (let index = 0; index < previousItems.length; index += 1) { + if (getVirtualItemStableKey(previousItems[index]) !== getVirtualItemStableKey(nextItems[prependedCount + index])) { + return 0; + } + } + + return prependedCount; +} + function getReservationTotalPx(reservation: BottomReservationBase): number { return Math.max(0, reservation.px); } @@ -227,6 +293,30 @@ export const VirtualMessageList = forwardRef((_, ref) => const virtuosoRef = useRef(null); const virtualItems = useVirtualItems(); const activeSession = useActiveSession(); + const virtuosoIndexStateRef = useRef<{ + sessionId: string | null; + firstItemIndex: number; + virtualItems: VirtualItem[]; + }>({ + sessionId: null, + firstItemIndex: VIRTUOSO_FIRST_ITEM_INDEX_BASE, + virtualItems: [], + }); + const virtuosoIndexState = virtuosoIndexStateRef.current; + const activeSessionId = activeSession?.sessionId ?? null; + if (virtuosoIndexState.sessionId !== activeSessionId) { + virtuosoIndexState.sessionId = activeSessionId; + virtuosoIndexState.firstItemIndex = VIRTUOSO_FIRST_ITEM_INDEX_BASE; + virtuosoIndexState.virtualItems = virtualItems; + } else if (virtuosoIndexState.virtualItems !== virtualItems) { + const prependedCount = getPrependedVirtualItemCount(virtuosoIndexState.virtualItems, virtualItems); + if (prependedCount > 0) { + virtuosoIndexState.firstItemIndex = Math.max(0, virtuosoIndexState.firstItemIndex - prependedCount); + } + virtuosoIndexState.virtualItems = virtualItems; + } + const virtuosoFirstItemIndex = virtuosoIndexState.firstItemIndex; + const toVirtuosoIndex = useCallback((localIndex: number) => virtuosoFirstItemIndex + localIndex, [virtuosoFirstItemIndex]); const [isAtBottom, setIsAtBottom] = useState(true); const [scrollerElement, setScrollerElement] = useState(null); @@ -243,6 +333,10 @@ export const VirtualMessageList = forwardRef((_, ref) => const measureFrameRef = useRef(null); const visibleTurnMeasureFrameRef = useRef(null); const pinReservationReconcileFrameRef = useRef(null); + const turnPinStabilizationFrameRef = useRef(null); + const latestEndAnchorStabilizationFrameRef = useRef(null); + const latestEndAnchorRequestRef = useRef(null); + const resolveLatestEndAnchorStabilizationRef = useRef<((reason: LatestEndAnchorResolveReason) => boolean) | null>(null); const resizeObserverRef = useRef(null); const mutationObserverRef = useRef(null); const layoutTransitionCountRef = useRef(0); @@ -287,6 +381,7 @@ export const VirtualMessageList = forwardRef((_, ref) => const isFollowingOutputRef = useRef(false); const isStreamingOutputRef = useRef(false); const previousIsStreamingOutputRef = useRef(false); + const transientTurnPinStabilizationRef = useRef(null); const isInputActive = useChatInputState(state => state.isActive); const isInputExpanded = useChatInputState(state => state.isExpanded); @@ -327,6 +422,37 @@ export const VirtualMessageList = forwardRef((_, ref) => }); }, []); + const clearTurnPinRequest = useCallback(() => { + transientTurnPinStabilizationRef.current = null; + + if (turnPinStabilizationFrameRef.current !== null) { + cancelAnimationFrame(turnPinStabilizationFrameRef.current); + turnPinStabilizationFrameRef.current = null; + } + + setPendingTurnPin(null); + }, []); + + const cancelLatestEndAnchorStabilization = useCallback(() => { + if (latestEndAnchorStabilizationFrameRef.current !== null) { + cancelAnimationFrame(latestEndAnchorStabilizationFrameRef.current); + latestEndAnchorStabilizationFrameRef.current = null; + } + latestEndAnchorRequestRef.current = null; + }, []); + + const activateTransientTurnPinStabilization = useCallback((request: PendingTurnPinState) => { + if (request.pinMode !== 'transient') { + return; + } + + transientTurnPinStabilizationRef.current = { + ...request, + behavior: 'auto', + attempts: request.attempts + 1, + }; + }, []); + const resetBottomReservations = useCallback(() => { updateBottomReservationState(createInitialBottomReservationState()); }, [updateBottomReservationState]); @@ -705,6 +831,47 @@ export const VirtualMessageList = forwardRef((_, ref) => ); }, []); + const isTurnRenderedInViewport = useCallback((turnId: string) => { + const scroller = scrollerElementRef.current; + if (!scroller) { + return false; + } + + 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}"]`, + ); + + return Array.from(nodes).some(node => { + const rect = node.getBoundingClientRect(); + return rect.bottom > scrollerRect.top && rect.top < scrollerRect.bottom; + }); + }, []); + + const isTurnTextRenderedInViewport = useCallback((turnId: string) => { + const scroller = scrollerElementRef.current; + if (!scroller) { + return false; + } + + 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}"]`, + ); + + return Array.from(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; + }); + }, []); + const buildPinReservation = useCallback(( turnId: string, pinMode: FlowChatPinTurnToTopMode, @@ -855,10 +1022,29 @@ export const VirtualMessageList = forwardRef((_, ref) => const scroller = scrollerElementRef.current; const virtuoso = virtuosoRef.current; - if (!scroller || !virtuoso) return false; + if (!scroller || !virtuoso) { + startupTrace.markPhase('flowchat_turn_pin_resolve', { + result: 'missing_scroller_or_virtuoso', + turnId: request.turnId, + pinMode: request.pinMode, + attempt: request.attempts, + hasScroller: Boolean(scroller), + hasVirtuoso: Boolean(virtuoso), + }); + return false; + } const targetItem = userMessageItems.find(({ item }) => item.turnId === request.turnId); - if (!targetItem) return false; + if (!targetItem) { + startupTrace.markPhase('flowchat_turn_pin_resolve', { + result: 'missing_target_item', + turnId: request.turnId, + pinMode: request.pinMode, + attempt: request.attempts, + userMessageCount: userMessageItems.length, + }); + return false; + } const currentPinReservation = bottomReservationStateRef.current.pin; // Existing pin tail space is synthetic footer reservation, not real content. @@ -901,10 +1087,20 @@ export const VirtualMessageList = forwardRef((_, ref) => } virtuoso.scrollToIndex({ - index: targetItem.index, + index: toVirtuosoIndex(targetItem.index), align: 'start', behavior: fallbackBehavior, }); + startupTrace.markPhase('flowchat_turn_pin_resolve', { + result: 'target_not_rendered_fallback_scroll_to_index', + turnId: request.turnId, + pinMode: request.pinMode, + attempt: request.attempts, + targetIndex: targetItem.index, + scrollTop: scroller.scrollTop, + scrollHeight: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + }); return false; } @@ -992,6 +1188,17 @@ export const VirtualMessageList = forwardRef((_, ref) => const alignedRect = resolvedMetrics.targetElement.getBoundingClientRect(); const alignedWithinTolerance = Math.abs(alignedRect.top - resolvedMetrics.viewportTop) <= 1.5; + startupTrace.markPhase('flowchat_turn_pin_resolve', { + result: alignedWithinTolerance ? 'aligned' : 'not_aligned_after_scroll', + turnId: request.turnId, + pinMode: request.pinMode, + attempt: request.attempts, + targetIndex: targetItem.index, + targetScrollTop, + scrollTop: scroller.scrollTop, + targetTop: alignedRect.top, + viewportTop: resolvedMetrics.viewportTop, + }); return alignedWithinTolerance; }, [ @@ -1002,10 +1209,59 @@ export const VirtualMessageList = forwardRef((_, ref) => schedulePinReservationReconcile, scheduleVisibleTurnMeasure, snapshotMeasuredContentHeight, + toVirtuosoIndex, updateBottomReservationState, userMessageItems, ]); + const reconcileTransientTurnPinStabilization = useCallback(() => { + const request = transientTurnPinStabilizationRef.current; + if (!request) { + return; + } + + if (request.pinMode !== 'transient' || performance.now() > request.expiresAtMs) { + transientTurnPinStabilizationRef.current = null; + return; + } + + const nextRequest: PendingTurnPinState = { + ...request, + behavior: 'auto', + attempts: request.attempts + 1, + }; + transientTurnPinStabilizationRef.current = nextRequest; + + if (tryResolvePendingTurnPin(nextRequest)) { + scheduleVisibleTurnMeasure(2); + } + }, [scheduleVisibleTurnMeasure, tryResolvePendingTurnPin]); + + const scheduleTransientTurnPinStabilization = useCallback((frames: number = 1) => { + if (!transientTurnPinStabilizationRef.current) { + return; + } + + if (turnPinStabilizationFrameRef.current !== null) { + cancelAnimationFrame(turnPinStabilizationFrameRef.current); + turnPinStabilizationFrameRef.current = null; + } + + const run = (remainingFrames: number) => { + turnPinStabilizationFrameRef.current = requestAnimationFrame(() => { + if (remainingFrames > 1) { + run(remainingFrames - 1); + return; + } + + turnPinStabilizationFrameRef.current = null; + reconcileTransientTurnPinStabilization(); + }); + }; + + run(Math.max(1, frames)); + }, [reconcileTransientTurnPinStabilization]); + const handleScrollerRef = useCallback((el: HTMLElement | Window | null) => { if (el && el instanceof HTMLElement) { scrollerElementRef.current = el; @@ -1038,7 +1294,8 @@ export const VirtualMessageList = forwardRef((_, ref) => useEffect(() => { previousMeasuredHeightRef.current = null; previousScrollTopRef.current = 0; - setPendingTurnPin(null); + clearTurnPinRequest(); + cancelLatestEndAnchorStabilization(); anchorLockRef.current = { active: false, targetScrollTop: 0, @@ -1056,7 +1313,7 @@ export const VirtualMessageList = forwardRef((_, ref) => cumulativeShrinkPx: 0, }; resetBottomReservations(); - }, [activeSession?.sessionId, resetBottomReservations]); + }, [activeSession?.sessionId, cancelLatestEndAnchorStabilization, clearTurnPinRequest, resetBottomReservations]); useEffect(() => { previousIsStreamingOutputRef.current = false; @@ -1065,10 +1322,17 @@ export const VirtualMessageList = forwardRef((_, ref) => useEffect(() => { if (virtualItems.length === 0) { previousMeasuredHeightRef.current = null; - setPendingTurnPin(null); + clearTurnPinRequest(); + cancelLatestEndAnchorStabilization(); resetBottomReservations(); } - }, [virtualItems.length, resetBottomReservations]); + }, [virtualItems.length, cancelLatestEndAnchorStabilization, clearTurnPinRequest, resetBottomReservations]); + + useEffect(() => { + return () => { + cancelLatestEndAnchorStabilization(); + }; + }, [cancelLatestEndAnchorStabilization]); useEffect(() => { if (!scrollerElement) { @@ -1086,9 +1350,11 @@ export const VirtualMessageList = forwardRef((_, ref) => resizeObserverRef.current?.disconnect(); resizeObserverRef.current = new ResizeObserver(() => { + resolveLatestEndAnchorStabilizationRef.current?.('resize-observer'); scheduleHeightMeasure(); scheduleVisibleTurnMeasure(2); schedulePinReservationReconcile(2); + scheduleTransientTurnPinStabilization(2); scheduleFollowToLatestWithViewportState('resize-observer'); }); resizeObserverRef.current.observe(resizeTarget); @@ -1115,6 +1381,7 @@ export const VirtualMessageList = forwardRef((_, ref) => scheduleHeightMeasure(2); scheduleVisibleTurnMeasure(2); schedulePinReservationReconcile(2); + scheduleTransientTurnPinStabilization(2); scheduleFollowToLatestWithViewportState('mutation-observer'); }); }); @@ -1138,9 +1405,11 @@ export const VirtualMessageList = forwardRef((_, ref) => const handleTransitionFinish = (event: TransitionEvent) => { if (!isLayoutTransitionProperty(event.propertyName)) return; layoutTransitionCountRef.current = Math.max(0, layoutTransitionCountRef.current - 1); + resolveLatestEndAnchorStabilizationRef.current?.('transition-finish'); scheduleHeightMeasure(2); scheduleVisibleTurnMeasure(2); schedulePinReservationReconcile(2); + scheduleTransientTurnPinStabilization(2); if (layoutTransitionCountRef.current === 0 && pendingCollapseIntentRef.current.active) { pendingCollapseIntentRef.current = { active: false, @@ -1256,6 +1525,11 @@ export const VirtualMessageList = forwardRef((_, ref) => scrollerElement.addEventListener('scroll', handleScroll, { passive: true }); const handleWheel = (event: WheelEvent) => { + if (event.deltaY !== 0) { + clearTurnPinRequest(); + cancelLatestEndAnchorStabilization(); + } + if (event.deltaY < 0) { userInitiatedUpwardScrollUntilMsRef.current = performance.now() + USER_UPWARD_SCROLL_INTENT_WINDOW_MS; @@ -1275,6 +1549,11 @@ export const VirtualMessageList = forwardRef((_, ref) => return; } + if (Math.abs(currentY - startY) > TOUCH_SCROLL_INTENT_EXIT_THRESHOLD_PX) { + clearTurnPinRequest(); + cancelLatestEndAnchorStabilization(); + } + if (currentY - startY > TOUCH_SCROLL_INTENT_EXIT_THRESHOLD_PX) { touchScrollIntentStartYRef.current = currentY; userInitiatedUpwardScrollUntilMsRef.current = @@ -1293,6 +1572,8 @@ export const VirtualMessageList = forwardRef((_, ref) => return; } + clearTurnPinRequest(); + cancelLatestEndAnchorStabilization(); userInitiatedUpwardScrollUntilMsRef.current = performance.now() + USER_UPWARD_SCROLL_INTENT_WINDOW_MS; followOutputControllerRef.current.handleUserScrollIntent(); @@ -1309,6 +1590,8 @@ export const VirtualMessageList = forwardRef((_, ref) => } scrollbarPointerInteractionActiveRef.current = true; + clearTurnPinRequest(); + cancelLatestEndAnchorStabilization(); userInitiatedUpwardScrollUntilMsRef.current = performance.now() + USER_UPWARD_SCROLL_INTENT_WINDOW_MS; followOutputControllerRef.current.handleUserScrollIntent(); @@ -1325,6 +1608,8 @@ export const VirtualMessageList = forwardRef((_, ref) => return; } + clearTurnPinRequest(); + cancelLatestEndAnchorStabilization(); userInitiatedUpwardScrollUntilMsRef.current = performance.now() + USER_UPWARD_SCROLL_INTENT_WINDOW_MS; followOutputControllerRef.current.handleUserScrollIntent(); @@ -1453,11 +1738,18 @@ export const VirtualMessageList = forwardRef((_, ref) => cancelAnimationFrame(pinReservationReconcileFrameRef.current); pinReservationReconcileFrameRef.current = null; } + + if (turnPinStabilizationFrameRef.current !== null) { + cancelAnimationFrame(turnPinStabilizationFrameRef.current); + turnPinStabilizationFrameRef.current = null; + } }; }, [ activateAnchorLock, applyFooterCompensationNow, + cancelLatestEndAnchorStabilization, consumeBottomCompensation, + clearTurnPinRequest, getTotalBottomCompensationPx, latestTurnId, pendingTurnPin?.pinMode, @@ -1466,6 +1758,7 @@ export const VirtualMessageList = forwardRef((_, ref) => scheduleHeightMeasure, scheduleFollowToLatestWithViewportState, schedulePinReservationReconcile, + scheduleTransientTurnPinStabilization, scheduleVisibleTurnMeasure, scrollerElement, shouldSuspendAutoFollow, @@ -1474,13 +1767,217 @@ export const VirtualMessageList = forwardRef((_, ref) => updateBottomReservationState, ]); + const resolveLatestEndAnchorStabilization = useCallback((reason: LatestEndAnchorResolveReason) => { + const request = latestEndAnchorRequestRef.current; + if (!request) { + return false; + } + + const scheduleNextResolve = () => { + if (latestEndAnchorStabilizationFrameRef.current === null) { + latestEndAnchorStabilizationFrameRef.current = requestAnimationFrame(() => { + latestEndAnchorStabilizationFrameRef.current = null; + resolveLatestEndAnchorStabilization('raf'); + }); + } + }; + + const scroller = scrollerElementRef.current; + const virtuoso = virtuosoRef.current; + if (!scroller || !virtuoso) { + request.attempts += 1; + if (request.attempts >= LATEST_END_ANCHOR_STABILIZATION_MAX_ATTEMPTS) { + cancelLatestEndAnchorStabilization(); + startupTrace.markPhase('flowchat_latest_end_anchor_unresolved', { + attempts: request.attempts, + reason, + targetIndex: request.targetIndex, + turnId: request.turnId, + cause: !scroller ? 'missing_scroller' : 'missing_virtuoso', + }); + return false; + } + + scheduleNextResolve(); + return false; + } + + let targetIndex = request.targetIndex; + if (targetIndex < 0 || virtualItems[targetIndex]?.turnId !== request.turnId) { + targetIndex = -1; + for (let index = virtualItems.length - 1; index >= 0; index -= 1) { + if (virtualItems[index]?.turnId === request.turnId) { + targetIndex = index; + break; + } + } + request.targetIndex = targetIndex; + } + + if (targetIndex < 0) { + cancelLatestEndAnchorStabilization(); + return false; + } + + request.attempts += 1; + const targetElement = getRenderedUserMessageElement(request.turnId); + const scrollerRect = scroller.getBoundingClientRect(); + const inputOverlayInsetPx = Math.max( + 0, + inputStackFooterPxRef.current - FLOWCHAT_MESSAGE_TAIL_CLEARANCE_PX, + ); + const visibleTop = scrollerRect.top + LATEST_END_ANCHOR_VISIBILITY_MARGIN_PX; + const visibleBottom = Math.max( + visibleTop + 1, + scrollerRect.bottom - inputOverlayInsetPx - LATEST_END_ANCHOR_VISIBILITY_MARGIN_PX, + ); + + const isTargetVisible = () => { + const currentTargetElement = getRenderedUserMessageElement(request.turnId); + if (!currentTargetElement) { + return false; + } + const rect = currentTargetElement.getBoundingClientRect(); + return rect.bottom > visibleTop && rect.top < visibleBottom; + }; + + const settleIfStableVisible = () => { + const currentTargetElement = getRenderedUserMessageElement(request.turnId); + if (!currentTargetElement) { + request.visibleFrames = 0; + request.stableVisibleFrames = 0; + return false; + } + + const targetRect = currentTargetElement.getBoundingClientRect(); + const scrollHeight = scroller.scrollHeight; + const scrollTop = scroller.scrollTop; + const geometryStable = ( + request.lastScrollHeight !== null && + request.lastScrollTop !== null && + request.lastTargetTop !== null && + request.lastTargetBottom !== null && + Math.abs(scrollHeight - request.lastScrollHeight) <= LATEST_END_ANCHOR_STABLE_EPSILON_PX && + Math.abs(scrollTop - request.lastScrollTop) <= LATEST_END_ANCHOR_STABLE_EPSILON_PX && + Math.abs(targetRect.top - request.lastTargetTop) <= LATEST_END_ANCHOR_STABLE_EPSILON_PX && + Math.abs(targetRect.bottom - request.lastTargetBottom) <= LATEST_END_ANCHOR_STABLE_EPSILON_PX + ); + + request.lastScrollHeight = scrollHeight; + request.lastScrollTop = scrollTop; + request.lastTargetTop = targetRect.top; + request.lastTargetBottom = targetRect.bottom; + request.visibleFrames += 1; + request.stableVisibleFrames = geometryStable ? request.stableVisibleFrames + 1 : 1; + + if ( + request.attempts < LATEST_END_ANCHOR_STABILIZATION_MIN_ATTEMPTS || + request.stableVisibleFrames < LATEST_END_ANCHOR_STABLE_VISIBLE_FRAMES + ) { + scheduleNextResolve(); + return true; + } + + startupTrace.markPhase('flowchat_latest_end_anchor_settled', { + attempts: request.attempts, + reason, + stableVisibleFrames: request.stableVisibleFrames, + targetIndex, + turnId: request.turnId, + }); + cancelLatestEndAnchorStabilization(); + scheduleVisibleTurnMeasure(1); + return true; + }; + + if (isTargetVisible()) { + return settleIfStableVisible(); + } + + request.visibleFrames = 0; + request.stableVisibleFrames = 0; + request.lastScrollHeight = null; + request.lastScrollTop = null; + request.lastTargetTop = null; + request.lastTargetBottom = null; + + if (targetElement) { + const targetRect = targetElement.getBoundingClientRect(); + const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); + let nextScrollTop = scroller.scrollTop; + if (targetRect.bottom > visibleBottom) { + nextScrollTop += targetRect.bottom - visibleBottom; + } else if (targetRect.top < visibleTop) { + nextScrollTop -= visibleTop - targetRect.top; + } + nextScrollTop = Math.max(0, Math.min(maxScrollTop, nextScrollTop)); + + if (Math.abs(nextScrollTop - scroller.scrollTop) > COMPENSATION_EPSILON_PX) { + scroller.scrollTop = nextScrollTop; + previousScrollTopRef.current = nextScrollTop; + previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(scroller); + } + } else { + request.visibleFrames = 0; + virtuoso.scrollToIndex({ + index: toVirtuosoIndex(targetIndex), + align: 'end', + behavior: 'auto', + }); + if (request.attempts >= 3) { + 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()) { + return settleIfStableVisible(); + } + + if (request.attempts >= LATEST_END_ANCHOR_STABILIZATION_MAX_ATTEMPTS) { + cancelLatestEndAnchorStabilization(); + scheduleVisibleTurnMeasure(1); + startupTrace.markPhase('flowchat_latest_end_anchor_unresolved', { + attempts: request.attempts, + reason, + targetIndex, + turnId: request.turnId, + }); + return false; + } + + scheduleNextResolve(); + + return false; + }, [ + cancelLatestEndAnchorStabilization, + getRenderedUserMessageElement, + scheduleVisibleTurnMeasure, + snapshotMeasuredContentHeight, + toVirtuosoIndex, + virtualItems, + ]); + resolveLatestEndAnchorStabilizationRef.current = resolveLatestEndAnchorStabilization; + // `rangeChanged` is affected by overscan/increaseViewportBy, so treat it as a // "rendered DOM changed" signal and derive the pinned turn from real DOM visibility. const handleRangeChanged = useCallback(() => { + resolveLatestEndAnchorStabilization('range-changed'); scheduleVisibleTurnMeasure(2); schedulePinReservationReconcile(2); + scheduleTransientTurnPinStabilization(2); scheduleFollowToLatestWithViewportState('range-changed'); - }, [scheduleFollowToLatestWithViewportState, schedulePinReservationReconcile, scheduleVisibleTurnMeasure]); + }, [ + resolveLatestEndAnchorStabilization, + scheduleFollowToLatestWithViewportState, + schedulePinReservationReconcile, + scheduleTransientTurnPinStabilization, + scheduleVisibleTurnMeasure, + ]); useEffect(() => { if (userMessageItems.length === 0) { @@ -1491,20 +1988,40 @@ export const VirtualMessageList = forwardRef((_, ref) => scheduleVisibleTurnMeasure(2); schedulePinReservationReconcile(2); - }, [activeSession?.sessionId, schedulePinReservationReconcile, scheduleVisibleTurnMeasure, scrollerElement, userMessageItems, virtualItems.length]); + scheduleTransientTurnPinStabilization(2); + }, [ + activeSession?.sessionId, + schedulePinReservationReconcile, + scheduleTransientTurnPinStabilization, + scheduleVisibleTurnMeasure, + scrollerElement, + userMessageItems, + virtualItems.length, + ]); useEffect(() => { if (!pendingTurnPin) return; if (performance.now() > pendingTurnPin.expiresAtMs) { - setPendingTurnPin(null); + clearTurnPinRequest(); return; } const frameId = requestAnimationFrame(() => { const resolved = tryResolvePendingTurnPin(pendingTurnPin); if (resolved) { - setPendingTurnPin(null); + if ( + pendingTurnPin.pinMode === 'transient' && + performance.now() <= pendingTurnPin.expiresAtMs + ) { + activateTransientTurnPinStabilization(pendingTurnPin); + scheduleTransientTurnPinStabilization(2); + scheduleVisibleTurnMeasure(2); + setPendingTurnPin(null); + return; + } + + clearTurnPinRequest(); scheduleVisibleTurnMeasure(2); return; } @@ -1525,7 +2042,14 @@ export const VirtualMessageList = forwardRef((_, ref) => return () => { cancelAnimationFrame(frameId); }; - }, [pendingTurnPin, scheduleVisibleTurnMeasure, tryResolvePendingTurnPin]); + }, [ + activateTransientTurnPinStabilization, + clearTurnPinRequest, + pendingTurnPin, + scheduleTransientTurnPinStabilization, + scheduleVisibleTurnMeasure, + tryResolvePendingTurnPin, + ]); // ── Navigation helpers ──────────────────────────────────────────────── const clearAllBottomReservationsForUserNavigation = useCallback(() => { @@ -1535,7 +2059,7 @@ export const VirtualMessageList = forwardRef((_, ref) => const hasActiveReservation = !areBottomReservationStatesEqual(currentState, nextReservationState); releaseAnchorLock('user-navigation'); - setPendingTurnPin(null); + clearTurnPinRequest(); pendingCollapseIntentRef.current = { active: false, anchorScrollTop: 0, @@ -1561,6 +2085,7 @@ export const VirtualMessageList = forwardRef((_, ref) => } }, [ applyFooterCompensationNow, + clearTurnPinRequest, releaseAnchorLock, snapshotMeasuredContentHeight, updateBottomReservationState, @@ -1577,7 +2102,7 @@ export const VirtualMessageList = forwardRef((_, ref) => ); releaseAnchorLock('user-navigation'); - setPendingTurnPin(null); + clearTurnPinRequest(); if (!hasActivePin) { return; @@ -1602,6 +2127,7 @@ export const VirtualMessageList = forwardRef((_, ref) => } }, [ applyFooterCompensationNow, + clearTurnPinRequest, releaseAnchorLock, snapshotMeasuredContentHeight, updateBottomReservationState, @@ -1631,6 +2157,16 @@ export const VirtualMessageList = forwardRef((_, ref) => return lastDialogTurn.modelRounds.some(round => round.isStreaming); }, [activeSession, isProcessing]); + const initialTopMostItemIndex = React.useMemo(() => { + if (isStreamingOutput) { + return toVirtuosoIndex(latestUserMessageIndex); + } + + return { + index: toVirtuosoIndex(Math.max(0, virtualItems.length - 1)), + align: 'end' as const, + }; + }, [isStreamingOutput, latestUserMessageIndex, toVirtuosoIndex, virtualItems.length]); useEffect(() => { const wasStreaming = previousIsStreamingOutputRef.current; @@ -1714,21 +2250,49 @@ export const VirtualMessageList = forwardRef((_, ref) => if (targetItem.index === 0 && requestedPinMode === 'transient') { // The first turn has a deterministic destination, so bypass the deferred // pin pipeline and snap to the true top immediately. - setPendingTurnPin(null); + clearTurnPinRequest(); virtuosoRef.current.scrollTo({ top: 0, behavior: 'auto' }); return true; } - setPendingTurnPin({ + const request: PendingTurnPinState = { turnId, behavior: requestedBehavior, pinMode: requestedPinMode, expiresAtMs: performance.now() + 1500, attempts: 0, + }; + + startupTrace.markPhase('flowchat_turn_pin_request', { + turnId, + pinMode: requestedPinMode, + targetIndex: targetItem.index, + userMessageCount: userMessageItems.length, }); + + if (tryResolvePendingTurnPin(request)) { + if (requestedPinMode === 'transient') { + activateTransientTurnPinStabilization(request); + scheduleTransientTurnPinStabilization(2); + } else { + clearTurnPinRequest(); + } + scheduleVisibleTurnMeasure(2); + return true; + } + + setPendingTurnPin(request); return true; - }, [activeSession?.dialogTurns, userMessageItems]); + }, [ + activeSession?.dialogTurns, + activateTransientTurnPinStabilization, + clearTurnPinRequest, + scheduleTransientTurnPinStabilization, + scheduleVisibleTurnMeasure, + tryResolvePendingTurnPin, + userMessageItems, + ]); const performAutoFollowSync = useCallback(() => { scrollToLatestEndPositionInternal('auto'); @@ -1829,7 +2393,7 @@ export const VirtualMessageList = forwardRef((_, ref) => // switch; scrolling by index lets Virtuoso resolve the correct position. const scrollToBottom = () => { virtuosoRef.current?.scrollToIndex({ - index: virtualItems.length - 1, + index: toVirtuosoIndex(virtualItems.length - 1), align: 'end', behavior: 'auto', }); @@ -1867,6 +2431,7 @@ export const VirtualMessageList = forwardRef((_, ref) => cancelPendingAutoFollowArm, isStreamingOutput, latestTurnId, + toVirtuosoIndex, virtualItems.length, ]); @@ -1941,12 +2506,12 @@ export const VirtualMessageList = forwardRef((_, ref) => virtuosoRef.current.scrollTo({ top: 0, behavior: 'smooth' }); } else { virtuosoRef.current.scrollToIndex({ - index: targetItem.index, + index: toVirtuosoIndex(targetItem.index), behavior: 'smooth', align: 'center', }); } - }, [clearPinReservationForUserNavigation, exitFollowOutput, userMessageItems]); + }, [clearPinReservationForUserNavigation, exitFollowOutput, toVirtuosoIndex, userMessageItems]); const scrollToIndex = useCallback((index: number) => { if (!virtuosoRef.current) return; @@ -1958,9 +2523,9 @@ export const VirtualMessageList = forwardRef((_, ref) => if (index === 0) { virtuosoRef.current.scrollTo({ top: 0, behavior: 'auto' }); } else { - virtuosoRef.current.scrollToIndex({ index, align: 'center', behavior: 'auto' }); + virtuosoRef.current.scrollToIndex({ index: toVirtuosoIndex(index), align: 'center', behavior: 'auto' }); } - }, [clearPinReservationForUserNavigation, exitFollowOutput, virtualItems.length]); + }, [clearPinReservationForUserNavigation, exitFollowOutput, toVirtuosoIndex, virtualItems.length]); const pinTurnToTop = useCallback((turnId: string, options?: { behavior?: ScrollBehavior; pinMode?: FlowChatPinTurnToTopMode }) => { const shouldExitFollowOutput = !( @@ -2004,11 +2569,69 @@ export const VirtualMessageList = forwardRef((_, ref) => clearAllBottomReservationsForUserNavigation(); scroller.scrollTo({ top: Math.max(0, scroller.scrollHeight - scroller.clientHeight), - behavior: 'smooth', + behavior: 'auto', }); } }, [clearAllBottomReservationsForUserNavigation]); + const scrollToTurnEndAndClearPin = useCallback((turnId: string) => { + const scroller = scrollerElementRef.current; + if (!virtuosoRef.current || !scroller || virtualItems.length === 0) { + return false; + } + + let targetIndex = -1; + for (let index = virtualItems.length - 1; index >= 0; index -= 1) { + if (virtualItems[index]?.turnId === turnId) { + targetIndex = index; + break; + } + } + + if (targetIndex < 0) { + return false; + } + + exitFollowOutput('scroll-to-index'); + clearAllBottomReservationsForUserNavigation(); + latestEndAnchorRequestRef.current = { + turnId, + targetIndex, + attempts: 0, + visibleFrames: 0, + stableVisibleFrames: 0, + lastScrollHeight: null, + lastScrollTop: null, + lastTargetTop: null, + lastTargetBottom: null, + }; + startupTrace.markPhase('flowchat_latest_end_anchor_request', { + targetIndex, + turnId, + virtualItemCount: virtualItems.length, + }); + virtuosoRef.current.scrollToIndex({ + index: toVirtuosoIndex(targetIndex), + align: 'end', + behavior: 'auto', + }); + if (latestEndAnchorStabilizationFrameRef.current === null) { + latestEndAnchorStabilizationFrameRef.current = requestAnimationFrame(() => { + latestEndAnchorStabilizationFrameRef.current = null; + resolveLatestEndAnchorStabilization('raf'); + }); + } + scheduleVisibleTurnMeasure(2); + return true; + }, [ + clearAllBottomReservationsForUserNavigation, + exitFollowOutput, + resolveLatestEndAnchorStabilization, + scheduleVisibleTurnMeasure, + toVirtuosoIndex, + virtualItems, + ]); + const scrollToLatestEndPosition = useCallback(() => { enterFollowOutput('jump-to-latest'); }, [enterFollowOutput]); @@ -2017,9 +2640,21 @@ export const VirtualMessageList = forwardRef((_, ref) => scrollToTurn, scrollToIndex, scrollToPhysicalBottomAndClearPin, + scrollToTurnEndAndClearPin, + isTurnRenderedInViewport, + isTurnTextRenderedInViewport, scrollToLatestEndPosition, pinTurnToTop, - }), [pinTurnToTop, scrollToTurn, scrollToIndex, scrollToPhysicalBottomAndClearPin, scrollToLatestEndPosition]); + }), [ + isTurnRenderedInViewport, + isTurnTextRenderedInViewport, + pinTurnToTop, + scrollToTurn, + scrollToIndex, + scrollToPhysicalBottomAndClearPin, + scrollToTurnEndAndClearPin, + scrollToLatestEndPosition, + ]); const handleAtBottomStateChange = useCallback((atBottom: boolean) => { setIsAtBottom(atBottom); @@ -2103,6 +2738,35 @@ export const VirtualMessageList = forwardRef((_, ref) => }, [lastItemInfo.isTurnProcessing, lastItemInfo.lastItem, isProcessing, processingPhase, isContentGrowing]); const footerHeightPx = getFooterHeightPx(getTotalBottomCompensationPx(bottomReservationState)); + const hasCompactHistoricalProjection = virtualItems.length >= 6 && virtualItems + .slice(-16) + .every(item => + item.type === 'user-message' || + 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'); + const defaultItemHeight = getVirtualMessageDefaultItemHeight({ + isHistorical: activeSession?.isHistorical === true, + hasCompactHistoricalProjection, + hasInitialHistoryModelRoundProjection, + }); + const virtuosoOverscan = useInitialHistoryRenderBudget + ? { main: 0, reverse: 0 } + : { main: 600, reverse: 600 }; + const virtuosoViewportIncrease = useInitialHistoryRenderBudget + ? { top: 0, bottom: 0 } + : { top: 600, bottom: 600 }; // ── Render ──────────────────────────────────────────────────────────── if (virtualItems.length === 0) { @@ -2120,32 +2784,34 @@ export const VirtualMessageList = forwardRef((_, ref) => - `${item.type}-${item.turnId}-${'data' in item && item.data && typeof item.data === 'object' && 'id' in item.data ? item.data.id : index}` - } + computeItemKey={(_, item) => 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={latestUserMessageIndex} + initialTopMostItemIndex={initialTopMostItemIndex} - overscan={{ main: 600, reverse: 600 }} + overscan={virtuosoOverscan} atBottomThreshold={50} atBottomStateChange={handleAtBottomStateChange} rangeChanged={handleRangeChanged} - defaultItemHeight={200} + // 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={{ top: 600, bottom: 600 }} + increaseViewportBy={virtuosoViewportIncrease} scrollerRef={handleScrollerRef} diff --git a/src/web-ui/src/flow_chat/components/modern/virtualMessageListLayout.ts b/src/web-ui/src/flow_chat/components/modern/virtualMessageListLayout.ts new file mode 100644 index 000000000..8db38d6f9 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/virtualMessageListLayout.ts @@ -0,0 +1,19 @@ +export const LIVE_SESSION_DEFAULT_ITEM_HEIGHT_PX = 200; +export const HISTORICAL_SESSION_DEFAULT_ITEM_HEIGHT_PX = 72; +export const HISTORICAL_SESSION_MODEL_ROUND_DEFAULT_ITEM_HEIGHT_PX = 960; + +export function getVirtualMessageDefaultItemHeight(params: { + isHistorical: boolean; + hasCompactHistoricalProjection: boolean; + hasInitialHistoryModelRoundProjection: boolean; +}): number { + if (params.isHistorical || params.hasCompactHistoricalProjection) { + return HISTORICAL_SESSION_DEFAULT_ITEM_HEIGHT_PX; + } + + if (params.hasInitialHistoryModelRoundProjection) { + return HISTORICAL_SESSION_MODEL_ROUND_DEFAULT_ITEM_HEIGHT_PX; + } + + return LIVE_SESSION_DEFAULT_ITEM_HEIGHT_PX; +} diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index 5ab8cac15..efe174731 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { flowChatStore } from './FlowChatStore'; import type { FlowChatState, Session } from '../types/flow-chat'; +import { startupTrace } from '@/shared/utils/startupTrace'; const apiMocks = vi.hoisted(() => ({ listSessions: vi.fn(), @@ -69,12 +70,10 @@ const resetStore = () => { }); metadataPageRequests?.clear(); const fullHistoryHydrationRequests = (flowChatStore as any).fullHistoryHydrationRequests as - | Map }> + | Map void }> | undefined; fullHistoryHydrationRequests?.forEach(request => { - if (request.timer) { - clearTimeout(request.timer); - } + request.cancel?.(); }); fullHistoryHydrationRequests?.clear(); ((flowChatStore as any).unsupportedRestoreCommands as Set | undefined)?.clear(); @@ -780,12 +779,20 @@ describe('FlowChatStore historical session hydration state', () => { undefined, expect.any(String), undefined, - 3, + 8, ); expect( flowChatStore.getState().sessions.get('history-1')?.dialogTurns.map(turn => turn.userMessage.content) ).toEqual(['latest prompt']); + expect(flowChatStore.getState().sessions.get('history-1')).toMatchObject({ + isPartial: true, + loadedTurnCount: 1, + totalTurnCount: 2, + }); + const partialLatestTurnRef = flowChatStore.getState().sessions.get('history-1')?.dialogTurns[0]; + expect(partialLatestTurnRef).toBeDefined(); flowChatStore.setSessionContextRestoreState('history-1', 'ready'); + flowChatStore.releaseSessionHistoryCompletionAfterInitialPaint('history-1'); await vi.runOnlyPendingTimersAsync(); await flushAsyncWork(); @@ -804,8 +811,364 @@ describe('FlowChatStore historical session hydration state', () => { expect( flowChatStore.getState().sessions.get('history-1')?.dialogTurns.map(turn => turn.userMessage.content) ).toEqual(['older prompt', 'latest prompt']); - expect(flowChatStore.getState().sessions.get('history-1')?.contextRestoreState).toBe('ready'); + expect(flowChatStore.getState().sessions.get('history-1')?.dialogTurns[1]).toBe(partialLatestTurnRef); + expect(flowChatStore.getState().sessions.get('history-1')).toMatchObject({ + contextRestoreState: 'ready', + isPartial: false, + loadedTurnCount: 2, + totalTurnCount: 2, + }); + } finally { + vi.useRealTimers(); + } + }); + + it('prepends full history without dropping turns added after partial restore', async () => { + vi.useFakeTimers(); + const olderTurn = { + turnId: 'turn-1', + turnIndex: 0, + sessionId: 'history-1', + timestamp: 1, + userMessage: { id: 'user-1', content: 'older prompt', timestamp: 1 }, + modelRounds: [], + startTime: 1, + status: 'completed', + }; + const latestTurn = { + turnId: 'turn-2', + turnIndex: 1, + sessionId: 'history-1', + timestamp: 2, + userMessage: { id: 'user-2', content: 'latest prompt', timestamp: 2 }, + modelRounds: [], + startTime: 2, + status: 'completed', + }; + apiMocks.restoreSessionView + .mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 2, + createdAt: 1, + }, + turns: [latestTurn], + contextRestoreState: 'pending', + isPartial: true, + loadedTurnCount: 1, + totalTurnCount: 2, + }) + .mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 2, + createdAt: 1, + }, + turns: [olderTurn, latestTurn], + contextRestoreState: 'pending', + isPartial: false, + loadedTurnCount: 2, + totalTurnCount: 2, + }); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + })], + ]), + activeSessionId: 'history-1', + })); + + try { + await flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun'); + + const newTurn = { + id: 'turn-3', + sessionId: 'history-1', + userMessage: { id: 'user-3', content: 'new prompt', timestamp: 3 }, + modelRounds: [], + status: 'processing', + startTime: 3, + } as any; + flowChatStore.addDialogTurn('history-1', newTurn); + + flowChatStore.releaseSessionHistoryCompletionAfterInitialPaint('history-1'); + await vi.runOnlyPendingTimersAsync(); + await flushAsyncWork(); + + expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(2); + expect( + flowChatStore.getState().sessions.get('history-1')?.dialogTurns.map(turn => turn.userMessage.content) + ).toEqual(['older prompt', 'latest prompt', 'new prompt']); + expect(flowChatStore.getState().sessions.get('history-1')?.dialogTurns[2]).toBe(newTurn); + } finally { + vi.useRealTimers(); + } + }); + + it('keeps remote partial restore on the smaller compatibility tail', async () => { + const latestTurn = { + turnId: 'turn-2', + turnIndex: 1, + sessionId: 'history-remote', + timestamp: 2, + userMessage: { id: 'user-2', content: 'latest prompt', timestamp: 2 }, + modelRounds: [], + startTime: 2, + status: 'completed', + }; + apiMocks.restoreSessionView.mockResolvedValueOnce({ + session: { + sessionId: 'history-remote', + sessionName: 'Remote History', + agentType: 'agentic', + state: 'Idle', + turnCount: 2, + createdAt: 1, + }, + turns: [latestTurn], + contextRestoreState: 'pending', + isPartial: true, + loadedTurnCount: 1, + totalTurnCount: 2, + }); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-remote', createSession({ + sessionId: 'history-remote', + isHistorical: true, + historyState: 'metadata-only', + })], + ]), + activeSessionId: 'history-remote', + })); + + await flowChatStore.loadSessionHistory( + 'history-remote', + '/remote/workspace', + undefined, + 'remote-1', + 'remote.example' + ); + + expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(1); + expect(apiMocks.restoreSessionView).toHaveBeenNthCalledWith( + 1, + 'history-remote', + '/remote/workspace', + 'remote-1', + 'remote.example', + expect.any(String), + undefined, + 3, + ); + }); + + it('waits for initial paint and browser idle before completing partial history in background', async () => { + vi.useFakeTimers(); + let idleCallback: (() => void) | null = null; + const originalRequestIdleCallback = (globalThis as any).requestIdleCallback; + const originalCancelIdleCallback = (globalThis as any).cancelIdleCallback; + (globalThis as any).requestIdleCallback = vi.fn((callback: () => void) => { + idleCallback = callback; + return 1; + }); + (globalThis as any).cancelIdleCallback = vi.fn(); + + const latestTurn = { + turnId: 'turn-2', + turnIndex: 1, + sessionId: 'history-1', + timestamp: 2, + userMessage: { id: 'user-2', content: 'latest prompt', timestamp: 2 }, + modelRounds: [], + startTime: 2, + status: 'completed', + }; + apiMocks.restoreSessionView + .mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 2, + createdAt: 1, + }, + turns: [latestTurn], + contextRestoreState: 'pending', + isPartial: true, + loadedTurnCount: 1, + totalTurnCount: 2, + }) + .mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 2, + createdAt: 1, + }, + turns: [ + { + ...latestTurn, + turnId: 'turn-1', + turnIndex: 0, + userMessage: { id: 'user-1', content: 'older prompt', timestamp: 1 }, + }, + latestTurn, + ], + contextRestoreState: 'pending', + isPartial: false, + loadedTurnCount: 2, + totalTurnCount: 2, + }); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + })], + ]), + activeSessionId: 'history-1', + })); + + try { + await flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun'); + + expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1000); + await flushAsyncWork(); + expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(1); + expect(idleCallback).toBeNull(); + + flowChatStore.releaseSessionHistoryCompletionAfterInitialPaint('history-1'); + + expect(idleCallback).toBeTypeOf('function'); + const hydrationPromise = Array.from( + ((flowChatStore as any).fullHistoryHydrationRequests as Map }>).values() + )[0]?.promise; + expect(hydrationPromise).toBeInstanceOf(Promise); + idleCallback?.(); + await hydrationPromise; + + expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(2); + expect( + flowChatStore.getState().sessions.get('history-1')?.dialogTurns.map(turn => turn.userMessage.content) + ).toEqual(['older prompt', 'latest prompt']); + } finally { + (globalThis as any).requestIdleCallback = originalRequestIdleCallback; + (globalThis as any).cancelIdleCallback = originalCancelIdleCallback; + vi.useRealTimers(); + } + }); + + it('delays partial history completion when idle callback is unavailable', async () => { + vi.useFakeTimers(); + const originalRequestIdleCallback = (globalThis as any).requestIdleCallback; + const originalCancelIdleCallback = (globalThis as any).cancelIdleCallback; + delete (globalThis as any).requestIdleCallback; + delete (globalThis as any).cancelIdleCallback; + + const latestTurn = { + turnId: 'turn-2', + turnIndex: 1, + sessionId: 'history-1', + timestamp: 2, + userMessage: { id: 'user-2', content: 'latest prompt', timestamp: 2 }, + modelRounds: [], + startTime: 2, + status: 'completed', + }; + apiMocks.restoreSessionView + .mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 2, + createdAt: 1, + }, + turns: [latestTurn], + contextRestoreState: 'pending', + isPartial: true, + loadedTurnCount: 1, + totalTurnCount: 2, + }) + .mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 2, + createdAt: 1, + }, + turns: [ + { + ...latestTurn, + turnId: 'turn-1', + turnIndex: 0, + userMessage: { id: 'user-1', content: 'older prompt', timestamp: 1 }, + }, + latestTurn, + ], + contextRestoreState: 'pending', + isPartial: false, + loadedTurnCount: 2, + totalTurnCount: 2, + }); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + })], + ]), + activeSessionId: 'history-1', + })); + + try { + await flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun'); + + expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(2499); + await flushAsyncWork(); + expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(1); + + flowChatStore.releaseSessionHistoryCompletionAfterInitialPaint('history-1'); + + await vi.advanceTimersByTimeAsync(1499); + await flushAsyncWork(); + expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(1); + + const hydrationPromise = Array.from( + ((flowChatStore as any).fullHistoryHydrationRequests as Map }>).values() + )[0]?.promise; + expect(hydrationPromise).toBeInstanceOf(Promise); + + await vi.advanceTimersByTimeAsync(1); + await hydrationPromise; + + expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(2); + expect( + flowChatStore.getState().sessions.get('history-1')?.dialogTurns.map(turn => turn.userMessage.content) + ).toEqual(['older prompt', 'latest prompt']); } finally { + (globalThis as any).requestIdleCallback = originalRequestIdleCallback; + (globalThis as any).cancelIdleCallback = originalCancelIdleCallback; vi.useRealTimers(); } }); @@ -1125,4 +1488,78 @@ describe('FlowChatStore historical session hydration state', () => { contextRestoreState: 'pending', }); }); + + it('records scalar restore timing fields for historical session diagnostics', async () => { + const restoredTurn = { + turnId: 'turn-1', + turnIndex: 0, + sessionId: 'history-1', + timestamp: 1, + userMessage: { id: 'user-1', content: 'hello', timestamp: 1 }, + modelRounds: [], + startTime: 1, + status: 'completed', + }; + apiMocks.restoreSessionView.mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 1, + createdAt: 1, + }, + turns: [restoredTurn], + contextRestoreState: 'pending', + isPartial: true, + loadedTurnCount: 1, + totalTurnCount: 2, + timings: { + resolveStoragePathDurationMs: 1, + visibilityMetadataDurationMs: 2, + loadSessionWithTurnsDurationMs: 37, + normalizeTurnIdsDurationMs: 4, + totalDurationMs: 44, + turnLoad: { + requestedTailTurnCount: 8, + loadedTurnCount: 1, + totalTurnCount: 2, + turnFileCount: 2, + missingTurnFileCount: 0, + fastPath: false, + metadataDurationMs: 5, + stateDurationMs: 6, + scanDurationMs: 7, + readDurationMs: 8, + maxTurnReadDurationMs: 9, + buildSessionDurationMs: 10, + totalDurationMs: 36, + }, + }, + }); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + })], + ]), + activeSessionId: 'history-1', + })); + + await flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun'); + + const restoreEvents = startupTrace.getSnapshot().phases.events + .filter(event => event.phase === 'historical_session_restore_end'); + expect(restoreEvents[restoreEvents.length - 1]).toMatchObject({ + restoreTotalDurationMs: 44, + restoreLoadSessionWithTurnsDurationMs: 37, + restoreTurnReadDurationMs: 8, + restoreTurnMaxReadDurationMs: 9, + restoreTurnLoadedCount: 1, + restoreTurnTotalCount: 2, + restoreTurnFastPath: false, + }); + }); }); diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index c961ef7c9..efc3a5cd0 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -27,7 +27,10 @@ import { import { elapsedMs, nowMs } from '@/shared/utils/timing'; import { i18nService } from '@/infrastructure/i18n/core/I18nService'; import type { DialogTurnData, LocalCommandMetadata, SessionKind } from '@/shared/types/session-history'; -import type { SessionInfo as AgentSessionInfo } from '@/infrastructure/api/service-api/AgentAPI'; +import type { + SessionInfo as AgentSessionInfo, + SessionViewRestoreTiming, +} from '@/infrastructure/api/service-api/AgentAPI'; import type { SessionMetadataPage } from '@/infrastructure/api/service-api/SessionAPI'; import { deriveLastFinishedAtFromMetadata, @@ -67,8 +70,10 @@ const VALID_AGENT_TYPES = new Set([ 'DeepResearch', ]); const METADATA_LIST_RECENT_DEDUPE_TTL_MS = 1000; -const HISTORICAL_SESSION_INITIAL_TAIL_TURN_COUNT = 3; -const HISTORICAL_SESSION_FULL_HISTORY_DELAY_MS = 150; +const HISTORICAL_SESSION_INITIAL_REMOTE_TAIL_TURN_COUNT = 3; +const HISTORICAL_SESSION_INITIAL_LOCAL_TAIL_TURN_COUNT = 8; +const HISTORICAL_SESSION_FULL_HISTORY_IDLE_TIMEOUT_MS = 1500; +const HISTORICAL_SESSION_FULL_HISTORY_FIRST_PAINT_TIMEOUT_MS = 2500; interface MetadataListRequest { promise: Promise; @@ -83,8 +88,10 @@ interface MetadataPageRequest { } interface FullHistoryHydrationRequest { + sessionId: string; promise: Promise; - timer?: ReturnType; + cancel?: () => void; + releaseAfterInitialPaint?: () => void; } interface CompleteSessionHistoryLoadRequest { @@ -101,6 +108,112 @@ function areStringArraysEqual(left: string[], right: string[]): boolean { return left.length === right.length && left.every((value, index) => value === right[index]); } +function startsWithStringArray(values: string[], prefix: string[]): boolean { + return values.length >= prefix.length && prefix.every((value, index) => values[index] === value); +} + +function scheduleHistoricalSessionFullHydrate(callback: () => void): () => void { + let cancelled = false; + const run = () => { + if (cancelled) { + return; + } + callback(); + }; + + const requestIdleCallback = (globalThis as { + requestIdleCallback?: (callback: () => void, options?: { timeout?: number }) => number; + }).requestIdleCallback; + const cancelIdleCallback = (globalThis as { + cancelIdleCallback?: (handle: number) => void; + }).cancelIdleCallback; + + if (typeof requestIdleCallback === 'function') { + const handle = requestIdleCallback(run, { + timeout: HISTORICAL_SESSION_FULL_HISTORY_IDLE_TIMEOUT_MS, + }); + return () => { + cancelled = true; + cancelIdleCallback?.(handle); + }; + } + + const timer = globalThis.setTimeout(run, HISTORICAL_SESSION_FULL_HISTORY_IDLE_TIMEOUT_MS); + return () => { + cancelled = true; + globalThis.clearTimeout(timer); + }; +} + +function scheduleLocalHistoricalSessionFullHydrate( + callback: (reason: 'initial_paint' | 'timeout') => void, +): { + cancel: () => void; + releaseAfterInitialPaint: () => void; +} { + let cancelled = false; + let started = false; + let cancelIdle: (() => void) | undefined; + const timeout = globalThis.setTimeout( + () => start('timeout'), + HISTORICAL_SESSION_FULL_HISTORY_FIRST_PAINT_TIMEOUT_MS, + ); + + function start(reason: 'initial_paint' | 'timeout') { + if (cancelled || started) { + return; + } + + started = true; + globalThis.clearTimeout(timeout); + cancelIdle = scheduleHistoricalSessionFullHydrate(() => callback(reason)); + } + + return { + cancel: () => { + cancelled = true; + globalThis.clearTimeout(timeout); + cancelIdle?.(); + }, + releaseAfterInitialPaint: () => start('initial_paint'), + }; +} + +function historicalSessionInitialTailTurnCount(remote: boolean): number { + return remote + ? HISTORICAL_SESSION_INITIAL_REMOTE_TAIL_TURN_COUNT + : HISTORICAL_SESSION_INITIAL_LOCAL_TAIL_TURN_COUNT; +} + +function sessionViewRestoreTimingTraceFields( + timing: SessionViewRestoreTiming | undefined, +): Record { + if (!timing) { + return {}; + } + + return { + restoreResolveStorageDurationMs: timing.resolveStoragePathDurationMs, + restoreVisibilityMetadataDurationMs: timing.visibilityMetadataDurationMs, + restoreLoadSessionWithTurnsDurationMs: timing.loadSessionWithTurnsDurationMs, + restoreNormalizeTurnIdsDurationMs: timing.normalizeTurnIdsDurationMs, + restoreTotalDurationMs: timing.totalDurationMs, + restoreTurnTailCount: timing.turnLoad?.requestedTailTurnCount, + restoreTurnLoadedCount: timing.turnLoad?.loadedTurnCount, + restoreTurnTotalCount: timing.turnLoad?.totalTurnCount, + restoreTurnFileCount: timing.turnLoad?.turnFileCount, + restoreTurnMissingFileCount: timing.turnLoad?.missingTurnFileCount, + restoreTurnFastPath: timing.turnLoad?.fastPath, + restoreTurnMetadataDurationMs: timing.turnLoad?.metadataDurationMs, + restoreTurnStateDurationMs: timing.turnLoad?.stateDurationMs, + restoreTurnScanDurationMs: timing.turnLoad?.scanDurationMs, + restoreTurnReadDurationMs: timing.turnLoad?.readDurationMs, + restoreTurnMaxReadDurationMs: timing.turnLoad?.maxTurnReadDurationMs, + restoreTurnBuildSessionDurationMs: timing.turnLoad?.buildSessionDurationMs, + restoreTurnTotalDurationMs: timing.turnLoad?.totalDurationMs, + }; +} + function isUnsupportedTauriCommandError(error: unknown, command: string): boolean { const anyError = error as any; const originalError = anyError?.context?.originalError; @@ -261,11 +374,18 @@ export class FlowChatStore { remote, sessionTraceId: request.initialSessionTraceId, loadedTurnCount: request.expectedDialogTurnIds.length, + scheduler: remote ? 'idle' : 'after_initial_paint_idle', }); - let timer: ReturnType | undefined; + let cancelScheduled: (() => void) | undefined; + let releaseAfterInitialPaint: (() => void) | undefined; const promise = new Promise(resolve => { - timer = setTimeout(() => { + const startFullHydrate = (trigger: 'idle' | 'initial_paint' | 'timeout') => { + startupTrace.markPhase('historical_session_full_hydrate_released', { + remote, + sessionTraceId: request.initialSessionTraceId, + trigger, + }); void this.completeSessionHistoryLoad(request) .catch(error => { startupTrace.markPhase('historical_session_full_hydrate_failed', { @@ -278,7 +398,16 @@ export class FlowChatStore { }); }) .finally(resolve); - }, HISTORICAL_SESSION_FULL_HISTORY_DELAY_MS); + }; + + if (remote) { + cancelScheduled = scheduleHistoricalSessionFullHydrate(() => startFullHydrate('idle')); + return; + } + + const scheduled = scheduleLocalHistoricalSessionFullHydrate(startFullHydrate); + cancelScheduled = scheduled.cancel; + releaseAfterInitialPaint = scheduled.releaseAfterInitialPaint; }).finally(() => { const currentRequest = this.fullHistoryHydrationRequests.get(requestKey); if (currentRequest?.promise === promise) { @@ -286,7 +415,33 @@ export class FlowChatStore { } }); - this.fullHistoryHydrationRequests.set(requestKey, { promise, timer }); + this.fullHistoryHydrationRequests.set(requestKey, { + sessionId: request.sessionId, + promise, + cancel: () => cancelScheduled?.(), + releaseAfterInitialPaint: () => releaseAfterInitialPaint?.(), + }); + } + + public hasPendingSessionHistoryCompletion(sessionId: string): boolean { + for (const request of this.fullHistoryHydrationRequests.values()) { + if (request.sessionId === sessionId) { + return true; + } + } + return false; + } + + public releaseSessionHistoryCompletionAfterInitialPaint(sessionId: string): boolean { + let released = false; + for (const request of this.fullHistoryHydrationRequests.values()) { + if (request.sessionId !== sessionId) { + continue; + } + request.releaseAfterInitialPaint?.(); + released = true; + } + return released; } private async completeSessionHistoryLoad( @@ -326,21 +481,42 @@ export class FlowChatStore { }); let applied = false; + let preservedTurnCount = 0; this.setState(prev => { const session = prev.sessions.get(request.sessionId); if (!session || session.historyState !== 'ready') { return prev; } - const currentDialogTurnIds = session.dialogTurns.map(turn => turn.id); - if (!areStringArraysEqual(currentDialogTurnIds, request.expectedDialogTurnIds)) { + const currentDialogTurns = session.dialogTurns; + const currentDialogTurnIds = currentDialogTurns.map(turn => turn.id); + const canMergeCurrentTurns = + areStringArraysEqual(currentDialogTurnIds, request.expectedDialogTurnIds) || + startsWithStringArray(currentDialogTurnIds, request.expectedDialogTurnIds); + if (!canMergeCurrentTurns) { return prev; } + const currentDialogTurnsById = new Map(currentDialogTurns.map(turn => [turn.id, turn])); + const restoredDialogTurnIds = new Set(dialogTurns.map(turn => turn.id)); + const appendedCurrentDialogTurns = currentDialogTurns + .slice(request.expectedDialogTurnIds.length) + .filter(turn => !restoredDialogTurnIds.has(turn.id)); + const mergedDialogTurns = [ + ...dialogTurns.map(turn => currentDialogTurnsById.get(turn.id) ?? turn), + ...appendedCurrentDialogTurns, + ]; + preservedTurnCount = mergedDialogTurns.reduce( + (count, turn) => count + (currentDialogTurnsById.get(turn.id) === turn ? 1 : 0), + 0, + ); const newSessions = new Map(prev.sessions); newSessions.set(request.sessionId, { ...session, - dialogTurns, + dialogTurns: mergedDialogTurns, + isPartial: false, + loadedTurnCount: mergedDialogTurns.length, + totalTurnCount: mergedDialogTurns.length, contextRestoreState: session.contextRestoreState === 'ready' ? 'ready' : contextRestoreState, mode: restored.session.agentType || session.mode, @@ -361,6 +537,8 @@ export class FlowChatStore { sessionTraceId: fullTraceId, turnCount: dialogTurns.length, applied, + preservedTurnCount, + ...sessionViewRestoreTimingTraceFields(restored.timings), durationMs: elapsedMs(startedAt), }); if (applied) { @@ -2446,9 +2624,28 @@ export class FlowChatStore { }); try { + const importStartedAt = nowMs(); + startupTrace.markPhase('session_metadata_api_import_start', { + remote, + source: traceSource, + metadataListTraceId, + }); const { sessionAPI } = await import('@/infrastructure/api'); + startupTrace.markPhase('session_metadata_api_import_end', { + remote, + source: traceSource, + metadataListTraceId, + durationMs: elapsedMs(importStartedAt), + }); let page: SessionMetadataPage; + const pageRequestStartedAt = nowMs(); try { + startupTrace.markPhase('session_metadata_page_request_start', { + remote, + source: traceSource, + metadataListTraceId, + command: 'list_persisted_sessions_page', + }); page = await sessionAPI.listSessionsPage({ workspacePath, limit, @@ -2456,12 +2653,42 @@ export class FlowChatStore { remoteConnectionId, remoteSshHost, }); + startupTrace.markPhase('session_metadata_page_request_end', { + remote, + source: traceSource, + metadataListTraceId, + command: 'list_persisted_sessions_page', + durationMs: elapsedMs(pageRequestStartedAt), + }); } catch (error) { if (!isUnsupportedTauriCommandError(error, 'list_persisted_sessions_page')) { + startupTrace.markPhase('session_metadata_page_request_failed', { + remote, + source: traceSource, + metadataListTraceId, + command: 'list_persisted_sessions_page', + durationMs: elapsedMs(pageRequestStartedAt), + }); throw error; } + const fallbackStartedAt = nowMs(); + startupTrace.markPhase('session_metadata_page_request_start', { + remote, + source: traceSource, + metadataListTraceId, + command: 'list_persisted_sessions', + fallback: true, + }); const sessions = await sessionAPI.listSessions(workspacePath, remoteConnectionId, remoteSshHost); + startupTrace.markPhase('session_metadata_page_request_end', { + remote, + source: traceSource, + metadataListTraceId, + command: 'list_persisted_sessions', + fallback: true, + durationMs: elapsedMs(fallbackStartedAt), + }); page = { sessions, totalTopLevelCount: sessions.length, @@ -2756,6 +2983,7 @@ export class FlowChatStore { let restoredHistoryPartial = false; let restoredLoadedTurnCount: number | undefined; let restoredTotalTurnCount: number | undefined; + let restoredTiming: SessionViewRestoreTiming | undefined; if (!isAcpSession) { const restoreStartedAt = nowMs(); startupTrace.markPhase('historical_session_restore_start', { remote, sessionTraceId }); @@ -2827,7 +3055,7 @@ export class FlowChatStore { remoteSshHost, sessionTraceId, options?.includeInternal, - HISTORICAL_SESSION_INITIAL_TAIL_TURN_COUNT, + historicalSessionInitialTailTurnCount(remote), ); restoredSessionInfo = restored.session; turns = restored.turns; @@ -2836,6 +3064,7 @@ export class FlowChatStore { restoredHistoryPartial = restored.isPartial === true; restoredLoadedTurnCount = restored.loadedTurnCount; restoredTotalTurnCount = restored.totalTurnCount; + restoredTiming = restored.timings; } catch (error) { if (!isUnsupportedTauriCommandError(error, 'restore_session_view')) { throw error; @@ -2861,6 +3090,7 @@ export class FlowChatStore { totalTurnCount: restoredTotalTurnCount, isPartial: restoredHistoryPartial, contextRestoreState, + ...sessionViewRestoreTimingTraceFields(restoredTiming), durationMs: elapsedMs(restoreStartedAt), }); } catch (error) { @@ -2920,6 +3150,9 @@ export class FlowChatStore { isHistorical: false, historyState: 'ready' as const, contextRestoreState, + isPartial: restoredHistoryPartial, + loadedTurnCount: restoredLoadedTurnCount ?? dialogTurns.length, + totalTurnCount: restoredTotalTurnCount ?? dialogTurns.length, error: null, mode: restoredSessionInfo?.agentType || session.mode, lastUserDialogMode: restoredLastUserDialogMode, diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts index 291309c8b..2bfa3fd4d 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts @@ -17,7 +17,9 @@ vi.mock('../tool-cards', () => ({ COMMAND_TOOL_NAMES: new Set(['Bash', 'Git']), })); -import { sessionToVirtualItems } from './modernFlowChatStore'; +import { sessionToVirtualItems, type VirtualItem } from './modernFlowChatStore'; + +type ModelRoundVirtualItem = Extract; function makeTextItem(id: string, content: string): FlowTextItem { return { @@ -31,6 +33,12 @@ function makeTextItem(id: string, content: string): FlowTextItem { }; } +function makeTextItems(count: number, prefix = 'text'): FlowTextItem[] { + return Array.from({ length: count }, (_, index) => + makeTextItem(`${prefix}-${index + 1}`, `Assistant response block ${index + 1}`) + ); +} + function makeReadTool(id: string): FlowToolItem { return makeTool(id, 'Read'); } @@ -128,6 +136,35 @@ describe('sessionToVirtualItems explore grouping', () => { expect(items.map(item => item.type)).toEqual(['user-message', 'explore-group']); }); + it('keeps trailing assistant text visible after collapsible tool history', () => { + const round = makeRound({ + id: 'round-with-trailing-answer', + items: [ + makeReadTool('tool-1'), + makeTextItem('text-final', 'Here is the answer after inspecting the files.'), + ], + }); + const session = makeSession({ + sessionId: 'trailing-answer-session', + dialogTurns: [{ + id: 'turn-1', + sessionId: 'trailing-answer-session', + userMessage: { + id: 'user-1', + content: 'Help', + timestamp: 900, + }, + modelRounds: [round], + status: 'completed', + startTime: 900, + }], + }); + + const items = sessionToVirtualItems(session); + + expect(items.map(item => item.type)).toEqual(['user-message', 'model-round']); + }); + it('does not special-case ACP rounds without explicit render hints', () => { const session = makeSession({ sessionId: 'acp-session', @@ -510,6 +547,112 @@ describe('sessionToVirtualItems explore grouping', () => { }); }); + it('splits completed large model rounds into stable virtual chunks', () => { + const largeRound = makeRound({ + id: 'large-round', + items: makeTextItems(25, 'large-text'), + isStreaming: false, + isComplete: true, + status: 'completed', + }); + const session = makeSession({ + sessionId: 'large-round-session', + dialogTurns: [{ + id: 'turn-1', + sessionId: 'large-round-session', + userMessage: { + id: 'user-1', + content: 'Summarize a large trace', + timestamp: 900, + }, + modelRounds: [largeRound], + status: 'completed', + startTime: 900, + }], + }); + + const items = sessionToVirtualItems(session); + const modelItems = items.filter((item): item is ModelRoundVirtualItem => item.type === 'model-round'); + + expect(items.map(item => item.type)).toEqual([ + 'user-message', + 'model-round', + 'model-round', + 'model-round', + 'model-round', + 'model-round', + 'model-round', + 'model-round', + ]); + expect(modelItems.map(item => item.segmentIndex)).toEqual([0, 1, 2, 3, 4, 5, 6]); + expect(modelItems.map(item => item.segmentCount)).toEqual([7, 7, 7, 7, 7, 7, 7]); + expect(modelItems.map(item => item.sourceRoundId)).toEqual([ + 'large-round', + 'large-round', + 'large-round', + 'large-round', + 'large-round', + 'large-round', + 'large-round', + ]); + expect(modelItems.map(item => item.data.id)).toEqual([ + 'large-round:segment:0', + 'large-round:segment:1', + 'large-round:segment:2', + 'large-round:segment:3', + 'large-round:segment:4', + 'large-round:segment:5', + 'large-round:segment:6', + ]); + expect(modelItems.map(item => item.data.items.length)).toEqual([4, 4, 4, 4, 4, 4, 1]); + expect(modelItems.map(item => item.isLastRound)).toEqual([false, false, false, false, false, false, true]); + expect(modelItems[0].data.items[0]?.id).toBe('large-text-1'); + expect(modelItems[6].data.items[0]?.id).toBe('large-text-25'); + }); + + it('does not split active or streaming large model rounds', () => { + const streamingRound = makeRound({ + id: 'streaming-large-round', + items: makeTextItems(25, 'streaming-text'), + isStreaming: true, + isComplete: false, + status: 'streaming', + }); + const session = makeSession({ + sessionId: 'streaming-large-round-session', + dialogTurns: [{ + id: 'turn-1', + sessionId: 'streaming-large-round-session', + userMessage: { + id: 'user-1', + content: 'Continue writing', + timestamp: 900, + }, + modelRounds: [streamingRound], + status: 'processing', + startTime: 900, + }], + }); + + const items = sessionToVirtualItems(session); + const modelItems = items.filter(item => item.type === 'model-round'); + + expect(items.map(item => item.type)).toEqual(['user-message', 'model-round']); + expect(modelItems).toHaveLength(1); + expect(modelItems[0]).toMatchObject({ + type: 'model-round', + data: { + id: 'streaming-large-round', + items: expect.arrayContaining([ + expect.objectContaining({ id: 'streaming-text-1' }), + expect.objectContaining({ id: 'streaming-text-25' }), + ]), + }, + isLastRound: true, + isTurnComplete: false, + }); + }); + it('reuses the projection for completed turns when a later active turn changes', () => { const completedTurn = { id: 'completed-turn', diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts index ad278942f..31f7e4a77 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts @@ -54,7 +54,17 @@ export type VirtualItem = steeringId: string; steeringStatus: FlowUserSteeringItem['status']; } - | { type: 'model-round'; data: ModelRound; turnId: string; isLastRound: boolean; isTurnComplete: boolean } + | { + type: 'model-round'; + data: ModelRound; + turnId: string; + isLastRound: boolean; + isTurnComplete: boolean; + segmentId?: string; + segmentIndex?: number; + segmentCount?: number; + sourceRoundId?: string; + } | { type: 'explore-group'; data: ExploreGroupData; turnId: string } | { type: 'image-analyzing'; turnId: string }; @@ -115,6 +125,23 @@ function hasRecentlyCompletedTool(round: ModelRound, nowMs: number): boolean { }); } +function hasTrailingVisibleText(round: ModelRound): boolean { + for (let index = round.items.length - 1; index >= 0; index -= 1) { + const item = round.items[index]; + if (!item || item.type === 'user-steering') { + continue; + } + + if (item.type !== 'text') { + return false; + } + + return typeof item.content === 'string' && item.content.trim().length > 0; + } + + return false; +} + function isExploreOnlyRound(round: ModelRound, nowMs: number): boolean { if (!round.items || round.items.length === 0) return false; @@ -129,6 +156,10 @@ function isExploreOnlyRound(round: ModelRound, nowMs: number): boolean { if (hasActiveTool(round) || (round.isStreaming && hasRecentlyCompletedTool(round, nowMs))) { return false; } + + if (hasTrailingVisibleText(round)) { + return false; + } const hasCollapsibleTool = round.items.some(item => item.type === 'tool' && isCollapsibleTool((item as FlowToolItem).toolName) @@ -149,6 +180,67 @@ function isExploreOnlyRound(round: ModelRound, nowMs: number): boolean { return allItemsCollapsible; } +const MODEL_ROUND_VIRTUAL_CHUNK_ITEM_LIMIT = 4; +const MODEL_ROUND_VIRTUAL_CHUNK_THRESHOLD = MODEL_ROUND_VIRTUAL_CHUNK_ITEM_LIMIT * 3; + +interface ModelRoundVirtualChunk { + round: ModelRound; + segmentId?: string; + segmentIndex?: number; + segmentCount?: number; + sourceRoundId?: string; +} + +function shouldSplitModelRoundForVirtualItems( + round: ModelRound, + isTurnComplete: boolean, + nowMs: number, +): boolean { + return ( + isTurnComplete && + isTerminalRoundStatus(round.status) && + !round.isStreaming && + round.isComplete !== false && + round.items.length > MODEL_ROUND_VIRTUAL_CHUNK_THRESHOLD && + !hasActiveTool(round) && + !hasRecentlyCompletedTool(round, nowMs) && + round.items.every(item => !isActiveFlowItem(item)) + ); +} + +function splitModelRoundForVirtualItems( + round: ModelRound, + isTurnComplete: boolean, + nowMs: number, +): ModelRoundVirtualChunk[] { + if (!shouldSplitModelRoundForVirtualItems(round, isTurnComplete, nowMs)) { + return [{ round }]; + } + + const segmentCount = Math.ceil(round.items.length / MODEL_ROUND_VIRTUAL_CHUNK_ITEM_LIMIT); + const chunks: ModelRoundVirtualChunk[] = []; + + for (let segmentIndex = 0; segmentIndex < segmentCount; segmentIndex += 1) { + const start = segmentIndex * MODEL_ROUND_VIRTUAL_CHUNK_ITEM_LIMIT; + const end = start + MODEL_ROUND_VIRTUAL_CHUNK_ITEM_LIMIT; + const segmentId = `${round.id}:segment:${segmentIndex}`; + + chunks.push({ + round: { + ...round, + id: segmentId, + items: round.items.slice(start, end), + }, + segmentId, + segmentIndex, + segmentCount, + sourceRoundId: round.id, + }); + } + + return chunks; +} + /** * Compute statistics for a single ModelRound */ @@ -411,12 +503,19 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { groupIndex++; } else { const isLastRound = roundIndex === rounds.length - 1; - items.push({ - type: 'model-round', - data: round, - turnId: turn.id, - isLastRound, - isTurnComplete, + const roundChunks = splitModelRoundForVirtualItems(round, isTurnComplete, nowMs); + roundChunks.forEach((chunk, chunkIndex) => { + items.push({ + type: 'model-round', + data: chunk.round, + turnId: turn.id, + isLastRound: isLastRound && chunkIndex === roundChunks.length - 1, + isTurnComplete, + segmentId: chunk.segmentId, + segmentIndex: chunk.segmentIndex, + segmentCount: chunk.segmentCount, + sourceRoundId: chunk.sourceRoundId, + }); }); roundIndex++; } diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index f214ae89a..d34225227 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -308,6 +308,15 @@ export interface Session { * lazily; message sending must ensure this becomes 'ready' first. */ contextRestoreState?: SessionContextRestoreState; + + /** + * True when the session currently contains only a tail preview of persisted + * history. Destructive history actions must wait for the full history hydrate + * so UI indexes cannot drift from persisted backend turn indexes. + */ + isPartial?: boolean; + loadedTurnCount?: number; + totalTurnCount?: number; todos?: TodoItem[]; diff --git a/src/web-ui/src/infrastructure/api/adapters/base.ts b/src/web-ui/src/infrastructure/api/adapters/base.ts index 31e55828f..a29b65fb3 100644 --- a/src/web-ui/src/infrastructure/api/adapters/base.ts +++ b/src/web-ui/src/infrastructure/api/adapters/base.ts @@ -6,7 +6,7 @@ export interface ITransportAdapter { connect(): Promise; - request(action: string, params?: any): Promise; + request(action: string, params?: any, timing?: TransportRequestTiming): Promise; listen(event: string, callback: (data: T) => void): () => void; @@ -18,6 +18,12 @@ export interface ITransportAdapter { isConnected(): boolean; } +export interface TransportRequestTiming { + adapterInitDurationMs?: number; + invokeDurationMs?: number; + transportDurationMs?: number; +} + export interface StreamEvent { type: 'text-chunk' | 'tool-event' | 'stream-start' | 'stream-end' | string; diff --git a/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.test.ts b/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.test.ts index bc7b0af8e..935c15d83 100644 --- a/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.test.ts +++ b/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.test.ts @@ -1,7 +1,21 @@ -import { describe, expect, it } from 'vitest'; -import { isExpectedTauriRequestError } from './tauri-adapter'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { isExpectedTauriRequestError, TauriTransportAdapter } from './tauri-adapter'; + +const invokeMock = vi.hoisted(() => vi.fn()); + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: invokeMock, +})); + +vi.mock('@tauri-apps/api/event', () => ({ + listen: vi.fn(), +})); describe('Tauri adapter expected errors', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it('classifies optional get_config not found as expected', () => { expect(isExpectedTauriRequestError( 'get_config', @@ -26,4 +40,25 @@ describe('Tauri adapter expected errors', () => { new Error("Config path not found: 'font'") )).toBe(false); }); + + it('records adapter init and invoke timings for each request', async () => { + invokeMock.mockResolvedValueOnce({ ok: true }); + const adapter = new TauriTransportAdapter(); + const timing: { + adapterInitDurationMs?: number; + invokeDurationMs?: number; + transportDurationMs?: number; + } = {}; + + await expect(adapter.request('list_persisted_sessions_page', { + request: { limit: 5 }, + }, timing)).resolves.toEqual({ ok: true }); + + expect(invokeMock).toHaveBeenCalledWith('list_persisted_sessions_page', { + request: { limit: 5 }, + }); + expect(timing.adapterInitDurationMs).toEqual(expect.any(Number)); + expect(timing.invokeDurationMs).toEqual(expect.any(Number)); + expect(timing.transportDurationMs).toEqual(expect.any(Number)); + }); }); diff --git a/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.ts index 92ecfbded..db91bdad2 100644 --- a/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.ts @@ -1,7 +1,8 @@ import { listen, UnlistenFn } from '@tauri-apps/api/event'; -import { ITransportAdapter } from './base'; +import { elapsedMs, nowMs } from '@/shared/utils/timing'; +import { ITransportAdapter, type TransportRequestTiming } from './base'; import { createLogger } from '@/shared/utils/logger'; import { sanitizeErrorForLog } from '../logSanitizer'; @@ -71,23 +72,36 @@ export class TauriTransportAdapter implements ITransportAdapter { this.connected = true; } - async request(action: string, params?: any): Promise { + async request(action: string, params?: any, timing?: TransportRequestTiming): Promise { + const transportStartedAt = nowMs(); if (!this.connected) { await this.connect(); } + const adapterInitStartedAt = nowMs(); await this.ensureInitialized(); + if (timing) { + timing.adapterInitDurationMs = elapsedMs(adapterInitStartedAt); + } try { if (!this.invokeFn) { throw new Error('Tauri invoke function not initialized'); } + const invokeStartedAt = nowMs(); const result = params !== undefined ? await this.invokeFn(action, params) : await this.invokeFn(action); + if (timing) { + timing.invokeDurationMs = elapsedMs(invokeStartedAt); + timing.transportDurationMs = elapsedMs(transportStartedAt); + } return result as T; } catch (error) { + if (timing) { + timing.transportDurationMs = elapsedMs(transportStartedAt); + } if (!isExpectedTauriRequestError(action, params, error)) { log.error('Request failed', { action, error: sanitizeErrorForLog(error) }); } diff --git a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts index 9c7802477..25e458ce1 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -103,6 +103,31 @@ export interface RestoreSessionWithTurnsResponse { turns: DialogTurnData[]; } +export interface SessionTurnLoadTiming { + requestedTailTurnCount?: number; + loadedTurnCount: number; + totalTurnCount: number; + turnFileCount: number; + missingTurnFileCount: number; + fastPath: boolean; + metadataDurationMs: number; + stateDurationMs: number; + scanDurationMs: number; + readDurationMs: number; + maxTurnReadDurationMs: number; + buildSessionDurationMs: number; + totalDurationMs: number; +} + +export interface SessionViewRestoreTiming { + resolveStoragePathDurationMs: number; + visibilityMetadataDurationMs: number; + loadSessionWithTurnsDurationMs: number; + normalizeTurnIdsDurationMs: number; + totalDurationMs: number; + turnLoad: SessionTurnLoadTiming; +} + export interface RestoreSessionViewResponse { session: SessionInfo; turns: DialogTurnData[]; @@ -110,6 +135,7 @@ export interface RestoreSessionViewResponse { isPartial?: boolean; loadedTurnCount?: number; totalTurnCount?: number; + timings?: SessionViewRestoreTiming; } export interface EnsureAssistantBootstrapRequest { diff --git a/src/web-ui/src/infrastructure/api/service-api/ApiClient.test.ts b/src/web-ui/src/infrastructure/api/service-api/ApiClient.test.ts index 3452d0bb2..42e453370 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ApiClient.test.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ApiClient.test.ts @@ -37,6 +37,7 @@ vi.mock('@/shared/utils/startupTrace', () => ({ describe('ApiClient startup trace classification', () => { beforeEach(() => { vi.clearAllMocks(); + delete globalThis.__BITFUN_PERF_TRACE_ENABLED__; }); it('does not record optional get_config not found as a startup failure', async () => { @@ -64,7 +65,28 @@ describe('ApiClient startup trace classification', () => { expect(loggerMocks.error).not.toHaveBeenCalled(); }); - it('uses a bounded response estimate cap for session view restore', async () => { + it('does not estimate payload bytes by default', async () => { + adapterMocks.request.mockResolvedValueOnce({ turns: [] }); + const client = new ApiClient({ enableLogging: false, retries: 0 }); + + await client.invoke('restore_session_view', { + request: { + sessionId: 'history-1', + workspacePath: 'D:/workspace/BitFun', + }, + }); + + expect(traceMocks.estimateJsonBytes).not.toHaveBeenCalled(); + expect(traceMocks.recordApiCall).toHaveBeenCalledWith(expect.objectContaining({ + command: 'restore_session_view', + requestBytes: undefined, + responseBytes: undefined, + payloadEstimateDurationMs: undefined, + })); + }); + + it('uses a bounded response estimate cap for session view restore when perf trace is enabled', async () => { + globalThis.__BITFUN_PERF_TRACE_ENABLED__ = true; adapterMocks.request.mockResolvedValueOnce({ turns: [] }); const client = new ApiClient({ enableLogging: false, retries: 0 }); @@ -80,4 +102,52 @@ describe('ApiClient startup trace classification', () => { 2 * 1024 * 1024 ); }); + + it('records request boundary timings and active request pressure', async () => { + let releaseFirstRequest!: () => void; + adapterMocks.request + .mockImplementationOnce((_command, _args, timing) => new Promise(resolve => { + Object.assign(timing, { + adapterInitDurationMs: 1, + invokeDurationMs: 10, + transportDurationMs: 11, + }); + releaseFirstRequest = resolve; + })) + .mockImplementationOnce((_command, _args, timing) => { + Object.assign(timing, { + adapterInitDurationMs: 2, + invokeDurationMs: 20, + transportDurationMs: 22, + }); + return Promise.resolve({ ok: true }); + }); + const client = new ApiClient({ enableLogging: false, retries: 0 }); + + const firstRequest = client.invoke('get_config', { + request: { path: 'app.keybindings' }, + }); + const secondRequest = client.invoke('list_persisted_sessions_page', { + request: { + workspacePath: 'D:/workspace/BitFun', + limit: 5, + }, + }); + + await secondRequest; + releaseFirstRequest(); + await firstRequest; + + expect(traceMocks.recordApiCall).toHaveBeenCalledWith(expect.objectContaining({ + command: 'list_persisted_sessions_page', + requestPayloadEstimateDurationMs: undefined, + responsePayloadEstimateDurationMs: undefined, + payloadEstimateDurationMs: undefined, + adapterInitDurationMs: expect.any(Number), + transportDurationMs: expect.any(Number), + activeRequestsAtStart: 1, + activeRequestsAtEnd: 1, + maxConcurrentRequests: 2, + })); + }); }); diff --git a/src/web-ui/src/infrastructure/api/service-api/ApiClient.ts b/src/web-ui/src/infrastructure/api/service-api/ApiClient.ts index 68471c75f..3d0764d43 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ApiClient.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ApiClient.ts @@ -1,6 +1,6 @@ -import { getTransportAdapter, ITransportAdapter } from '../adapters'; +import { getTransportAdapter, ITransportAdapter, type TransportRequestTiming } from '../adapters'; import { IApiClient, ApiResponse, @@ -30,6 +30,10 @@ function responseEstimateMaxBytes(command: string): number | undefined { : undefined; } +function shouldEstimateApiPayloadBytes(): boolean { + return globalThis.__BITFUN_PERF_TRACE_ENABLED__ === true; +} + function isOptionalConfigNotFoundCommand(config: TauriCommandConfig, error: unknown): boolean { if (config.command !== 'get_config') { return false; @@ -83,6 +87,7 @@ function traceTargetForCommand(command: string, payload: unknown): string | unde export class ApiClient implements IApiClient { private config: ApiConfig; private activeRequests = new Map(); + private activeRequestPressure = new Map(); private stats: ApiStats = { totalRequests: 0, successfulRequests: 0, @@ -189,7 +194,7 @@ export class ApiClient implements IApiClient { private async executeRequest(request: ApiRequest): Promise { const startedAt = nowMs(); - const tracePayloadStartedAt = nowMs(); + const estimatePayloadBytes = shouldEstimateApiPayloadBytes(); const tracePayload = request.type === 'tauri' ? (request.config as TauriCommandConfig).args : { @@ -199,17 +204,30 @@ export class ApiClient implements IApiClient { const traceCommand = request.type === 'tauri' ? (request.config as TauriCommandConfig).command : `${(request.config as HttpRequestConfig).method} ${(request.config as HttpRequestConfig).url}`; - const requestBytes = estimateJsonBytes(tracePayload); + const tracePayloadStartedAt = estimatePayloadBytes ? nowMs() : undefined; + const requestBytes = estimatePayloadBytes ? estimateJsonBytes(tracePayload) : undefined; const remote = isRemoteTraceRequest(tracePayload); const traceTarget = traceTargetForCommand(traceCommand, tracePayload); - const requestPayloadEstimateDurationMs = elapsedMs(tracePayloadStartedAt); + const requestPayloadEstimateDurationMs = tracePayloadStartedAt !== undefined + ? elapsedMs(tracePayloadStartedAt) + : undefined; + let activeRequestsAtStart = 0; + let activeRequestsAtEnd = 0; + let maxConcurrentRequests = 0; + let transportTiming: TransportRequestTiming | undefined; this.updateStats({ totalRequests: this.stats.totalRequests + 1 }); try { const controller = new AbortController(); + activeRequestsAtStart = this.activeRequests.size; this.activeRequests.set(request.id, controller); + const pressure = { maxConcurrentRequests: this.activeRequests.size }; + this.activeRequestPressure.set(request.id, pressure); + this.activeRequestPressure.forEach(item => { + item.maxConcurrentRequests = Math.max(item.maxConcurrentRequests, this.activeRequests.size); + }); const timeoutId = setTimeout(() => { @@ -220,23 +238,35 @@ export class ApiClient implements IApiClient { const response = await this.applyMiddleware(request, async (req) => { if (req.type === 'tauri') { - return this.executeTauriCommand(req.config as TauriCommandConfig); + transportTiming = {}; + return this.executeTauriCommand(req.config as TauriCommandConfig, transportTiming); } else { return this.executeHttpRequest(req.config as HttpRequestConfig, controller.signal); } }); clearTimeout(timeoutId); + maxConcurrentRequests = this.activeRequestPressure.get(request.id)?.maxConcurrentRequests ?? this.activeRequests.size; this.activeRequests.delete(request.id); + this.activeRequestPressure.delete(request.id); + activeRequestsAtEnd = this.activeRequests.size; const durationMs = elapsedMs(startedAt); - const responseEstimateStartedAt = nowMs(); - const responseBytes = estimateJsonBytes( - response.data, - responseEstimateMaxBytes(traceCommand) - ); - const responseEstimateDurationMs = elapsedMs(responseEstimateStartedAt); + const responseEstimateStartedAt = estimatePayloadBytes ? nowMs() : undefined; + const responseBytes = estimatePayloadBytes + ? estimateJsonBytes( + response.data, + responseEstimateMaxBytes(traceCommand) + ) + : undefined; + const responseEstimateDurationMs = responseEstimateStartedAt !== undefined + ? elapsedMs(responseEstimateStartedAt) + : undefined; + const payloadEstimateDurationMs = requestPayloadEstimateDurationMs !== undefined || + responseEstimateDurationMs !== undefined + ? (requestPayloadEstimateDurationMs ?? 0) + (responseEstimateDurationMs ?? 0) + : undefined; startupTrace.recordApiCall({ type: request.type, command: traceCommand, @@ -247,7 +277,15 @@ export class ApiClient implements IApiClient { outcome: 'success', requestBytes, responseBytes, - payloadEstimateDurationMs: requestPayloadEstimateDurationMs + responseEstimateDurationMs, + payloadEstimateDurationMs, + requestPayloadEstimateDurationMs, + responsePayloadEstimateDurationMs: responseEstimateDurationMs, + adapterInitDurationMs: transportTiming?.adapterInitDurationMs, + transportDurationMs: transportTiming?.transportDurationMs, + invokeDurationMs: transportTiming?.invokeDurationMs, + activeRequestsAtStart, + activeRequestsAtEnd, + maxConcurrentRequests, remote, }); this.recordResponseTime(durationMs); @@ -264,8 +302,13 @@ export class ApiClient implements IApiClient { return response.data; } finally { + maxConcurrentRequests = maxConcurrentRequests || + this.activeRequestPressure.get(request.id)?.maxConcurrentRequests || + this.activeRequests.size; clearTimeout(timeoutId); this.activeRequests.delete(request.id); + this.activeRequestPressure.delete(request.id); + activeRequestsAtEnd = this.activeRequests.size; } } catch (error) { const optionalConfigNotFound = isOptionalConfigNotFound(request, error); @@ -282,6 +325,13 @@ export class ApiClient implements IApiClient { outcome: optionalConfigNotFound ? 'success' : 'failure', requestBytes, payloadEstimateDurationMs: requestPayloadEstimateDurationMs, + requestPayloadEstimateDurationMs, + adapterInitDurationMs: transportTiming?.adapterInitDurationMs, + transportDurationMs: transportTiming?.transportDurationMs, + invokeDurationMs: transportTiming?.invokeDurationMs, + activeRequestsAtStart, + activeRequestsAtEnd, + maxConcurrentRequests, remote, }); @@ -318,11 +368,13 @@ export class ApiClient implements IApiClient { } } - private async executeTauriCommand(config: TauriCommandConfig): Promise { + private async executeTauriCommand( + config: TauriCommandConfig, + transportTiming?: TransportRequestTiming + ): Promise { try { - - const data = await this.adapter.request(config.command, config.args || {}); + const data = await this.adapter.request(config.command, config.args || {}, transportTiming); return { success: true, diff --git a/src/web-ui/src/infrastructure/contexts/WorkspaceProvider.tsx b/src/web-ui/src/infrastructure/contexts/WorkspaceProvider.tsx index 03d0fda6a..a9d9639b5 100644 --- a/src/web-ui/src/infrastructure/contexts/WorkspaceProvider.tsx +++ b/src/web-ui/src/infrastructure/contexts/WorkspaceProvider.tsx @@ -120,6 +120,14 @@ export const WorkspaceProvider: React.FC = ({ children } const isInitializedRef = useRef(false); + useEffect(() => { + startupTrace.markPhase('workspace_context_state_committed', { + loading: state.loading, + openedCount: state.openedWorkspacesList.length, + hasActiveWorkspace: state.activeWorkspace !== null, + }); + }, [state.activeWorkspace, state.loading, state.openedWorkspacesList.length]); + useEffect(() => { const removeListener = workspaceManager.addEventListener(() => { setState((prev) => { diff --git a/src/web-ui/src/infrastructure/theme/core/ThemeService.test.ts b/src/web-ui/src/infrastructure/theme/core/ThemeService.test.ts index d538b3a4d..8a10e3f44 100644 --- a/src/web-ui/src/infrastructure/theme/core/ThemeService.test.ts +++ b/src/web-ui/src/infrastructure/theme/core/ThemeService.test.ts @@ -1,6 +1,9 @@ import { JSDOM } from 'jsdom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { configAPI } from '@/infrastructure/api'; +import { bitfunLightTheme } from '../presets'; +import type { ThemeConfig } from '../types'; import { ThemeService } from './ThemeService'; vi.mock('@/infrastructure/api', () => ({ @@ -27,6 +30,10 @@ vi.mock('@/shared/utils/logger', () => ({ describe('ThemeService flow chat link tokens', () => { let dom: JSDOM; + const bootstrapGlobals = globalThis as typeof globalThis & { + __BITFUN_BOOTSTRAP_THEME_ID__?: string; + __BITFUN_BOOTSTRAP_THEME_SELECTION__?: string; + }; beforeEach(() => { dom = new JSDOM(''); @@ -40,6 +47,10 @@ describe('ThemeService flow chat link tokens', () => { removeEventListener: vi.fn(), }), }); + delete bootstrapGlobals.__BITFUN_BOOTSTRAP_THEME_ID__; + delete bootstrapGlobals.__BITFUN_BOOTSTRAP_THEME_SELECTION__; + vi.mocked(configAPI.getConfig).mockResolvedValue(undefined); + vi.mocked(configAPI.setConfig).mockResolvedValue(undefined); }); afterEach(() => { @@ -68,4 +79,115 @@ describe('ThemeService flow chat link tokens', () => { expect(rootStyle.getPropertyValue('--flowchat-link-color')).toBe('#60a5fa'); expect(rootStyle.getPropertyValue('--flowchat-link-hover-color')).toBe('#93c5fd'); }); + + it('initializes from bootstrap theme selection without reading or writing themes.current', async () => { + bootstrapGlobals.__BITFUN_BOOTSTRAP_THEME_ID__ = 'bitfun-slate'; + bootstrapGlobals.__BITFUN_BOOTSTRAP_THEME_SELECTION__ = 'bitfun-slate'; + const service = new ThemeService(); + + await service.initialize(); + + expect(service.getCurrentThemeId()).toBe('bitfun-slate'); + expect(document.documentElement.getAttribute('data-theme')).toBe('bitfun-slate'); + expect(configAPI.getConfig).not.toHaveBeenCalled(); + expect(configAPI.getConfig).not.toHaveBeenCalledWith( + 'themes.current', + expect.anything(), + ); + expect(configAPI.setConfig).not.toHaveBeenCalledWith( + 'themes.current', + expect.anything(), + ); + }); + + it('loads custom themes on demand after initialization and deduplicates repeated loads', async () => { + bootstrapGlobals.__BITFUN_BOOTSTRAP_THEME_ID__ = 'bitfun-slate'; + bootstrapGlobals.__BITFUN_BOOTSTRAP_THEME_SELECTION__ = 'bitfun-slate'; + const service = new ThemeService(); + await service.initialize(); + + await service.ensureUserThemesLoaded(); + await service.ensureUserThemesLoaded(); + + expect(configAPI.getConfig).toHaveBeenCalledTimes(1); + expect(configAPI.getConfig).toHaveBeenCalledWith( + 'themes', + expect.objectContaining({ skipRetryOnNotFound: true }), + ); + }); + + it('falls back to config lookup when bootstrap theme selection is unavailable', async () => { + bootstrapGlobals.__BITFUN_BOOTSTRAP_THEME_ID__ = 'bitfun-light'; + vi.mocked(configAPI.getConfig).mockImplementation(async (key: string) => { + if (key === 'themes.current') { + return 'bitfun-slate'; + } + return undefined; + }); + const service = new ThemeService(); + + await service.initialize(); + + expect(service.getCurrentThemeId()).toBe('bitfun-slate'); + expect(configAPI.getConfig).toHaveBeenCalledWith( + 'themes.current', + expect.objectContaining({ skipRetryOnNotFound: true }), + ); + }); + + it('applies saved custom theme during initialization when bootstrap cannot provide it', async () => { + const customTheme: ThemeConfig = { + ...bitfunLightTheme, + id: 'custom-ocean', + name: 'Custom Ocean', + colors: { + ...bitfunLightTheme.colors, + background: { + ...bitfunLightTheme.colors.background, + primary: '#001122', + }, + }, + }; + vi.mocked(configAPI.getConfig).mockImplementation(async (key: string) => { + if (key === 'themes.current') { + return 'custom-ocean'; + } + if (key === 'themes') { + return { custom: [customTheme] }; + } + return undefined; + }); + const service = new ThemeService(); + + await service.initialize(); + await service.ensureUserThemesLoaded(); + + expect(service.getCurrentThemeId()).toBe('custom-ocean'); + expect(service.getResolvedThemeId()).toBe('custom-ocean'); + expect(document.documentElement.getAttribute('data-theme')).toBe('custom-ocean'); + expect(document.documentElement.style.getPropertyValue('--color-bg-primary')).toBe('#001122'); + expect(configAPI.getConfig).toHaveBeenCalledWith( + 'themes', + expect.objectContaining({ skipRetryOnNotFound: true }), + ); + expect(vi.mocked(configAPI.getConfig).mock.calls.filter(([key]) => key === 'themes')).toHaveLength(1); + expect(configAPI.setConfig).not.toHaveBeenCalledWith('themes.current', 'custom-ocean'); + }); + + it('does not persist the theme selection again during initialization', async () => { + vi.mocked(configAPI.getConfig).mockImplementation(async (key: string) => { + if (key === 'themes.current') { + return 'bitfun-slate'; + } + return undefined; + }); + const service = new ThemeService(); + + await service.initialize(); + + expect(configAPI.setConfig).not.toHaveBeenCalledWith( + 'themes.current', + expect.anything(), + ); + }); }); diff --git a/src/web-ui/src/infrastructure/theme/core/ThemeService.ts b/src/web-ui/src/infrastructure/theme/core/ThemeService.ts index 73cde5c61..07193d3d0 100644 --- a/src/web-ui/src/infrastructure/theme/core/ThemeService.ts +++ b/src/web-ui/src/infrastructure/theme/core/ThemeService.ts @@ -31,6 +31,14 @@ const FLOW_CHAT_LINK_COLORS = { }, } as const; +declare global { + // Injected by the desktop webview initialization script. These values let the + // first renderer pass apply the persisted built-in theme without waiting on a + // Tauri config round trip. They are absent on plain web/F5 fallback paths. + var __BITFUN_BOOTSTRAP_THEME_ID__: string | undefined; + var __BITFUN_BOOTSTRAP_THEME_SELECTION__: string | undefined; +} + /** Space-separated R G B for `rgba(var(--color-primary-rgb) / α)` in component styles. */ function accentColorToRgbChannels(accent: string): string | null { const trimmed = accent.trim(); @@ -59,6 +67,9 @@ export class ThemeService { private listeners: Map> = new Map(); private hooks: ThemeHooks = {}; private initialized = false; + private userThemesLoaded = false; + private userThemesLoadPromise: Promise | null = null; + private pendingUserThemeSelection: ThemeId | null = null; constructor() { this.initializeBuiltinThemes(); @@ -79,29 +90,90 @@ export class ThemeService { if (this.initialized) return; this.initialized = true; try { + const bootstrapSelection = this.getBootstrapThemeSelection(); + if (bootstrapSelection) { + await this.applyThemeSelection(bootstrapSelection, { persist: false }); + return; + } + const saved = await this.loadThemeSelection(); if (saved === SYSTEM_THEME_ID) { - await this.applyTheme(SYSTEM_THEME_ID); + await this.applyThemeSelection(SYSTEM_THEME_ID, { persist: false }); } else if (saved && this.themes.has(saved)) { - await this.applyTheme(saved); - } else { - const preInjectedThemeId = document.documentElement.getAttribute('data-theme'); - if (preInjectedThemeId && this.themes.has(preInjectedThemeId as ThemeId)) { - await this.applyTheme(preInjectedThemeId as ThemeId); - } else { - await this.applyTheme(SYSTEM_THEME_ID); + await this.applyThemeSelection(saved, { persist: false }); + } else if (saved) { + this.pendingUserThemeSelection = saved; + await this.ensureUserThemesLoaded(); + if (this.themeSelection === saved) { + return; } + await this.applyStartupFallbackTheme(); + } else { + await this.applyStartupFallbackTheme(); } - this.loadUserThemes().catch(() => { - - }); } catch (error) { log.error('Theme system initialization failed', error); - await this.applyTheme(SYSTEM_THEME_ID); + await this.applyThemeSelection(SYSTEM_THEME_ID, { persist: false }); + } + } + + + private async applyStartupFallbackTheme(): Promise { + const preInjectedThemeId = document.documentElement.getAttribute('data-theme'); + if (preInjectedThemeId && this.themes.has(preInjectedThemeId as ThemeId)) { + await this.applyThemeSelection(preInjectedThemeId as ThemeId, { persist: false }); + } else { + await this.applyThemeSelection(SYSTEM_THEME_ID, { persist: false }); + } + } + + + private getBootstrapThemeSelection(): ThemeSelectionId | null { + const selection = globalThis.__BITFUN_BOOTSTRAP_THEME_SELECTION__; + if (selection === SYSTEM_THEME_ID) { + return SYSTEM_THEME_ID; + } + if (typeof selection === 'string' && this.themes.has(selection as ThemeId)) { + return selection as ThemeId; + } + + return null; + } + + + async ensureUserThemesLoaded(): Promise { + if (this.userThemesLoaded) { + await this.applyPendingUserThemeSelection(); + return; + } + if (!this.userThemesLoadPromise) { + this.userThemesLoadPromise = this.loadUserThemes() + .finally(() => { + this.userThemesLoaded = true; + this.userThemesLoadPromise = null; + }); + } + await this.userThemesLoadPromise; + await this.applyPendingUserThemeSelection(); + } + + + private async applyPendingUserThemeSelection(): Promise { + const pending = this.pendingUserThemeSelection; + if (!pending) { + return; + } + + this.pendingUserThemeSelection = null; + if (!this.themes.has(pending)) { + log.warn('Saved theme selection was not found after loading user themes', { id: pending }); + return; } + + await this.applyThemeSelection(pending, { persist: false }); } @@ -292,7 +364,10 @@ export class ThemeService { } } - async applyTheme(themeId: ThemeId | typeof SYSTEM_THEME_ID): Promise { + private async applyThemeSelection( + themeId: ThemeId | typeof SYSTEM_THEME_ID, + options: { persist: boolean }, + ): Promise { if (themeId !== SYSTEM_THEME_ID && !this.themes.has(themeId)) { log.error('Theme not found', { id: themeId }); throw new Error(`Theme ${themeId} not found`); @@ -302,17 +377,29 @@ export class ThemeService { if (themeId === SYSTEM_THEME_ID) { this.themeSelection = SYSTEM_THEME_ID; - await this.saveThemeSelection(SYSTEM_THEME_ID); + if (options.persist) { + await this.saveThemeSelection(SYSTEM_THEME_ID); + } else { + this.lastSavedSelection = SYSTEM_THEME_ID; + } this.attachSystemThemeListener(); const resolved = getSystemPreferredDefaultThemeId(); await this.applyResolvedTheme(resolved); } else { this.themeSelection = themeId; - await this.saveThemeSelection(themeId); + if (options.persist) { + await this.saveThemeSelection(themeId); + } else { + this.lastSavedSelection = themeId; + } await this.applyResolvedTheme(themeId); } } + async applyTheme(themeId: ThemeId | typeof SYSTEM_THEME_ID): Promise { + await this.applyThemeSelection(themeId, { persist: true }); + } + private injectCSSVariables(theme: ThemeConfig): void { const root = document.documentElement; diff --git a/src/web-ui/src/infrastructure/theme/store/themeStore.ts b/src/web-ui/src/infrastructure/theme/store/themeStore.ts index 4ee238662..9609363d6 100644 --- a/src/web-ui/src/infrastructure/theme/store/themeStore.ts +++ b/src/web-ui/src/infrastructure/theme/store/themeStore.ts @@ -59,6 +59,7 @@ export const useThemeStore = create((set) => ({ await themeService.initialize(); + await themeService.ensureUserThemesLoaded(); const themes = themeService.getThemeList(); diff --git a/src/web-ui/src/main.tsx b/src/web-ui/src/main.tsx index 566ba7844..3e734d1d7 100644 --- a/src/web-ui/src/main.tsx +++ b/src/web-ui/src/main.tsx @@ -206,15 +206,6 @@ async function initializeBeforeRender(): Promise { }); }); - await traceStartupStep('before_render_step', 'initialize_frontend_log_level_sync', async () => { - await measureAsyncAndLog(log, 'Startup step completed', async () => { - const { initializeFrontendLogLevelSync } = await import('./infrastructure/config/services/FrontendLogLevelSync'); - await initializeFrontendLogLevelSync(); - }, { - data: { step: 'initializeFrontendLogLevelSync' }, - }); - }); - log.info('Initializing BitFun'); await traceStartupStep('before_render_step', 'theme_service_initialize', async () => { @@ -256,9 +247,17 @@ async function initializeAfterRender(): Promise { }); })(), (async () => { - const { installFrontendLogLevelConfigWatcher } = await import('./infrastructure/config/services/FrontendLogLevelSync'); + const { + initializeFrontendLogLevelSync, + installFrontendLogLevelConfigWatcher, + } = await import('./infrastructure/config/services/FrontendLogLevelSync'); + await initializeFrontendLogLevelSync(); await installFrontendLogLevelConfigWatcher(); })(), + (async () => { + const { themeService } = await import('./infrastructure/theme'); + await themeService.ensureUserThemesLoaded(); + })(), (async () => { const { registerDefaultContextTypes } = await import('./shared/context-system/core/registerDefaultTypes'); registerDefaultContextTypes(); @@ -288,6 +287,7 @@ async function initializeAfterRender(): Promise { const names = [ 'EditorConfigPreload', 'LogLevelConfigWatcher', + 'UserThemes', 'DefaultContextTypes', 'RecommendationProviders', 'Tools', diff --git a/src/web-ui/src/shared/utils/logger.test.ts b/src/web-ui/src/shared/utils/logger.test.ts new file mode 100644 index 000000000..e1acc21db --- /dev/null +++ b/src/web-ui/src/shared/utils/logger.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@tauri-apps/plugin-log', () => ({ + trace: vi.fn(() => Promise.resolve()), + debug: vi.fn(() => Promise.resolve()), + info: vi.fn(() => Promise.resolve()), + warn: vi.fn(() => Promise.resolve()), + error: vi.fn(() => Promise.resolve()), +})); + +async function importLoggerWithBootstrapLevel(level: unknown) { + vi.resetModules(); + if (level === undefined) { + delete globalThis.__BITFUN_BOOTSTRAP_LOG_LEVEL__; + } else { + globalThis.__BITFUN_BOOTSTRAP_LOG_LEVEL__ = level as string; + } + return import('./logger'); +} + +describe('logger bootstrap level', () => { + afterEach(() => { + delete globalThis.__BITFUN_BOOTSTRAP_LOG_LEVEL__; + }); + + it('uses the native bootstrap log level before async config sync runs', async () => { + const { LogLevel, logger } = await importLoggerWithBootstrapLevel('debug'); + + expect(logger.getLevel()).toBe(LogLevel.DEBUG); + }); + + it('ignores invalid bootstrap levels and keeps the environment default', async () => { + const baseline = await importLoggerWithBootstrapLevel(undefined); + const expectedDefault = baseline.logger.getLevel(); + + const { logger } = await importLoggerWithBootstrapLevel('verbose'); + + expect(logger.getLevel()).toBe(expectedDefault); + }); +}); diff --git a/src/web-ui/src/shared/utils/logger.ts b/src/web-ui/src/shared/utils/logger.ts index eb7442804..2e8bdfbd7 100644 --- a/src/web-ui/src/shared/utils/logger.ts +++ b/src/web-ui/src/shared/utils/logger.ts @@ -36,6 +36,11 @@ const isDev = import.meta.env?.DEV ?? process.env.NODE_ENV === 'development'; const CONSOLE_FORWARD_INSTALLED = '__bitfun_console_forward_installed__'; let includeSensitiveDiagnostics = true; +declare global { + // Injected by the desktop WebView initialization script before the frontend bundle runs. + var __BITFUN_BOOTSTRAP_LOG_LEVEL__: string | undefined; +} + export function setIncludeSensitiveDiagnostics(enabled: boolean): void { includeSensitiveDiagnostics = enabled; } @@ -158,6 +163,38 @@ export function bootstrapLogger(): void { let initialized = false; let initPromise: Promise | null = null; +function logLevelFromString(value: unknown): LogLevel | null { + if (typeof value !== 'string') { + return null; + } + + switch (value.trim().toLowerCase()) { + case 'trace': + return LogLevel.TRACE; + case 'debug': + return LogLevel.DEBUG; + case 'info': + return LogLevel.INFO; + case 'warn': + return LogLevel.WARN; + case 'error': + return LogLevel.ERROR; + case 'off': + return LogLevel.NONE; + default: + return null; + } +} + +function initialLogLevel(): LogLevel { + const bootstrapLevel = logLevelFromString(globalThis.__BITFUN_BOOTSTRAP_LOG_LEVEL__); + if (bootstrapLevel !== null) { + return bootstrapLevel; + } + + return isDev ? LogLevel.DEBUG : LogLevel.WARN; +} + /** * Initialize logger state and ensure console forwarding is installed. */ @@ -218,7 +255,7 @@ export class Logger { private currentLevel: LogLevel; private constructor() { - this.currentLevel = isDev ? LogLevel.DEBUG : LogLevel.WARN; + this.currentLevel = initialLogLevel(); } public static getInstance(): Logger { diff --git a/src/web-ui/src/shared/utils/startupTrace.test.ts b/src/web-ui/src/shared/utils/startupTrace.test.ts index 277a5e7a1..2c11908bd 100644 --- a/src/web-ui/src/shared/utils/startupTrace.test.ts +++ b/src/web-ui/src/shared/utils/startupTrace.test.ts @@ -4,7 +4,9 @@ import { estimateJsonBytes, isRemoteTraceContext, isRemoteTraceRequest, + isStartupRenderTraceEnabled, markPhaseAfterAnimationFrames, + recordReactRenderProfile, } from './startupTrace'; import type { LoggerLike } from './timing'; @@ -54,6 +56,108 @@ describe('startupTrace', () => { expect(payload).not.toHaveProperty('sshHost'); }); + it('records react render profiles only when perf trace is explicitly enabled', () => { + const previousRenderProfileEnabled = globalThis.__BITFUN_RENDER_PROFILE_ENABLED__; + const trace = createStartupTrace({ + traceId: 'trace-test', + now: () => 100, + }); + + try { + globalThis.__BITFUN_RENDER_PROFILE_ENABLED__ = false; + expect(isStartupRenderTraceEnabled()).toBe(false); + recordReactRenderProfile(trace, { + component: 'MarkdownRenderer', + phase: 'mount', + actualDurationMs: 12.345, + baseDurationMs: 20.5, + startTimeMs: 10, + commitTimeMs: 25, + contentLength: 1024, + itemCount: 12, + groupCount: 7, + renderedCount: 5, + turnId: 'turn-1', + roundId: 'round-1', + itemId: 'item-1', + visibleGroupStartIndex: 2, + visibleGroupEndIndex: 7, + textItemCount: 4, + toolItemCount: 8, + visibleTextItemCount: 2, + visibleToolItemCount: 3, + criticalGroupCount: 5, + exploreGroupCount: 2, + hasCodeBlock: true, + request: { unsafe: 'payload' }, + }); + expect(trace.getSnapshot().phases.events).toHaveLength(0); + + globalThis.__BITFUN_RENDER_PROFILE_ENABLED__ = true; + expect(isStartupRenderTraceEnabled()).toBe(true); + recordReactRenderProfile(trace, { + component: 'MarkdownRenderer', + phase: 'mount', + actualDurationMs: 12.345, + baseDurationMs: 20.5, + startTimeMs: 10, + commitTimeMs: 25, + contentLength: 1024, + itemCount: 12, + groupCount: 7, + renderedCount: 5, + turnId: 'turn-1', + roundId: 'round-1', + itemId: 'item-1', + visibleGroupStartIndex: 2, + visibleGroupEndIndex: 7, + textItemCount: 4, + toolItemCount: 8, + visibleTextItemCount: 2, + visibleToolItemCount: 3, + criticalGroupCount: 5, + exploreGroupCount: 2, + hasCodeBlock: true, + request: { unsafe: 'payload' }, + }); + + expect(trace.getSnapshot().phases.events).toEqual([ + expect.objectContaining({ + phase: 'react_render_profile', + component: 'MarkdownRenderer', + renderPhase: 'mount', + actualDurationMs: 12.3, + baseDurationMs: 20.5, + startTimeMs: 10, + commitTimeMs: 25, + contentLength: 1024, + itemCount: 12, + groupCount: 7, + renderedCount: 5, + turnId: 'turn-1', + roundId: 'round-1', + itemId: 'item-1', + visibleGroupStartIndex: 2, + visibleGroupEndIndex: 7, + textItemCount: 4, + toolItemCount: 8, + visibleTextItemCount: 2, + visibleToolItemCount: 3, + criticalGroupCount: 5, + exploreGroupCount: 2, + hasCodeBlock: true, + }), + ]); + expect(trace.getSnapshot().phases.events[0]).not.toHaveProperty('request'); + } finally { + if (previousRenderProfileEnabled === undefined) { + delete globalThis.__BITFUN_RENDER_PROFILE_ENABLED__; + } else { + globalThis.__BITFUN_RENDER_PROFILE_ENABLED__ = previousRenderProfileEnabled; + } + } + }); + it('logs sanitized phase events only when explicitly enabled', () => { const logger = createTestLogger(); const trace = createStartupTrace({ @@ -210,6 +314,46 @@ describe('startupTrace', () => { }); }); + it('records API boundary timing and concurrency fields for bottleneck attribution', () => { + const trace = createStartupTrace({ + logger: createTestLogger(), + traceId: 'trace-test', + now: () => 100, + }); + + trace.recordApiCall({ + type: 'tauri', + command: 'list_persisted_sessions_page', + durationMs: 120.4, + startedAtMs: 200, + endedAtMs: 320.4, + requestBytes: 50, + responseBytes: 500, + remote: false, + requestPayloadEstimateDurationMs: 1.2, + responsePayloadEstimateDurationMs: 2.3, + adapterInitDurationMs: 4.5, + transportDurationMs: 112.1, + activeRequestsAtStart: 3, + activeRequestsAtEnd: 2, + maxConcurrentRequests: 5, + }); + + const [call] = trace.getSnapshot().api.calls; + expect(call).toMatchObject({ + command: 'list_persisted_sessions_page', + durationMs: 120.4, + requestPayloadEstimateDurationMs: 1.2, + responsePayloadEstimateDurationMs: 2.3, + payloadEstimateDurationMs: 3.5, + adapterInitDurationMs: 4.5, + transportDurationMs: 112.1, + activeRequestsAtStart: 3, + activeRequestsAtEnd: 2, + maxConcurrentRequests: 5, + }); + }); + it('flushes bounded phase records so early events survive logger startup timing', () => { const logger = createTestLogger(); let now = 10; diff --git a/src/web-ui/src/shared/utils/startupTrace.ts b/src/web-ui/src/shared/utils/startupTrace.ts index 9e05cb827..7175aa24f 100644 --- a/src/web-ui/src/shared/utils/startupTrace.ts +++ b/src/web-ui/src/shared/utils/startupTrace.ts @@ -18,6 +18,14 @@ export interface StartupTraceApiCall { requestBytes?: number; responseBytes?: number; payloadEstimateDurationMs?: number; + requestPayloadEstimateDurationMs?: number; + responsePayloadEstimateDurationMs?: number; + adapterInitDurationMs?: number; + transportDurationMs?: number; + invokeDurationMs?: number; + activeRequestsAtStart?: number; + activeRequestsAtEnd?: number; + maxConcurrentRequests?: number; remote: boolean; } @@ -38,6 +46,34 @@ export interface DeferredAnimationFrameTraceOptions { requestAnimationFrame?: (callback: (time: number) => void) => number; } +export interface ReactRenderProfileTrace { + component: string; + phase: string; + actualDurationMs: number; + baseDurationMs?: number; + startTimeMs?: number; + commitTimeMs?: number; + contentLength?: number; + itemCount?: number; + groupCount?: number; + renderedCount?: number; + turnId?: string; + roundId?: string; + itemId?: string; + visibleGroupStartIndex?: number; + visibleGroupEndIndex?: number; + textItemCount?: number; + toolItemCount?: number; + visibleTextItemCount?: number; + visibleToolItemCount?: number; + criticalGroupCount?: number; + exploreGroupCount?: number; + hasCodeBlock?: boolean; + hasTable?: boolean; + isStreaming?: boolean; + [key: string]: unknown; +} + interface CommandAggregate { command: string; count: number; @@ -88,6 +124,15 @@ export interface StartupTraceApiCallRecord { requestBytes: number; responseBytes: number; remote: boolean; + payloadEstimateDurationMs?: number; + requestPayloadEstimateDurationMs?: number; + responsePayloadEstimateDurationMs?: number; + adapterInitDurationMs?: number; + transportDurationMs?: number; + invokeDurationMs?: number; + activeRequestsAtStart?: number; + activeRequestsAtEnd?: number; + maxConcurrentRequests?: number; } export interface StartupTraceSnapshot { @@ -109,6 +154,7 @@ declare global { // It intentionally exposes no raw request payloads or workspace paths. var __BITFUN_STARTUP_TRACE__: StartupTraceDiagnostics | undefined; var __BITFUN_PERF_TRACE_ENABLED__: boolean | undefined; + var __BITFUN_RENDER_PROFILE_ENABLED__: boolean | undefined; } const DEFAULT_MAX_ESTIMATED_BYTES = 64 * 1024; @@ -129,6 +175,16 @@ function createTraceId(): string { return `startup-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; } +function optionalRounded(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) + ? roundDurationMs(value) + : undefined; +} + +function optionalString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + function isSafeScalar(value: unknown): value is string | number | boolean | null { return ( value === null || @@ -363,6 +419,10 @@ export class StartupTrace { : (startedAtMs !== undefined ? roundDurationMs(startedAtMs + durationMs) : undefined); const requestBytes = call.requestBytes ?? 0; const responseBytes = call.responseBytes ?? 0; + const requestPayloadEstimateDurationMs = optionalRounded(call.requestPayloadEstimateDurationMs); + const responsePayloadEstimateDurationMs = optionalRounded(call.responsePayloadEstimateDurationMs); + const payloadEstimateDurationMs = optionalRounded(call.payloadEstimateDurationMs) + ?? roundDurationMs((requestPayloadEstimateDurationMs ?? 0) + (responsePayloadEstimateDurationMs ?? 0)); const succeeded = call.outcome !== 'failure'; const cacheOutcome = call.cacheOutcome ?? 'unknown'; this.totalApiCount += 1; @@ -374,7 +434,7 @@ export class StartupTrace { this.remoteApiCount += call.remote ? 1 : 0; this.requestBytes += requestBytes; this.responseBytes += responseBytes; - this.payloadEstimateDurationMs += call.payloadEstimateDurationMs ?? 0; + this.payloadEstimateDurationMs += payloadEstimateDurationMs; if (this.apiCallEvents < this.maxApiCallRecords) { this.apiCallEvents += 1; @@ -391,6 +451,15 @@ export class StartupTrace { requestBytes, responseBytes, remote: call.remote, + payloadEstimateDurationMs, + requestPayloadEstimateDurationMs, + responsePayloadEstimateDurationMs, + adapterInitDurationMs: optionalRounded(call.adapterInitDurationMs), + transportDurationMs: optionalRounded(call.transportDurationMs), + invokeDurationMs: optionalRounded(call.invokeDurationMs), + activeRequestsAtStart: optionalRounded(call.activeRequestsAtStart), + activeRequestsAtEnd: optionalRounded(call.activeRequestsAtEnd), + maxConcurrentRequests: optionalRounded(call.maxConcurrentRequests), }); } @@ -484,6 +553,46 @@ export function createStartupTrace(options: StartupTraceOptions = {}): StartupTr export const startupTrace = createStartupTrace(); +export function isStartupRenderTraceEnabled(): boolean { + return globalThis.__BITFUN_RENDER_PROFILE_ENABLED__ === true; +} + +export function recordReactRenderProfile( + trace: StartupTrace, + profile: ReactRenderProfileTrace +): void { + if (!isStartupRenderTraceEnabled()) { + return; + } + + trace.markPhase('react_render_profile', { + component: profile.component, + renderPhase: profile.phase, + actualDurationMs: roundDurationMs(profile.actualDurationMs), + baseDurationMs: optionalRounded(profile.baseDurationMs), + startTimeMs: optionalRounded(profile.startTimeMs), + commitTimeMs: optionalRounded(profile.commitTimeMs), + contentLength: optionalRounded(profile.contentLength), + itemCount: optionalRounded(profile.itemCount), + groupCount: optionalRounded(profile.groupCount), + renderedCount: optionalRounded(profile.renderedCount), + turnId: optionalString(profile.turnId), + roundId: optionalString(profile.roundId), + itemId: optionalString(profile.itemId), + visibleGroupStartIndex: optionalRounded(profile.visibleGroupStartIndex), + visibleGroupEndIndex: optionalRounded(profile.visibleGroupEndIndex), + textItemCount: optionalRounded(profile.textItemCount), + toolItemCount: optionalRounded(profile.toolItemCount), + visibleTextItemCount: optionalRounded(profile.visibleTextItemCount), + visibleToolItemCount: optionalRounded(profile.visibleToolItemCount), + criticalGroupCount: optionalRounded(profile.criticalGroupCount), + exploreGroupCount: optionalRounded(profile.exploreGroupCount), + hasCodeBlock: profile.hasCodeBlock, + hasTable: profile.hasTable, + isStreaming: profile.isStreaming, + }); +} + if (import.meta.env.DEV || globalThis.__BITFUN_PERF_TRACE_ENABLED__ === true) { globalThis.__BITFUN_STARTUP_TRACE__ = { snapshot: () => startupTrace.getSnapshot(), diff --git a/src/web-ui/src/tools/git/hooks/useGitState.ts b/src/web-ui/src/tools/git/hooks/useGitState.ts index 3bd27d29c..109cf1709 100644 --- a/src/web-ui/src/tools/git/hooks/useGitState.ts +++ b/src/web-ui/src/tools/git/hooks/useGitState.ts @@ -38,6 +38,12 @@ import { RefreshOptions, } from '../state/types'; import type { GitBranch, GitCommit } from '../types/repository'; + +export type GitBasicInfoOptions = Pick< + UseGitStateOptions, + 'isActive' | 'participateInWindowFocusRefresh' | 'refreshOnMount' | 'refreshOnActive' +>; + export function useGitState(options: UseGitStateOptions): UseGitStateReturn { const { repositoryPath, @@ -194,14 +200,17 @@ export function useGitState(options: UseGitStateOptions): UseGitStateReturn { * Optimized hook for basic info only * Suitable for scenarios like BottomBar that only need branch name */ -export function useGitBasicInfo(repositoryPath: string) { +export function useGitBasicInfo( + repositoryPath: string, + options: GitBasicInfoOptions = {} +) { return useGitState({ repositoryPath, - isActive: true, - participateInWindowFocusRefresh: false, + isActive: options.isActive ?? true, + participateInWindowFocusRefresh: options.participateInWindowFocusRefresh ?? false, layers: ['basic'], - refreshOnMount: true, - refreshOnActive: false, + refreshOnMount: options.refreshOnMount ?? true, + refreshOnActive: options.refreshOnActive ?? false, }); } diff --git a/tests/e2e/helpers/performance-trace.ts b/tests/e2e/helpers/performance-trace.ts index 91b085e35..764c3f2a5 100644 --- a/tests/e2e/helpers/performance-trace.ts +++ b/tests/e2e/helpers/performance-trace.ts @@ -48,6 +48,15 @@ export interface StartupTraceApiCallRecord { requestBytes: number; responseBytes: number; remote: boolean; + payloadEstimateDurationMs?: number; + requestPayloadEstimateDurationMs?: number; + responsePayloadEstimateDurationMs?: number; + adapterInitDurationMs?: number; + transportDurationMs?: number; + invokeDurationMs?: number; + activeRequestsAtStart?: number; + activeRequestsAtEnd?: number; + maxConcurrentRequests?: number; } export interface StartupTraceNativeEvent { @@ -150,9 +159,14 @@ export type StartupPerfBreakdown = { slowCalls: Array<{ command: string; target?: string; + startedAtMs?: number; frontendDurationMs: number; backendDurationMs?: number; estimatedQueueOrBridgeMs?: number; + transportDurationMs?: number; + invokeDurationMs?: number; + activeRequestsAtStart?: number; + maxConcurrentRequests?: number; }>; }; }; @@ -172,6 +186,23 @@ export type StartupPerfBreakdown = { }; }; +export interface StartupApiCommandSegment { + command: string; + target?: string; + startedAtMs?: number; + frontendDurationMs: number; + backendDurationMs?: number; + estimatedQueueOrBridgeMs?: number; + transportDurationMs?: number; + invokeDurationMs?: number; + activeRequestsAtStart?: number; + activeRequestsAtEnd?: number; + maxConcurrentRequests?: number; + requestBytes: number; + responseBytes: number; + remote: boolean; +} + export type SessionOpenPerfMilestones = { clickToHydrateStartMs?: number; clickToLatestFrameMs?: number; @@ -189,6 +220,8 @@ export type SessionOpenPerfMilestones = { loadedTurnCount?: number; totalTurnCount?: number; isPartial?: boolean; + restoreTiming?: unknown; + fullHydrateRestoreTiming?: unknown; }; function numberField(value: unknown): number | undefined { @@ -308,10 +341,17 @@ function aggregateApiCalls(calls: StartupTraceApiCallRecord[]): StartupTraceComm .sort((left, right) => right.totalDurationMs - left.totalDurationMs); } -function summarizeBackendCommandOverlap( +export function summarizeApiCommandSegments( + snapshot: StartupTraceSnapshot, + frontendCalls: StartupTraceApiCallRecord[] = snapshot.api.calls ?? [], +): StartupApiCommandSegment[] { + return matchBackendCommandSegments(frontendCalls, snapshot.native?.events ?? []); +} + +function matchBackendCommandSegments( frontendCalls: StartupTraceApiCallRecord[], nativeEvents: StartupTraceNativeEvent[], -) { +): StartupApiCommandSegment[] { const backendEvents = nativeEvents .filter(event => event.category === 'tauri_command' && @@ -319,7 +359,7 @@ function summarizeBackendCommandOverlap( typeof event.durationMs === 'number' ) .map(event => ({ ...event, consumed: false })); - const matched = frontendCalls.map(call => { + return frontendCalls.map(call => { const backend = backendEvents.find(event => !event.consumed && event.command === call.command && @@ -332,14 +372,29 @@ function summarizeBackendCommandOverlap( return { command: call.command, target: call.target, + startedAtMs: round(call.startedAtMs), frontendDurationMs: round(call.durationMs) ?? 0, backendDurationMs: round(backendDurationMs), estimatedQueueOrBridgeMs: round( backendDurationMs === undefined ? undefined : call.durationMs - backendDurationMs ), + transportDurationMs: round(call.transportDurationMs), + invokeDurationMs: round(call.invokeDurationMs), + activeRequestsAtStart: numberField(call.activeRequestsAtStart), + activeRequestsAtEnd: numberField(call.activeRequestsAtEnd), + maxConcurrentRequests: numberField(call.maxConcurrentRequests), + requestBytes: call.requestBytes, + responseBytes: call.responseBytes, + remote: call.remote, }; }); +} +function summarizeBackendCommandOverlap( + frontendCalls: StartupTraceApiCallRecord[], + nativeEvents: StartupTraceNativeEvent[], +) { + const matched = matchBackendCommandSegments(frontendCalls, nativeEvents); const byCommand = new Map= options.sessionCount) { + throw new Error('--long-session-index must be smaller than --session-count'); + } if (!options.sessionPrefix.trim()) { throw new Error('--session-prefix cannot be empty'); } + if (!['explore-only', 'mixed-visible', 'dense-visible'].includes(options.scenario)) { + throw new Error('--scenario must be one of: explore-only, mixed-visible, dense-visible'); + } return options; } @@ -96,12 +114,15 @@ Usage: Options: --session-count Number of metadata rows to create. Default: 80 - --long-turns Turn count for the latest session. Default: 80 + --long-session-index Session index that receives long-turn content. Default: 0 + --long-turns Turn count for the selected long session. Default: 80 --short-turns Turn count for other sessions. Default: 1 --assistant-chars Assistant text chars per turn. Default: 2000 --tool-result-chars Raw tool result chars per tool item. Default: 12000 --tool-items Tool item count per turn. Default: 2 + --dense-groups Groups in the latest dense-visible turn. Default: 160 --session-prefix Session id prefix. Default: perf-long-session + --scenario Fixture shape: mixed-visible, dense-visible, or explore-only. Default: mixed-visible --bitfun-home BitFun home root. Default: BITFUN_HOME or ~/.bitfun --cleanup Remove generated sessions for the prefix. `); @@ -131,7 +152,38 @@ function repeatedText(chars, label) { return `${label} ${'x'.repeat(chars - label.length - 1)}`; } -function makeMetadata({ sessionId, sessionName, workspacePath, createdAt, lastActiveAt, turnCount, toolItems }) { +function repeatedMarkdown(chars, turnIndex) { + const seed = [ + `Synthetic assistant response ${turnIndex}`, + '', + 'The fixture keeps visible narrative content outside collapsed explore groups so scroll anchoring is tested against realistic heights.', + '', + '```ts', + `export function fixtureTurn${turnIndex}() {`, + ` return "turn-${turnIndex}-visible-content";`, + '}', + '```', + '', + '| Area | Observation |', + '| --- | --- |', + '| restore | checks latest-turn anchoring |', + '| render | includes markdown, code, and table blocks |', + '', + ].join('\n'); + + if (chars <= seed.length) { + return seed.slice(0, chars); + } + + const paragraph = `Visible markdown paragraph for turn ${turnIndex}. `; + let content = seed; + while (content.length < chars) { + content += paragraph; + } + return content.slice(0, chars); +} + +function makeMetadata({ sessionId, sessionName, workspacePath, createdAt, lastActiveAt, turnCount, toolItems, scenario }) { return { sessionId, sessionName, @@ -152,7 +204,9 @@ function makeMetadata({ sessionId, sessionName, workspacePath, createdAt, lastAc tags: ['performance-fixture'], customMetadata: { generatedBy: 'tests/e2e/scripts/generate-long-session-fixture.mjs', - fixtureVersion: 1, + fixtureVersion: 2, + fixtureScenario: scenario, + lastFinishedAt: lastActiveAt, }, relationship: null, todos: null, @@ -193,10 +247,8 @@ function makeState(workspacePath) { }; } -function makeTurn({ sessionId, turnIndex, timestamp, assistantChars, toolResultChars, toolItems }) { - const turnId = `${sessionId}-turn-${String(turnIndex).padStart(4, '0')}`; - const textItemId = `${turnId}-text-0`; - const toolItemsData = Array.from({ length: toolItems }, (_, toolIndex) => { +function makeToolItems({ turnId, turnIndex, timestamp, toolResultChars, toolItems }) { + return Array.from({ length: toolItems }, (_, toolIndex) => { const toolId = `${turnId}-tool-${toolIndex}`; return { id: toolId, @@ -231,39 +283,156 @@ function makeTurn({ sessionId, turnIndex, timestamp, assistantChars, toolResultC status: 'completed', }; }); +} +function makeTextItem({ turnId, turnIndex, timestamp, assistantChars, orderIndex }) { return { - schema_version: SESSION_STORAGE_SCHEMA_VERSION, - turnId, - turnIndex, - sessionId, - timestamp, - kind: 'user_dialog', - agentType: 'agentic', - userMessage: { - id: `${turnId}-user`, - content: `Synthetic user turn ${turnIndex}`, - timestamp, - metadata: { - generatedBy: 'performance-fixture', + id: `${turnId}-text-0`, + content: repeatedMarkdown(assistantChars, turnIndex), + isStreaming: false, + timestamp: timestamp + 20, + isMarkdown: true, + orderIndex, + status: 'completed', + }; +} + +function makeDenseTextItem({ turnId, turnIndex, groupIndex, timestamp, assistantChars, orderIndex }) { + return { + id: `${turnId}-dense-text-${groupIndex}`, + content: repeatedMarkdown( + Math.max(240, Math.min(assistantChars, 900)), + `${turnIndex}-${groupIndex}`, + ), + isStreaming: false, + timestamp: timestamp + 20 + groupIndex, + isMarkdown: true, + orderIndex, + status: 'completed', + }; +} + +function makeDenseToolItem({ turnId, turnIndex, groupIndex, timestamp, toolResultChars, orderIndex }) { + const toolId = `${turnId}-dense-tool-${groupIndex}`; + return { + id: toolId, + toolName: 'Read', + toolCall: { + id: toolId, + input: { + filePath: `/workspace/perf-dense-${turnIndex}-${groupIndex}.txt`, }, }, - modelRounds: [ + toolResult: { + result: { + output: repeatedText( + Math.min(toolResultChars, 2_000), + `dense raw result ${turnIndex}.${groupIndex}`, + ), + fixture: true, + }, + success: true, + resultForAssistant: repeatedText( + Math.min(512, toolResultChars), + `dense assistant result ${turnIndex}.${groupIndex}`, + ), + durationMs: 5, + }, + aiIntent: 'Synthetic dense performance fixture tool result', + startTime: timestamp + 10 + groupIndex, + endTime: timestamp + 15 + groupIndex, + durationMs: 5, + queueWaitMs: 0, + preflightMs: 0, + confirmationWaitMs: 0, + executionMs: 5, + orderIndex, + status: 'completed', + }; +} + +function makeDenseLatestModelRounds({ turnId, turnIndex, timestamp, assistantChars, toolResultChars, denseGroups }) { + const textItems = []; + const toolItems = []; + const groupCount = Math.max(1, denseGroups); + + for (let groupIndex = 0; groupIndex < groupCount; groupIndex += 1) { + const orderIndex = groupIndex; + // Alternate dense tool/history groups with visible text so the fixture + // exercises both progressive model-round rendering and latest content paint. + if (groupIndex % 4 === 3 || groupIndex >= groupCount - 8) { + textItems.push(makeDenseTextItem({ + turnId, + turnIndex, + groupIndex, + timestamp, + assistantChars, + orderIndex, + })); + } else { + toolItems.push(makeDenseToolItem({ + turnId, + turnIndex, + groupIndex, + timestamp, + toolResultChars, + orderIndex, + })); + } + } + + return [ + { + id: `${turnId}-dense-round-0`, + turnId, + roundIndex: 0, + timestamp: timestamp + 1, + textItems, + toolItems, + thinkingItems: [], + startTime: timestamp + 1, + endTime: timestamp + 30, + durationMs: 29, + providerId: 'perf-fixture', + modelId: 'perf-fixture-model', + modelAlias: 'Perf Fixture', + status: 'completed', + }, + ]; +} + +function makeTurn({ + sessionId, + turnIndex, + totalTurns, + timestamp, + assistantChars, + toolResultChars, + toolItems, + scenario, + denseGroups, +}) { + const turnId = `${sessionId}-turn-${String(turnIndex).padStart(4, '0')}`; + const toolItemsData = makeToolItems({ turnId, turnIndex, timestamp, toolResultChars, toolItems }); + const isLatestTurn = turnIndex === totalTurns - 1; + const modelRounds = scenario === 'dense-visible' && isLatestTurn + ? makeDenseLatestModelRounds({ + turnId, + turnIndex, + timestamp, + assistantChars, + toolResultChars, + denseGroups, + }) + : scenario === 'explore-only' + ? [ { id: `${turnId}-round-0`, turnId, roundIndex: 0, timestamp: timestamp + 1, textItems: [ - { - id: textItemId, - content: repeatedText(assistantChars, `Synthetic assistant response ${turnIndex}`), - isStreaming: false, - timestamp: timestamp + 20, - isMarkdown: true, - orderIndex: toolItems, - status: 'completed', - }, + makeTextItem({ turnId, turnIndex, timestamp, assistantChars, orderIndex: toolItems }), ], toolItems: toolItemsData, thinkingItems: [], @@ -275,7 +444,61 @@ function makeTurn({ sessionId, turnIndex, timestamp, assistantChars, toolResultC modelAlias: 'Perf Fixture', status: 'completed', }, - ], + ] + : [ + { + id: `${turnId}-round-0`, + turnId, + roundIndex: 0, + timestamp: timestamp + 1, + textItems: [], + toolItems: toolItemsData, + thinkingItems: [], + startTime: timestamp + 1, + endTime: timestamp + 15, + durationMs: 14, + providerId: 'perf-fixture', + modelId: 'perf-fixture-model', + modelAlias: 'Perf Fixture', + status: 'completed', + }, + { + id: `${turnId}-round-1`, + turnId, + roundIndex: 1, + timestamp: timestamp + 20, + textItems: [ + makeTextItem({ turnId, turnIndex, timestamp, assistantChars, orderIndex: 0 }), + ], + toolItems: [], + thinkingItems: [], + startTime: timestamp + 20, + endTime: timestamp + 30, + durationMs: 10, + providerId: 'perf-fixture', + modelId: 'perf-fixture-model', + modelAlias: 'Perf Fixture', + status: 'completed', + }, + ]; + + return { + schema_version: SESSION_STORAGE_SCHEMA_VERSION, + turnId, + turnIndex, + sessionId, + timestamp, + kind: 'user_dialog', + agentType: 'agentic', + userMessage: { + id: `${turnId}-user`, + content: `Synthetic user turn ${turnIndex}`, + timestamp, + metadata: { + generatedBy: 'performance-fixture', + }, + }, + modelRounds, startTime: timestamp, endTime: timestamp + 30, durationMs: 30, @@ -352,14 +575,15 @@ async function generate(options) { const generatedMetadata = []; for (let sessionIndex = 0; sessionIndex < options.sessionCount; sessionIndex += 1) { const sessionId = `${options.sessionPrefix}-${String(sessionIndex).padStart(3, '0')}`; - const turnCount = sessionIndex === 0 ? options.longTurns : options.shortTurns; - const createdAt = now - (options.sessionCount - sessionIndex) * 60_000; + const isLongSession = sessionIndex === options.longSessionIndex; + const turnCount = isLongSession ? options.longTurns : options.shortTurns; + const createdAt = now - sessionIndex * 60_000; const lastActiveAt = now - sessionIndex * 1_000; const sessionDir = path.join(sessionsRoot, sessionId); const turnsDir = path.join(sessionDir, 'turns'); const metadata = makeMetadata({ sessionId, - sessionName: sessionIndex === 0 + sessionName: isLongSession ? `Perf Fixture Long Session (${turnCount} turns)` : `Perf Fixture Session ${sessionIndex}`, workspacePath, @@ -367,6 +591,7 @@ async function generate(options) { lastActiveAt, turnCount, toolItems: options.toolItems, + scenario: options.scenario, }); await fs.mkdir(turnsDir, { recursive: true }); @@ -382,10 +607,13 @@ async function generate(options) { makeTurn({ sessionId, turnIndex, + totalTurns: turnCount, timestamp: createdAt + turnIndex * 1_000, assistantChars: options.assistantChars, toolResultChars: options.toolResultChars, toolItems: options.toolItems, + scenario: options.scenario, + denseGroups: options.denseGroups, }), ); } @@ -401,11 +629,13 @@ async function generate(options) { sessionsRoot, sessionPrefix: options.sessionPrefix, sessionCount: options.sessionCount, - longSessionId: `${options.sessionPrefix}-000`, + longSessionId: `${options.sessionPrefix}-${String(options.longSessionIndex).padStart(3, '0')}`, + scenario: options.scenario, longTurns: options.longTurns, assistantChars: options.assistantChars, toolResultChars: options.toolResultChars, toolItems: options.toolItems, + denseGroups: options.denseGroups, }; } diff --git a/tests/e2e/specs/performance/startup-session-perf.spec.ts b/tests/e2e/specs/performance/startup-session-perf.spec.ts index 97159ce2e..bad40275b 100644 --- a/tests/e2e/specs/performance/startup-session-perf.spec.ts +++ b/tests/e2e/specs/performance/startup-session-perf.spec.ts @@ -1,9 +1,13 @@ import { $, browser, expect } from '@wdio/globals'; +import * as crypto from 'crypto'; import * as fs from 'fs/promises'; +import * as fsSync from 'fs'; +import * as os from 'os'; import * as path from 'path'; import { readPerformanceNow, readStartupTraceSnapshot, + summarizeApiCommandSegments, summarizeSessionOpen, summarizeStartup, summarizeStartupBreakdown, @@ -15,6 +19,155 @@ import { ensureWorkspaceOpen } from '../../helpers/workspace-utils'; import { ensureCodeSessionOpen, openWorkspace } from '../../helpers/workspace-helper'; const DEFAULT_PERF_SESSION_ID = 'perf-long-session-000'; +const MAX_PROJECT_SLUG_LEN = 120; +const LONG_SESSION_VIEWPORT_MIN_COVERAGE_RATIO = 0.7; +const LONG_SESSION_VIEWPORT_MAX_BOTTOM_BLANK_PX = 64; +const LONG_SESSION_VIEWPORT_MAX_BLANK_GAP_PX = 64; +const LONG_SESSION_LATEST_VISIBLE_MAX_BOTTOM_BLANK_PX = 96; +const LONG_SESSION_LATEST_VISIBLE_MAX_BLANK_GAP_PX = 96; +const LONG_SESSION_INPUT_MIN_TOP_RATIO = 0.65; +const LONG_SESSION_INPUT_BOTTOM_TOLERANCE_PX = 96; +const LONG_SESSION_MAX_LATEST_TEXT_DELAY_AFTER_VISIBLE_MS = 120; + +type LongSessionViewportState = { + hasRoot: boolean; + hasScroller: boolean; + scrollTop: number | null; + scrollHeight: number | null; + clientHeight: number | null; + latestTurnId: string | null; + latestTop: number | null; + latestBottom: number | null; + latestRendered: boolean; + latestModelRoundRendered: boolean; + latestModelRoundVisible: boolean; + latestModelRoundTextLength: number; + latestContentVisible: boolean; + historyPlaceholderVisible: boolean; + scrollerTop: number | null; + scrollerBottom: number | null; + effectiveScrollerBottom: number | null; + inputOverlayTop: number | null; + inputOverlayBottom: number | null; + inputOverlayHeight: number | null; + latestVisible: boolean; + visibleTurnIds: string[]; + visibleUserMessageCount: number; + userMessageCount: number; + visibleItemCount: number; + visibleItemTypes: string[]; + visibleModelRoundCount: number; + visibleExploreGroupCount: number; + visibleTextLength: number; + visibleItemHeightStats: { + min: number | null; + max: number | null; + avg: number | null; + }; + visibleItemSummaries: Array<{ + type: string | null; + turnId: string | null; + top: number; + bottom: number; + height: number; + textLength: number; + }>; + coveredViewportPx: number; + coverageRatio: number | null; + topBlankPx: number | null; + largestBlankGapPx: number | null; + bottomBlankPx: number | null; +}; + +type LongSessionViewportTimelineSample = { + atMs: number; + sinceClickMs: number; + hasRoot: boolean; + hasScroller: boolean; + latestRendered: boolean; + latestModelRoundRendered: boolean; + latestModelRoundVisible: boolean; + latestModelRoundTextLength: number; + latestContentVisible: boolean; + historyPlaceholderVisible: boolean; + latestVisible: boolean; + latestTurnId: string | null; + scrollTop: number | null; + scrollHeight: number | null; + clientHeight: number | null; + visibleItemCount: number; + visibleItemTypes: string[]; + visibleModelRoundCount: number; + visibleTextLength: number; + visibleItemSummaries: Array<{ + type: string | null; + turnId: string | null; + top: number; + bottom: number; + height: number; + textLength: number; + textContentLength: number; + opacity: string | null; + }>; + renderedItemCount: number; + renderedItemSummaries: Array<{ + type: string | null; + turnId: string | null; + top: number; + bottom: number; + height: number; + textLength: number; + textContentLength: number; + opacity: string | null; + visible: boolean; + }>; + coverageRatio: number | null; + topBlankPx: number | null; + largestBlankGapPx: number | null; + bottomBlankPx: number | null; + inputOverlayTop: number | null; + inputOverlayBottom: number | null; +}; + +type LongSessionMainThreadTask = { + startMs: number; + sinceClickMs: number; + durationMs: number; + name: string; + entryType: string; +}; + +type LongSessionViewportTimeline = { + samples: LongSessionViewportTimelineSample[]; + mainThreadTasks: LongSessionMainThreadTask[]; +}; + +type LongSessionViewportTimelineSummary = { + firstScrollerAtMs: number | null; + firstScrollerBlankAtMs: number | null; + firstVisibleItemAtMs: number | null; + firstHistoryPlaceholderAtMs: number | null; + firstLatestVisibleAtMs: number | null; + firstLatestContentVisibleAtMs: number | null; + firstLatestTextVisibleAtMs: number | null; + firstLatestVisibleTextlessAtMs: number | null; + firstLatestContentVisibleTextlessAtMs: number | null; + latestTextDelayAfterVisibleMs: number | null; + latestTextDelayAfterContentVisibleMs: number | null; + latestVisibleTextlessSampleCount: number; + latestContentVisibleTextlessSampleCount: number; + maxTextlessVisibleBlankGapPx: number | null; + maxTextlessVisibleBottomBlankPx: number | null; + preLatestTextVisibleBlankSampleCount: number; + preLatestTextVisibleBlankWithoutPlaceholderSampleCount: number; + maxPreLatestTextVisibleBlankGapPx: number | null; + maxPreLatestTextVisibleBlankWithoutPlaceholderGapPx: number | null; + maxPreLatestTextVisibleBottomBlankPx: number | null; + postLatestTextVisibleBlankSampleCount: number; + maxPostLatestTextVisibleBlankGapPx: number | null; + maxPostLatestTextVisibleBottomBlankPx: number | null; + postLatestTextVisibleLatestContentMissingSampleCount: number; +}; function reportDir(): string { return path.resolve(process.cwd(), 'reports', 'performance'); @@ -55,34 +208,66 @@ async function waitForOptionalPhaseCount( } } -async function findSessionItem(sessionId: string) { - for (let attempt = 0; attempt < 4; attempt += 1) { +async function findSessionItem(sessionId: string): Promise | null> { + let lastVisibleSessionIds: string[] = []; + for (let attempt = 0; attempt < 6; attempt += 1) { const item = await $(`[data-testid="session-nav-item"][data-session-id="${sessionId}"]`); if (await item.isExisting()) { return item; } + lastVisibleSessionIds = await browser.execute(() => + Array.from(document.querySelectorAll('[data-testid="session-nav-item"]')) + .map(element => element.getAttribute('data-session-id') || '') + .filter(Boolean) + ); const showMore = await $('[data-testid="session-nav-show-more"]'); if (!(await showMore.isExisting()) || !(await showMore.isEnabled())) { break; } + + const beforeCount = lastVisibleSessionIds.length; await showMore.click(); - await browser.pause(500); + await browser.waitUntil(async () => { + const ids = await browser.execute(() => + Array.from(document.querySelectorAll('[data-testid="session-nav-item"]')) + .map(element => element.getAttribute('data-session-id') || '') + .filter(Boolean) + ); + const toggle = await $('[data-testid="session-nav-show-more"]'); + const toggleReady = !(await toggle.isExisting()) || (await toggle.isEnabled()); + return ids.length !== beforeCount && toggleReady; + }, { timeout: 3000, interval: 100 }).catch(() => undefined); + + const currentVisibleSessionIds = await browser.execute(() => + Array.from(document.querySelectorAll('[data-testid="session-nav-item"]')) + .map(element => element.getAttribute('data-session-id') || '') + .filter(Boolean) + ); + if (currentVisibleSessionIds.length <= beforeCount && attempt > 0) { + lastVisibleSessionIds = currentVisibleSessionIds; + break; + } } + console.log('[Perf] visible session ids while locating target', JSON.stringify({ + target: sessionId, + visibleSessionIds: lastVisibleSessionIds.slice(0, 40), + visibleSessionCount: lastVisibleSessionIds.length, + })); return null; } async function ensurePerformanceWorkspace(startupPage: StartupPage): Promise { - const targetWorkspace = process.env.E2E_TEST_WORKSPACE; - if (!targetWorkspace) { - return ensureWorkspaceOpen(startupPage); - } - const isBundledApp = await browser.execute(() => window.location.hostname === 'tauri.localhost'); if (isBundledApp) { return true; } + const targetWorkspace = process.env.E2E_TEST_WORKSPACE; + if (!targetWorkspace) { + return ensureWorkspaceOpen(startupPage); + } + const opened = await openWorkspace(targetWorkspace); if (!opened) { return ensureWorkspaceOpen(startupPage); @@ -91,11 +276,80 @@ async function ensurePerformanceWorkspace(startupPage: StartupPage): Promise { - const className = await item.getAttribute('class'); +async function isSessionItemActive(item: ReturnType): Promise { + const className = await item.getAttribute('class') ?? ''; return className.split(/\s+/).includes('is-active'); } +function projectRuntimeSlug(workspacePath: string): string { + const canonical = fsSync.realpathSync(workspacePath); + const slug = canonical + .split('') + .map(ch => /[a-zA-Z0-9]/.test(ch) ? ch.toLowerCase() : '-') + .join('') + .replace(/^-+|-+$/g, '') || 'workspace'; + + if (slug.length <= MAX_PROJECT_SLUG_LEN) { + return slug; + } + + const suffix = crypto.createHash('sha256').update(canonical).digest('hex').slice(0, 12); + const maxPrefixLen = MAX_PROJECT_SLUG_LEN - suffix.length - 1; + return `${slug.slice(0, maxPrefixLen).replace(/-+$/g, '')}-${suffix}`; +} + +type LongSessionMetadata = { + turnCount?: unknown; + customMetadata?: { + fixtureScenario?: unknown; + } | null; +}; + +async function readLongSessionMetadata(sessionId: string): Promise { + const bitfunHome = process.env.BITFUN_HOME || path.join(os.homedir(), '.bitfun'); + const workspaceCandidates = Array.from(new Set([ + process.env.E2E_TEST_WORKSPACE, + path.resolve(process.cwd(), '..', '..'), + process.cwd(), + ].filter((workspacePath): workspacePath is string => Boolean(workspacePath)))); + + for (const workspacePath of workspaceCandidates) { + try { + const metadataPath = path.join( + bitfunHome, + 'projects', + projectRuntimeSlug(workspacePath), + 'sessions', + sessionId, + 'metadata.json', + ); + return JSON.parse(await fs.readFile(metadataPath, 'utf8')) as LongSessionMetadata; + } catch { + // Try the next known E2E workspace candidate. + } + } + + return null; +} + +async function readExpectedLatestTurnId(sessionId: string): Promise { + const metadata = await readLongSessionMetadata(sessionId); + const turnCount = Number(metadata?.turnCount); + if (!Number.isFinite(turnCount) || turnCount < 1) { + return null; + } + return `${sessionId}-turn-${String(turnCount - 1).padStart(4, '0')}`; +} + +async function readLongSessionFixtureScenario(sessionId: string): Promise { + const metadata = await readLongSessionMetadata(sessionId); + const scenario = metadata?.customMetadata?.fixtureScenario; + if (typeof scenario !== 'string' || scenario.length === 0) { + return null; + } + return scenario; +} + function siblingSessionId(sessionId: string): string | null { const match = /^(.*-)(\d{3,})$/.exec(sessionId); if (!match) { @@ -104,17 +358,15 @@ function siblingSessionId(sessionId: string): string | null { return `${match[1]}${match[2] === '001' ? '000' : '001'}`; } -async function switchAwayIfSessionIsActive(sessionId: string): Promise { - const item = await findSessionItem(sessionId); - if (!item || !(await isSessionItemActive(item))) { - return; - } - +async function switchAwayFromSession(sessionId: string): Promise { const alternateId = siblingSessionId(sessionId); const alternate = alternateId ? await findSessionItem(alternateId) : null; if (!alternate) { return; } + if (await isSessionItemActive(alternate)) { + return; + } const beforeSnapshot = await readStartupTraceSnapshot(); const frameCountBefore = countPhase( @@ -127,6 +379,941 @@ async function switchAwayIfSessionIsActive(sessionId: string): Promise { frameCountBefore + 1, 10000, ); + await browser.pause(50); +} + +async function readLongSessionViewportState(expectedLatestTurnId?: string | null): Promise { + return browser.execute((targetTurnId) => { + const root = document.querySelector( + '.modern-flowchat-container__messages .virtual-message-list', + ); + const scroller = root?.querySelector( + '[data-virtuoso-scroller="true"], [data-virtuoso-scroller]', + ) ?? null; + const userMessages = Array.from(root?.querySelectorAll( + '.virtual-item-wrapper[data-turn-id][data-item-type="user-message"]', + ) ?? []); + const renderedLatest = userMessages.length > 0 ? userMessages[userMessages.length - 1] : null; + const latest = targetTurnId + ? root?.querySelector( + `.virtual-item-wrapper[data-turn-id="${targetTurnId}"][data-item-type="user-message"]`, + ) ?? null + : renderedLatest; + const latestModelRoundSegments = targetTurnId + ? Array.from(root?.querySelectorAll( + `.virtual-item-wrapper[data-turn-id="${targetTurnId}"][data-item-type="model-round"]`, + ) ?? []) + : []; + const scrollerRect = scroller?.getBoundingClientRect() ?? null; + const inputOverlay = document.querySelector('.bitfun-chat-input-drop-zone'); + const inputOverlayRect = inputOverlay?.getBoundingClientRect() ?? null; + const historyPlaceholder = document.querySelector( + '.modern-flowchat-container__messages .history-session-placeholder', + ); + const historyPlaceholderRect = historyPlaceholder?.getBoundingClientRect() ?? null; + const historyPlaceholderStyle = historyPlaceholder + ? window.getComputedStyle(historyPlaceholder) + : null; + const historyPlaceholderVisible = Boolean( + historyPlaceholder && + historyPlaceholderRect && + historyPlaceholderRect.width > 0 && + historyPlaceholderRect.height > 0 && + historyPlaceholderStyle?.visibility !== 'hidden' && + historyPlaceholderStyle?.display !== 'none' && + historyPlaceholderStyle?.opacity !== '0' + ); + const effectiveScrollerBottom = scrollerRect + ? Math.min(scrollerRect.bottom, inputOverlayRect?.top ?? scrollerRect.bottom) + : null; + const latestRect = latest?.getBoundingClientRect() ?? null; + const isVisibleWithinScroller = (rect: DOMRect | null): boolean => Boolean( + scrollerRect && + rect && + rect.bottom > scrollerRect.top && + rect.top < (effectiveScrollerBottom ?? scrollerRect.bottom) + ); + const latestModelRoundVisibleSegments = latestModelRoundSegments + .filter(element => isVisibleWithinScroller(element.getBoundingClientRect())); + const latestModelRoundVisible = latestModelRoundVisibleSegments.length > 0; + const latestModelRoundTextLength = latestModelRoundVisibleSegments + .reduce((total, element) => total + (element.innerText?.length ?? 0), 0); + const latestVisible = isVisibleWithinScroller(latestRect); + const visibleUserMessages = scrollerRect + ? userMessages.filter(element => { + const rect = element.getBoundingClientRect(); + return rect.bottom > scrollerRect.top && rect.top < (effectiveScrollerBottom ?? scrollerRect.bottom); + }) + : []; + const visibleItems = scrollerRect + ? Array.from(root?.querySelectorAll('.virtual-item-wrapper[data-turn-id]') ?? []) + .map(element => { + const rect = element.getBoundingClientRect(); + return { + element, + top: Math.max(rect.top, scrollerRect.top), + bottom: Math.min(rect.bottom, effectiveScrollerBottom ?? scrollerRect.bottom), + rawTop: rect.top, + rawBottom: rect.bottom, + }; + }) + .filter(({ top, bottom }) => + bottom > scrollerRect.top && + top < (effectiveScrollerBottom ?? scrollerRect.bottom) && + bottom > top + ) + .sort((left, right) => left.top - right.top) + : []; + const visibleItemSummaries = scrollerRect + ? visibleItems.map(({ element, rawTop, rawBottom }) => ({ + type: element.dataset.itemType ?? null, + turnId: element.dataset.turnId ?? null, + top: rawTop - scrollerRect.top, + bottom: rawBottom - scrollerRect.top, + height: Math.max(0, rawBottom - rawTop), + textLength: element.innerText?.length ?? 0, + })) + : []; + const visibleTextLength = visibleItemSummaries + .reduce((total, item) => total + item.textLength, 0); + const visibleItemHeights = visibleItemSummaries.map(item => item.height); + const visibleItemHeightStats = visibleItemHeights.length > 0 + ? { + min: Math.min(...visibleItemHeights), + max: Math.max(...visibleItemHeights), + avg: visibleItemHeights.reduce((sum, height) => sum + height, 0) / visibleItemHeights.length, + } + : { min: null, max: null, avg: null }; + + let coveredViewportPx = 0; + let topBlankPx: number | null = null; + let largestBlankGapPx: number | null = null; + let bottomBlankPx: number | null = null; + if (scrollerRect) { + let cursor = scrollerRect.top; + let maxBottom = scrollerRect.top; + visibleItems.forEach((item, index) => { + if (item.top > cursor) { + const gap = item.top - cursor; + if (index === 0) { + topBlankPx = gap; + } + largestBlankGapPx = Math.max(largestBlankGapPx ?? 0, gap); + } + const coveredStart = Math.max(cursor, item.top); + if (item.bottom > coveredStart) { + coveredViewportPx += item.bottom - coveredStart; + cursor = Math.max(cursor, item.bottom); + maxBottom = Math.max(maxBottom, item.bottom); + } + }); + if (visibleItems.length === 0) { + topBlankPx = Math.max(0, (effectiveScrollerBottom ?? scrollerRect.bottom) - scrollerRect.top); + } else if (topBlankPx === null) { + topBlankPx = 0; + } + bottomBlankPx = Math.max(0, (effectiveScrollerBottom ?? scrollerRect.bottom) - maxBottom); + largestBlankGapPx = Math.max(largestBlankGapPx ?? 0, bottomBlankPx); + } + const effectiveViewportHeight = scrollerRect && effectiveScrollerBottom !== null + ? Math.max(0, effectiveScrollerBottom - scrollerRect.top) + : null; + + return { + hasRoot: Boolean(root), + hasScroller: Boolean(scroller), + scrollTop: scroller?.scrollTop ?? null, + scrollHeight: scroller?.scrollHeight ?? null, + clientHeight: scroller?.clientHeight ?? null, + latestTurnId: latest?.dataset.turnId ?? targetTurnId ?? null, + latestTop: latestRect?.top ?? null, + latestBottom: latestRect?.bottom ?? null, + latestRendered: Boolean(latest), + latestModelRoundRendered: latestModelRoundSegments.length > 0, + latestModelRoundVisible, + latestModelRoundTextLength, + latestContentVisible: latestVisible || latestModelRoundVisible, + historyPlaceholderVisible, + scrollerTop: scrollerRect?.top ?? null, + scrollerBottom: scrollerRect?.bottom ?? null, + effectiveScrollerBottom, + inputOverlayTop: inputOverlayRect?.top ?? null, + inputOverlayBottom: inputOverlayRect?.bottom ?? null, + inputOverlayHeight: inputOverlayRect?.height ?? null, + latestVisible, + visibleTurnIds: visibleUserMessages + .map(element => element.dataset.turnId) + .filter((turnId): turnId is string => Boolean(turnId)), + visibleUserMessageCount: visibleUserMessages.length, + userMessageCount: userMessages.length, + visibleItemCount: visibleItems.length, + visibleItemTypes: visibleItems + .map(({ element }) => element.dataset.itemType) + .filter((itemType): itemType is string => Boolean(itemType)), + visibleModelRoundCount: visibleItems + .filter(({ element }) => element.dataset.itemType === 'model-round') + .length, + visibleExploreGroupCount: visibleItems + .filter(({ element }) => element.dataset.itemType === 'explore-group') + .length, + visibleTextLength, + visibleItemHeightStats, + visibleItemSummaries, + coveredViewportPx, + coverageRatio: effectiveViewportHeight && effectiveViewportHeight > 0 + ? coveredViewportPx / effectiveViewportHeight + : null, + topBlankPx, + largestBlankGapPx, + bottomBlankPx, + }; + }, expectedLatestTurnId ?? null); +} + +async function startLongSessionViewportTimelineRecorder( + expectedLatestTurnId: string, + clickedAtMs: number, + enableRenderProfile: boolean, +): Promise { + await browser.execute((targetTurnId, clickTime, shouldEnableRenderProfile) => { + const globalWindow = window as typeof window & { + __bitfunLongSessionViewportTimeline?: LongSessionViewportTimelineSample[]; + __bitfunLongSessionMainThreadTasks?: LongSessionMainThreadTask[]; + __bitfunLongSessionViewportTimelineTimer?: number; + __bitfunLongSessionLongTaskObserver?: PerformanceObserver; + __BITFUN_RENDER_PROFILE_ENABLED__?: boolean; + }; + globalWindow.__BITFUN_RENDER_PROFILE_ENABLED__ = shouldEnableRenderProfile; + if (globalWindow.__bitfunLongSessionViewportTimelineTimer !== undefined) { + window.clearInterval(globalWindow.__bitfunLongSessionViewportTimelineTimer); + } + globalWindow.__bitfunLongSessionLongTaskObserver?.disconnect(); + const samples: LongSessionViewportTimelineSample[] = []; + const mainThreadTasks: LongSessionMainThreadTask[] = []; + try { + if (PerformanceObserver.supportedEntryTypes.includes('longtask')) { + const observer = new PerformanceObserver(list => { + for (const entry of list.getEntries()) { + mainThreadTasks.push({ + startMs: entry.startTime, + sinceClickMs: entry.startTime - clickTime, + durationMs: entry.duration, + name: entry.name, + entryType: entry.entryType, + }); + if (mainThreadTasks.length > 120) { + mainThreadTasks.shift(); + } + } + }); + observer.observe({ entryTypes: ['longtask'] }); + globalWindow.__bitfunLongSessionLongTaskObserver = observer; + } + } catch { + globalWindow.__bitfunLongSessionLongTaskObserver = undefined; + } + const readSample = (): LongSessionViewportTimelineSample => { + const root = document.querySelector( + '.modern-flowchat-container__messages .virtual-message-list', + ); + const scroller = root?.querySelector( + '[data-virtuoso-scroller="true"], [data-virtuoso-scroller]', + ) ?? null; + const inputOverlay = document.querySelector('.bitfun-chat-input-drop-zone'); + const scrollerRect = scroller?.getBoundingClientRect() ?? null; + const inputOverlayRect = inputOverlay?.getBoundingClientRect() ?? null; + const historyPlaceholder = document.querySelector( + '.modern-flowchat-container__messages .history-session-placeholder', + ); + const historyPlaceholderRect = historyPlaceholder?.getBoundingClientRect() ?? null; + const historyPlaceholderStyle = historyPlaceholder + ? window.getComputedStyle(historyPlaceholder) + : null; + const historyPlaceholderVisible = Boolean( + historyPlaceholder && + historyPlaceholderRect && + historyPlaceholderRect.width > 0 && + historyPlaceholderRect.height > 0 && + historyPlaceholderStyle?.visibility !== 'hidden' && + historyPlaceholderStyle?.display !== 'none' && + historyPlaceholderStyle?.opacity !== '0' + ); + const effectiveScrollerBottom = scrollerRect + ? Math.min(scrollerRect.bottom, inputOverlayRect?.top ?? scrollerRect.bottom) + : null; + const latest = targetTurnId + ? root?.querySelector( + `.virtual-item-wrapper[data-turn-id="${targetTurnId}"][data-item-type="user-message"]`, + ) ?? null + : null; + const latestModelRoundSegments = targetTurnId + ? Array.from(root?.querySelectorAll( + `.virtual-item-wrapper[data-turn-id="${targetTurnId}"][data-item-type="model-round"]`, + ) ?? []) + : []; + const latestRect = latest?.getBoundingClientRect() ?? null; + const isVisibleWithinScroller = (rect: DOMRect | null): boolean => Boolean( + scrollerRect && + rect && + rect.bottom > scrollerRect.top && + rect.top < (effectiveScrollerBottom ?? scrollerRect.bottom) + ); + const latestModelRoundVisibleSegments = latestModelRoundSegments + .filter(element => isVisibleWithinScroller(element.getBoundingClientRect())); + const latestModelRoundVisible = latestModelRoundVisibleSegments.length > 0; + const latestModelRoundTextLength = latestModelRoundVisibleSegments + .reduce((total, element) => total + (element.innerText?.length ?? 0), 0); + const latestVisible = isVisibleWithinScroller(latestRect); + const renderedItems = scrollerRect + ? Array.from(root?.querySelectorAll('.virtual-item-wrapper[data-turn-id]') ?? []) + .map(element => { + const rect = element.getBoundingClientRect(); + const top = Math.max(rect.top, scrollerRect.top); + const bottom = Math.min(rect.bottom, effectiveScrollerBottom ?? scrollerRect.bottom); + const visible = ( + bottom > scrollerRect.top && + top < (effectiveScrollerBottom ?? scrollerRect.bottom) && + bottom > top + ); + return { + element, + rect, + top, + bottom, + visible, + }; + }) + .sort((left, right) => left.rect.top - right.rect.top) + : []; + const visibleItems = renderedItems + .filter(item => item.visible) + .sort((left, right) => left.top - right.top); + const visibleTextLength = visibleItems + .reduce((total, { element }) => total + (element.innerText?.length ?? 0), 0); + const summarizeItem = ({ element, rect, visible }: typeof renderedItems[number]) => ({ + type: element.dataset.itemType ?? null, + turnId: element.dataset.turnId ?? null, + top: scrollerRect ? rect.top - scrollerRect.top : 0, + bottom: scrollerRect ? rect.bottom - scrollerRect.top : 0, + height: Math.max(0, rect.bottom - rect.top), + textLength: element.innerText?.length ?? 0, + textContentLength: element.textContent?.length ?? 0, + opacity: window.getComputedStyle(element).opacity ?? null, + visible, + }); + const visibleItemSummaries = visibleItems.map(item => { + const summary = summarizeItem(item); + return { + type: summary.type, + turnId: summary.turnId, + top: summary.top, + bottom: summary.bottom, + height: summary.height, + textLength: summary.textLength, + textContentLength: summary.textContentLength, + opacity: summary.opacity, + }; + }); + const renderedItemSummaries = renderedItems + .slice(0, 16) + .map(summarizeItem); + + let coveredViewportPx = 0; + let topBlankPx: number | null = null; + let largestBlankGapPx: number | null = null; + let bottomBlankPx: number | null = null; + if (scrollerRect) { + let cursor = scrollerRect.top; + let maxBottom = scrollerRect.top; + visibleItems.forEach((item, index) => { + if (item.top > cursor) { + const gap = item.top - cursor; + if (index === 0) { + topBlankPx = gap; + } + largestBlankGapPx = Math.max(largestBlankGapPx ?? 0, gap); + } + const coveredStart = Math.max(cursor, item.top); + if (item.bottom > coveredStart) { + coveredViewportPx += item.bottom - coveredStart; + cursor = Math.max(cursor, item.bottom); + maxBottom = Math.max(maxBottom, item.bottom); + } + }); + if (visibleItems.length === 0) { + topBlankPx = Math.max(0, (effectiveScrollerBottom ?? scrollerRect.bottom) - scrollerRect.top); + } else if (topBlankPx === null) { + topBlankPx = 0; + } + bottomBlankPx = Math.max(0, (effectiveScrollerBottom ?? scrollerRect.bottom) - maxBottom); + largestBlankGapPx = Math.max(largestBlankGapPx ?? 0, bottomBlankPx); + } + const effectiveViewportHeight = scrollerRect && effectiveScrollerBottom !== null + ? Math.max(0, effectiveScrollerBottom - scrollerRect.top) + : null; + const atMs = performance.now(); + + return { + atMs, + sinceClickMs: atMs - clickTime, + hasRoot: Boolean(root), + hasScroller: Boolean(scroller), + latestRendered: Boolean(latest), + latestModelRoundRendered: latestModelRoundSegments.length > 0, + latestModelRoundVisible, + latestModelRoundTextLength, + latestContentVisible: latestVisible || latestModelRoundVisible, + historyPlaceholderVisible, + latestVisible, + latestTurnId: latest?.dataset.turnId ?? targetTurnId ?? null, + scrollTop: scroller?.scrollTop ?? null, + scrollHeight: scroller?.scrollHeight ?? null, + clientHeight: scroller?.clientHeight ?? null, + visibleItemCount: visibleItems.length, + visibleItemTypes: visibleItems + .map(({ element }) => element.dataset.itemType) + .filter((itemType): itemType is string => Boolean(itemType)), + visibleModelRoundCount: visibleItems + .filter(({ element }) => element.dataset.itemType === 'model-round') + .length, + visibleTextLength, + visibleItemSummaries, + renderedItemCount: renderedItems.length, + renderedItemSummaries, + coverageRatio: effectiveViewportHeight && effectiveViewportHeight > 0 + ? coveredViewportPx / effectiveViewportHeight + : null, + topBlankPx, + largestBlankGapPx, + bottomBlankPx, + inputOverlayTop: inputOverlayRect?.top ?? null, + inputOverlayBottom: inputOverlayRect?.bottom ?? null, + }; + }; + + const record = () => { + samples.push(readSample()); + if (samples.length > 120) { + samples.shift(); + } + }; + + globalWindow.__bitfunLongSessionViewportTimeline = samples; + globalWindow.__bitfunLongSessionMainThreadTasks = mainThreadTasks; + record(); + globalWindow.__bitfunLongSessionViewportTimelineTimer = window.setInterval(record, 50); + }, expectedLatestTurnId, clickedAtMs, enableRenderProfile); +} + +async function stopLongSessionViewportTimelineRecorder(): Promise { + return browser.execute(() => { + const globalWindow = window as typeof window & { + __bitfunLongSessionViewportTimeline?: LongSessionViewportTimelineSample[]; + __bitfunLongSessionMainThreadTasks?: LongSessionMainThreadTask[]; + __bitfunLongSessionViewportTimelineTimer?: number; + __bitfunLongSessionLongTaskObserver?: PerformanceObserver; + __BITFUN_RENDER_PROFILE_ENABLED__?: boolean; + }; + if (globalWindow.__bitfunLongSessionViewportTimelineTimer !== undefined) { + window.clearInterval(globalWindow.__bitfunLongSessionViewportTimelineTimer); + globalWindow.__bitfunLongSessionViewportTimelineTimer = undefined; + } + globalWindow.__bitfunLongSessionLongTaskObserver?.disconnect(); + globalWindow.__bitfunLongSessionLongTaskObserver = undefined; + const samples = globalWindow.__bitfunLongSessionViewportTimeline ?? []; + const mainThreadTasks = globalWindow.__bitfunLongSessionMainThreadTasks ?? []; + globalWindow.__bitfunLongSessionViewportTimeline = undefined; + globalWindow.__bitfunLongSessionMainThreadTasks = undefined; + globalWindow.__BITFUN_RENDER_PROFILE_ENABLED__ = false; + return { samples, mainThreadTasks }; + }); +} + +async function waitForLatestLongSessionTurnVisible(timeoutMs: number, expectedLatestTurnId?: string | null): Promise<{ + visibleAtMs: number; + viewport: LongSessionViewportState; +}> { + let viewport = await readLongSessionViewportState(expectedLatestTurnId); + try { + await browser.waitUntil(async () => { + viewport = await readLongSessionViewportState(expectedLatestTurnId); + return viewport.latestContentVisible; + }, { + timeout: timeoutMs, + interval: 50, + timeoutMsg: 'latest long-session content did not become visible', + }); + } catch (error) { + viewport = await readLongSessionViewportState(expectedLatestTurnId); + const snapshot = await readStartupTraceSnapshot().catch(() => null); + const relatedEvents = snapshot?.phases.events + .filter(event => + event.phase.includes('latest_anchor') || + event.phase.includes('latest_end_anchor') || + event.phase.includes('turn_pin') + ) + .slice(-30) ?? []; + throw new Error( + `${error instanceof Error ? error.message : String(error)}; ` + + `viewport=${JSON.stringify(viewport)}; ` + + `relatedEvents=${JSON.stringify(relatedEvents)}`, + ); + } + + return { + visibleAtMs: await readPerformanceNow(), + viewport, + }; +} + +function isLongSessionViewportUsable(viewport: LongSessionViewportState): boolean { + const coverageRatio = viewport.coverageRatio ?? 0; + const bottomBlankPx = viewport.bottomBlankPx ?? Number.POSITIVE_INFINITY; + const largestBlankGapPx = viewport.largestBlankGapPx ?? Number.POSITIVE_INFINITY; + return ( + viewport.latestContentVisible && + viewport.latestModelRoundVisible && + viewport.latestModelRoundTextLength > 0 && + coverageRatio >= LONG_SESSION_VIEWPORT_MIN_COVERAGE_RATIO && + bottomBlankPx <= LONG_SESSION_VIEWPORT_MAX_BOTTOM_BLANK_PX && + largestBlankGapPx <= LONG_SESSION_VIEWPORT_MAX_BLANK_GAP_PX + ); +} + +function isLongSessionLatestVisibleViewportPositioned(viewport: LongSessionViewportState): boolean { + const coverageRatio = viewport.coverageRatio ?? 0; + const bottomBlankPx = viewport.bottomBlankPx ?? Number.POSITIVE_INFINITY; + const largestBlankGapPx = viewport.largestBlankGapPx ?? Number.POSITIVE_INFINITY; + return ( + viewport.latestContentVisible && + coverageRatio >= LONG_SESSION_VIEWPORT_MIN_COVERAGE_RATIO && + bottomBlankPx <= LONG_SESSION_LATEST_VISIBLE_MAX_BOTTOM_BLANK_PX && + largestBlankGapPx <= LONG_SESSION_LATEST_VISIBLE_MAX_BLANK_GAP_PX + ); +} + +async function maybeSavePerfScreenshot(name: string): Promise { + if (process.env.BITFUN_E2E_PERF_SCREENSHOTS !== '1') { + return null; + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const screenshotsDir = path.resolve(process.cwd(), 'reports', 'screenshots'); + await fs.mkdir(screenshotsDir, { recursive: true }); + const screenshotPath = path.join(screenshotsDir, `${name}-${timestamp}.png`); + await browser.saveScreenshot(screenshotPath); + return screenshotPath; +} + +function isLongSessionInputAnchoredNearBottom(viewport: LongSessionViewportState): boolean { + if ( + viewport.scrollerTop === null || + viewport.scrollerBottom === null || + viewport.clientHeight === null || + viewport.inputOverlayTop === null || + viewport.inputOverlayBottom === null + ) { + return false; + } + + const minTop = viewport.scrollerTop + viewport.clientHeight * LONG_SESSION_INPUT_MIN_TOP_RATIO; + const bottomDistance = Math.abs(viewport.scrollerBottom - viewport.inputOverlayBottom); + return ( + viewport.inputOverlayTop >= minTop && + bottomDistance <= LONG_SESSION_INPUT_BOTTOM_TOLERANCE_PX + ); +} + +function summarizeLongSessionViewportTimeline( + samples: LongSessionViewportTimelineSample[], +): LongSessionViewportTimelineSummary { + const firstScroller = samples.find(sample => sample.hasScroller); + const firstScrollerBlank = samples.find(sample => + sample.hasScroller && + sample.visibleItemCount === 0 + ); + const firstVisibleItem = samples.find(sample => sample.visibleItemCount > 0); + const firstHistoryPlaceholder = samples.find(sample => sample.historyPlaceholderVisible); + const firstLatestVisible = samples.find(sample => sample.latestVisible); + const firstLatestContentVisible = samples.find(sample => sample.latestContentVisible); + const firstLatestTextVisible = samples.find(sample => + sample.latestModelRoundVisible && + sample.latestModelRoundTextLength > 0 + ); + const latestVisibleTextlessSamples = samples.filter(sample => + sample.latestVisible && + sample.latestModelRoundVisible && + sample.latestModelRoundTextLength === 0 + ); + const firstLatestVisibleTextless = latestVisibleTextlessSamples[0]; + const latestContentVisibleTextlessSamples = samples.filter(sample => + sample.latestContentVisible && + sample.latestModelRoundVisible && + sample.latestModelRoundTextLength === 0 + ); + const firstLatestContentVisibleTextless = latestContentVisibleTextlessSamples[0]; + const textlessBlankGaps = latestVisibleTextlessSamples + .map(sample => sample.largestBlankGapPx) + .filter((value): value is number => typeof value === 'number'); + const textlessBottomBlanks = latestVisibleTextlessSamples + .map(sample => sample.bottomBlankPx) + .filter((value): value is number => typeof value === 'number'); + const preLatestTextVisibleBlankSamples = firstLatestTextVisible + ? samples.filter(sample => + sample.sinceClickMs < firstLatestTextVisible.sinceClickMs && + sample.hasRoot && + sample.hasScroller && + sample.visibleItemCount === 0 + ) + : samples.filter(sample => + sample.hasRoot && + sample.hasScroller && + sample.visibleItemCount === 0 + ); + const preLatestTextVisibleBlankGaps = preLatestTextVisibleBlankSamples + .map(sample => sample.largestBlankGapPx) + .filter((value): value is number => typeof value === 'number'); + const preLatestTextVisibleBlankWithoutPlaceholderSamples = preLatestTextVisibleBlankSamples + .filter(sample => !sample.historyPlaceholderVisible); + const preLatestTextVisibleBlankWithoutPlaceholderGaps = preLatestTextVisibleBlankWithoutPlaceholderSamples + .map(sample => sample.largestBlankGapPx) + .filter((value): value is number => typeof value === 'number'); + const preLatestTextVisibleBottomBlanks = preLatestTextVisibleBlankSamples + .map(sample => sample.bottomBlankPx) + .filter((value): value is number => typeof value === 'number'); + const postLatestTextVisibleBlankSamples = firstLatestTextVisible + ? samples.filter(sample => + sample.sinceClickMs > firstLatestTextVisible.sinceClickMs && + sample.hasRoot && + sample.hasScroller && + sample.visibleItemCount === 0 + ) + : []; + const postLatestTextVisibleBlankGaps = postLatestTextVisibleBlankSamples + .map(sample => sample.largestBlankGapPx) + .filter((value): value is number => typeof value === 'number'); + const postLatestTextVisibleBottomBlanks = postLatestTextVisibleBlankSamples + .map(sample => sample.bottomBlankPx) + .filter((value): value is number => typeof value === 'number'); + const postLatestTextVisibleLatestContentMissingSamples = firstLatestTextVisible + ? samples.filter(sample => + sample.sinceClickMs > firstLatestTextVisible.sinceClickMs && + sample.hasRoot && + sample.hasScroller && + !sample.latestContentVisible + ) + : []; + + return { + firstScrollerAtMs: firstScroller?.sinceClickMs ?? null, + firstScrollerBlankAtMs: firstScrollerBlank?.sinceClickMs ?? null, + firstVisibleItemAtMs: firstVisibleItem?.sinceClickMs ?? null, + firstHistoryPlaceholderAtMs: firstHistoryPlaceholder?.sinceClickMs ?? null, + firstLatestVisibleAtMs: firstLatestVisible?.sinceClickMs ?? null, + firstLatestContentVisibleAtMs: firstLatestContentVisible?.sinceClickMs ?? null, + firstLatestTextVisibleAtMs: firstLatestTextVisible?.sinceClickMs ?? null, + firstLatestVisibleTextlessAtMs: firstLatestVisibleTextless?.sinceClickMs ?? null, + firstLatestContentVisibleTextlessAtMs: firstLatestContentVisibleTextless?.sinceClickMs ?? null, + latestTextDelayAfterVisibleMs: ( + firstLatestVisible && firstLatestTextVisible + ? firstLatestTextVisible.sinceClickMs - firstLatestVisible.sinceClickMs + : null + ), + latestTextDelayAfterContentVisibleMs: ( + firstLatestContentVisible && firstLatestTextVisible + ? firstLatestTextVisible.sinceClickMs - firstLatestContentVisible.sinceClickMs + : null + ), + latestVisibleTextlessSampleCount: latestVisibleTextlessSamples.length, + latestContentVisibleTextlessSampleCount: latestContentVisibleTextlessSamples.length, + maxTextlessVisibleBlankGapPx: textlessBlankGaps.length > 0 + ? Math.max(...textlessBlankGaps) + : null, + maxTextlessVisibleBottomBlankPx: textlessBottomBlanks.length > 0 + ? Math.max(...textlessBottomBlanks) + : null, + preLatestTextVisibleBlankSampleCount: preLatestTextVisibleBlankSamples.length, + preLatestTextVisibleBlankWithoutPlaceholderSampleCount: preLatestTextVisibleBlankWithoutPlaceholderSamples.length, + maxPreLatestTextVisibleBlankGapPx: preLatestTextVisibleBlankGaps.length > 0 + ? Math.max(...preLatestTextVisibleBlankGaps) + : null, + maxPreLatestTextVisibleBlankWithoutPlaceholderGapPx: preLatestTextVisibleBlankWithoutPlaceholderGaps.length > 0 + ? Math.max(...preLatestTextVisibleBlankWithoutPlaceholderGaps) + : null, + maxPreLatestTextVisibleBottomBlankPx: preLatestTextVisibleBottomBlanks.length > 0 + ? Math.max(...preLatestTextVisibleBottomBlanks) + : null, + postLatestTextVisibleBlankSampleCount: postLatestTextVisibleBlankSamples.length, + maxPostLatestTextVisibleBlankGapPx: postLatestTextVisibleBlankGaps.length > 0 + ? Math.max(...postLatestTextVisibleBlankGaps) + : null, + maxPostLatestTextVisibleBottomBlankPx: postLatestTextVisibleBottomBlanks.length > 0 + ? Math.max(...postLatestTextVisibleBottomBlanks) + : null, + postLatestTextVisibleLatestContentMissingSampleCount: postLatestTextVisibleLatestContentMissingSamples.length, + }; +} + +async function waitForLatestLongSessionViewportUsable(timeoutMs: number, expectedLatestTurnId?: string | null): Promise<{ + usableAtMs: number; + viewport: LongSessionViewportState; +}> { + let viewport = await readLongSessionViewportState(expectedLatestTurnId); + try { + await browser.waitUntil(async () => { + viewport = await readLongSessionViewportState(expectedLatestTurnId); + return isLongSessionViewportUsable(viewport); + }, { + timeout: timeoutMs, + interval: 50, + timeoutMsg: 'latest long-session viewport did not become usable', + }); + } catch (error) { + viewport = await readLongSessionViewportState(expectedLatestTurnId); + const snapshot = await readStartupTraceSnapshot().catch(() => null); + const relatedEvents = snapshot?.phases.events + .filter(event => + event.phase.includes('latest_anchor') || + event.phase.includes('latest_end_anchor') || + event.phase.includes('turn_pin') + ) + .slice(-30) ?? []; + throw new Error( + `${error instanceof Error ? error.message : String(error)}; ` + + `viewport=${JSON.stringify(viewport)}; ` + + `relatedEvents=${JSON.stringify(relatedEvents)}`, + ); + } + + return { + usableAtMs: await readPerformanceNow(), + viewport, + }; +} + +type LongSessionOpenMeasurement = { + appMode: string; + sessionId: string; + fixtureScenario: string | null; + expectedLatestTurnId: string | null; + clickedAtMs: number; + sessionOpen: ReturnType; + latestVisibleAtMs: number; + clickToLatestVisibleMs: number; + latestUsableAtMs: number; + clickToLatestUsableMs: number; + latestAnswerTextVisibleAtMs: number; + clickToLatestAnswerTextVisibleMs: number; + finalViewportCheckedAtMs: number; + postHydrateUsableAtMs?: number; + clickToPostHydrateUsableMs?: number; + latestVisibleViewport: LongSessionViewportState; + latestUsableViewport: LongSessionViewportState; + latestAnswerTextVisibleViewport: LongSessionViewportState; + viewport: LongSessionViewportState; + viewportTimeline: LongSessionViewportTimelineSample[]; + viewportTimelineSummary: LongSessionViewportTimelineSummary; + mainThreadTasks: LongSessionMainThreadTask[]; + screenshotPath: string | null; + events: StartupTraceSnapshot['phases']['events']; + apiSegments: ReturnType; + api: StartupTraceSnapshot['api']; + native: StartupTraceSnapshot['native']; +}; + +type LongSessionOpenMeasurementOptions = { + requireFrameTrace?: boolean; +}; + +async function collectLongSessionOpenMeasurement( + sessionId: string, + expectedLatestTurnId: string | null, + options: LongSessionOpenMeasurementOptions = {}, +): Promise { + await switchAwayFromSession(sessionId); + + const item = await findSessionItem(sessionId); + if (!item) { + return null; + } + if (!expectedLatestTurnId) { + throw new Error(`Could not resolve expected latest turn id for session ${sessionId}`); + } + const fixtureScenario = await readLongSessionFixtureScenario(sessionId); + + const beforeClickSnapshot = await readStartupTraceSnapshot(); + const frameCountBefore = countPhase( + beforeClickSnapshot, + 'historical_session_after_state_commit_frame', + ); + const fullHydrateCountBefore = countPhase( + beforeClickSnapshot, + 'historical_session_full_hydrate_end', + ); + const fullHydrateFrameCountBefore = countPhase( + beforeClickSnapshot, + 'historical_session_full_hydrate_after_state_commit_frame', + ); + const latestAnchorAttemptCountBefore = countPhase( + beforeClickSnapshot, + 'historical_session_latest_anchor_attempt', + ); + const requireFrameTrace = options.requireFrameTrace !== false; + const afterFrameTimeoutMs = requireFrameTrace ? 20000 : 1000; + const fullHydrateTimeoutMs = requireFrameTrace ? 10000 : 1000; + const latestAnchorTimeoutMs = requireFrameTrace ? 5000 : 1000; + const clickedAtMs = await readPerformanceNow(); + await startLongSessionViewportTimelineRecorder( + expectedLatestTurnId, + clickedAtMs, + process.env.BITFUN_E2E_RENDER_PROFILE === '1', + ); + + await item.click(); + const latestVisiblePromise = waitForLatestLongSessionTurnVisible(5000, expectedLatestTurnId); + const latestUsablePromise = waitForLatestLongSessionViewportUsable(5000, expectedLatestTurnId); + + const afterFrameSnapshot = requireFrameTrace + ? await waitForTracePhaseCount( + 'historical_session_after_state_commit_frame', + frameCountBefore + 1, + afterFrameTimeoutMs, + ) + : await waitForOptionalPhaseCount( + 'historical_session_after_state_commit_frame', + frameCountBefore + 1, + afterFrameTimeoutMs, + ); + const afterFullSnapshot = await waitForOptionalPhaseCount( + 'historical_session_full_hydrate_end', + fullHydrateCountBefore + 1, + fullHydrateTimeoutMs, + ); + const afterFullFrameSnapshot = await waitForOptionalPhaseCount( + 'historical_session_full_hydrate_after_state_commit_frame', + fullHydrateFrameCountBefore + 1, + fullHydrateTimeoutMs, + ); + const afterAnchorSnapshot = await waitForOptionalPhaseCount( + 'historical_session_latest_anchor_attempt', + latestAnchorAttemptCountBefore + 1, + latestAnchorTimeoutMs, + ); + const latestVisible = await latestVisiblePromise; + const latestUsable = await latestUsablePromise; + let finalViewport = await readLongSessionViewportState(expectedLatestTurnId); + let finalViewportCheckedAtMs = await readPerformanceNow(); + if (!isLongSessionViewportUsable(finalViewport)) { + const finalUsable = await waitForLatestLongSessionViewportUsable( + 3000, + expectedLatestTurnId, + ); + finalViewport = finalUsable.viewport; + finalViewportCheckedAtMs = finalUsable.usableAtMs; + } + const viewportTimeline = await stopLongSessionViewportTimelineRecorder(); + const viewportTimelineSummary = summarizeLongSessionViewportTimeline(viewportTimeline.samples); + const finalSnapshot = await readStartupTraceSnapshot() + .catch(() => [ + afterFrameSnapshot, + afterFullSnapshot, + afterFullFrameSnapshot, + afterAnchorSnapshot, + ].reduce((latest, snapshot) => + snapshot.phases.events.length >= latest.phases.events.length ? snapshot : latest + )); + const sessionEvents = finalSnapshot.phases.events.filter(event => + event.atMs >= clickedAtMs && + ( + event.phase.startsWith('historical_session') || + event.phase.startsWith('flowchat_latest_end_anchor') || + event.phase === 'react_render_profile' + ) + ); + const screenshotPath = await maybeSavePerfScreenshot(`long-session-${sessionId}`); + + return { + appMode: process.env.BITFUN_E2E_APP_MODE ?? 'auto', + sessionId, + fixtureScenario, + expectedLatestTurnId, + clickedAtMs, + sessionOpen: summarizeSessionOpen(sessionEvents, clickedAtMs), + latestVisibleAtMs: latestVisible.visibleAtMs, + clickToLatestVisibleMs: latestVisible.visibleAtMs - clickedAtMs, + latestUsableAtMs: latestUsable.usableAtMs, + clickToLatestUsableMs: latestUsable.usableAtMs - clickedAtMs, + latestAnswerTextVisibleAtMs: latestUsable.usableAtMs, + clickToLatestAnswerTextVisibleMs: latestUsable.usableAtMs - clickedAtMs, + finalViewportCheckedAtMs, + ...(requireFrameTrace + ? { + postHydrateUsableAtMs: finalViewportCheckedAtMs, + clickToPostHydrateUsableMs: finalViewportCheckedAtMs - clickedAtMs, + } + : {}), + latestVisibleViewport: latestVisible.viewport, + latestUsableViewport: latestUsable.viewport, + latestAnswerTextVisibleViewport: latestUsable.viewport, + viewport: finalViewport, + viewportTimeline: viewportTimeline.samples, + viewportTimelineSummary, + mainThreadTasks: viewportTimeline.mainThreadTasks, + screenshotPath, + events: sessionEvents, + apiSegments: summarizeApiCommandSegments(finalSnapshot), + api: finalSnapshot.api, + native: finalSnapshot.native, + }; +} + +function expectLongSessionMeasurementUsable( + measurement: LongSessionOpenMeasurement, + maxLatestFrameMs?: number, + options: LongSessionOpenMeasurementOptions = {}, +): void { + expect(measurement.clickToLatestVisibleMs).toBeGreaterThan(0); + expect(measurement.clickToLatestUsableMs).toBeGreaterThan(0); + if (options.requireFrameTrace !== false) { + expect(measurement.clickToPostHydrateUsableMs).toBeGreaterThan(0); + } + if (options.requireFrameTrace !== false) { + expect(measurement.sessionOpen.hydrateDurationMs).toBeGreaterThan(0); + expect(measurement.sessionOpen.latestFrameSinceHydrateMs).toBeGreaterThan(0); + expect(measurement.sessionOpen.clickToLatestFrameMs).toBeGreaterThan(0); + } + expect(measurement.viewport.hasScroller).toBe(true); + expect(measurement.viewport.latestContentVisible).toBe(true); + expect(measurement.viewport.latestModelRoundVisible).toBe(true); + expect(measurement.viewport.latestModelRoundTextLength).toBeGreaterThan(0); + expect(measurement.viewport.latestTurnId).toBe(measurement.expectedLatestTurnId); + expect(measurement.latestVisibleViewport.hasScroller).toBe(true); + expect(measurement.latestVisibleViewport.latestContentVisible).toBe(true); + expect(measurement.latestVisibleViewport.latestModelRoundVisible).toBe(true); + expect(measurement.latestVisibleViewport.latestTurnId).toBe(measurement.expectedLatestTurnId); + expect(isLongSessionLatestVisibleViewportPositioned(measurement.latestVisibleViewport)).toBe(true); + expect(measurement.latestAnswerTextVisibleViewport.latestModelRoundVisible).toBe(true); + expect(measurement.latestAnswerTextVisibleViewport.latestModelRoundTextLength).toBeGreaterThan(0); + expect(isLongSessionViewportUsable(measurement.latestAnswerTextVisibleViewport)).toBe(true); + if (measurement.viewportTimelineSummary.latestTextDelayAfterContentVisibleMs !== null) { + expect(measurement.viewportTimelineSummary.latestTextDelayAfterContentVisibleMs) + .toBeLessThanOrEqual(LONG_SESSION_MAX_LATEST_TEXT_DELAY_AFTER_VISIBLE_MS); + } + expect(measurement.viewportTimelineSummary.preLatestTextVisibleBlankWithoutPlaceholderSampleCount).toBe(0); + expect(measurement.viewportTimelineSummary.postLatestTextVisibleBlankSampleCount).toBe(0); + expect(measurement.viewportTimelineSummary.postLatestTextVisibleLatestContentMissingSampleCount).toBe(0); + if (measurement.fixtureScenario === 'mixed-visible') { + expect(measurement.latestVisibleViewport.visibleModelRoundCount).toBeGreaterThan(0); + } + expect(isLongSessionInputAnchoredNearBottom(measurement.latestVisibleViewport)).toBe(true); + expect(isLongSessionViewportUsable(measurement.viewport)).toBe(true); + expect(isLongSessionInputAnchoredNearBottom(measurement.viewport)).toBe(true); + if ( + maxLatestFrameMs !== undefined && + measurement.sessionOpen.latestFrameSinceHydrateMs !== undefined + ) { + expect(measurement.sessionOpen.latestFrameSinceHydrateMs).toBeLessThanOrEqual(maxLatestFrameMs); + } } describe('Performance telemetry', () => { @@ -141,6 +1328,7 @@ describe('Performance telemetry', () => { const snapshot = await readStartupTraceSnapshot(); const startup = summarizeStartup(snapshot); const breakdown = summarizeStartupBreakdown(snapshot); + const apiSegments = summarizeApiCommandSegments(snapshot); const maxInteractiveMs = numericEnv('BITFUN_E2E_PERF_MAX_INTERACTIVE_MS'); console.log('[Perf] startup', JSON.stringify({ @@ -156,6 +1344,7 @@ describe('Performance telemetry', () => { traceId: snapshot.traceId, startup, breakdown, + apiSegments, api: snapshot.api, native: snapshot.native, phases: snapshot.phases.events, @@ -170,80 +1359,59 @@ describe('Performance telemetry', () => { it('collects first-open timing for a generated long session', async function () { const sessionId = process.env.BITFUN_E2E_PERF_SESSION_ID || DEFAULT_PERF_SESSION_ID; - await switchAwayIfSessionIsActive(sessionId); - - const item = await findSessionItem(sessionId); - if (!item) { + const expectedLatestTurnId = await readExpectedLatestTurnId(sessionId); + const measurement = await collectLongSessionOpenMeasurement( + sessionId, + expectedLatestTurnId, + { requireFrameTrace: true }, + ); + if (!measurement) { + if (expectedLatestTurnId) { + throw new Error(`Session ${sessionId} exists on disk but was not reachable from the session navigation.`); + } console.log(`[Perf] Session ${sessionId} not found; generate it before running this spec.`); this.skip(); return; } + const maxLatestFrameMs = numericEnv('BITFUN_E2E_PERF_MAX_SESSION_FRAME_MS'); - const beforeClickSnapshot = await readStartupTraceSnapshot(); - const frameCountBefore = countPhase( - beforeClickSnapshot, - 'historical_session_after_state_commit_frame', - ); - const fullHydrateCountBefore = countPhase( - beforeClickSnapshot, - 'historical_session_full_hydrate_end', - ); - const fullHydrateFrameCountBefore = countPhase( - beforeClickSnapshot, - 'historical_session_full_hydrate_after_state_commit_frame', - ); - const clickedAtMs = await readPerformanceNow(); + console.log('[Perf] long-session-first-open', JSON.stringify({ + appMode: measurement.appMode, + sessionId, + fixtureScenario: measurement.fixtureScenario, + sessionOpen: measurement.sessionOpen, + })); - await item.click(); + await writeReport('long-session-first-open', measurement); + expectLongSessionMeasurementUsable(measurement, maxLatestFrameMs, { requireFrameTrace: true }); + }); - const afterFrameSnapshot = await waitForTracePhaseCount( - 'historical_session_after_state_commit_frame', - frameCountBefore + 1, - 20000, - ); - const afterFullSnapshot = await waitForOptionalPhaseCount( - 'historical_session_full_hydrate_end', - fullHydrateCountBefore + 1, - 10000, - ); - const afterFullFrameSnapshot = await waitForOptionalPhaseCount( - 'historical_session_full_hydrate_after_state_commit_frame', - fullHydrateFrameCountBefore + 1, - 10000, - ); - const finalSnapshot = [ - afterFrameSnapshot, - afterFullSnapshot, - afterFullFrameSnapshot, - ].reduce((latest, snapshot) => - snapshot.phases.events.length >= latest.phases.events.length ? snapshot : latest - ); - const sessionEvents = finalSnapshot.phases.events.filter(event => - event.atMs >= clickedAtMs && - event.phase.startsWith('historical_session') + it('collects warm-reopen timing for a generated long session', async function () { + const sessionId = process.env.BITFUN_E2E_PERF_SESSION_ID || DEFAULT_PERF_SESSION_ID; + const expectedLatestTurnId = await readExpectedLatestTurnId(sessionId); + const measurement = await collectLongSessionOpenMeasurement( + sessionId, + expectedLatestTurnId, + { requireFrameTrace: false }, ); - const sessionOpen = summarizeSessionOpen(sessionEvents, clickedAtMs); + if (!measurement) { + if (expectedLatestTurnId) { + throw new Error(`Session ${sessionId} exists on disk but was not reachable from the session navigation.`); + } + console.log(`[Perf] Session ${sessionId} not found; generate it before running this spec.`); + this.skip(); + return; + } const maxLatestFrameMs = numericEnv('BITFUN_E2E_PERF_MAX_SESSION_FRAME_MS'); - console.log('[Perf] long-session-first-open', JSON.stringify({ - appMode: process.env.BITFUN_E2E_APP_MODE ?? 'auto', + console.log('[Perf] long-session-warm-reopen', JSON.stringify({ + appMode: measurement.appMode, sessionId, - sessionOpen, + fixtureScenario: measurement.fixtureScenario, + sessionOpen: measurement.sessionOpen, })); - await writeReport('long-session-first-open', { - appMode: process.env.BITFUN_E2E_APP_MODE ?? 'auto', - sessionId, - clickedAtMs, - sessionOpen, - events: sessionEvents, - api: finalSnapshot.api, - }); - expect(sessionOpen.hydrateDurationMs).toBeGreaterThan(0); - expect(sessionOpen.latestFrameSinceHydrateMs).toBeGreaterThan(0); - expect(sessionOpen.clickToLatestFrameMs).toBeGreaterThan(0); - if (maxLatestFrameMs !== undefined) { - expect(sessionOpen.latestFrameSinceHydrateMs).toBeLessThanOrEqual(maxLatestFrameMs); - } + await writeReport('long-session-warm-reopen', measurement); + expectLongSessionMeasurementUsable(measurement, maxLatestFrameMs, { requireFrameTrace: false }); }); });