diff --git a/src/apps/cli/src/peer_host/commands/mod.rs b/src/apps/cli/src/peer_host/commands/mod.rs index 5c31acbae0..24ad3c4817 100644 --- a/src/apps/cli/src/peer_host/commands/mod.rs +++ b/src/apps/cli/src/peer_host/commands/mod.rs @@ -72,6 +72,7 @@ pub(crate) async fn dispatch( "list_persisted_sessions_count" => { session::list_persisted_sessions_count(state, args).await } + "load_session_turn_window" => session::load_session_turn_window(state, args).await, "load_session_turns" => session::load_session_turns(state, args).await, "restore_session_view" => session::restore_session_view(state, args).await, "restore_session_with_turns" => session::restore_session_with_turns(state, args).await, diff --git a/src/apps/cli/src/peer_host/commands/session.rs b/src/apps/cli/src/peer_host/commands/session.rs index b7f936022c..bbd21b2c7a 100644 --- a/src/apps/cli/src/peer_host/commands/session.rs +++ b/src/apps/cli/src/peer_host/commands/session.rs @@ -14,7 +14,7 @@ use bitfun_core::util::errors::BitFunError; use bitfun_runtime_ports::{ AgentSessionArchiveRequest, AgentSessionCreateRequest, AgentSessionDeleteRequest, AgentSessionModelUpdateRequest, AgentSessionRenameRequest, AgentThreadGoalGetRequest, - SessionStoragePathRequest, + SessionStoragePathRequest, SessionTurnWindowRequest, }; use crate::diagnostics::{OUTCOME_UNKNOWN_ERROR_CODE, SESSION_IN_USE_ERROR_CODE}; @@ -200,6 +200,37 @@ pub(crate) async fn load_session_turns( serde_json::to_value(turns).map_err(|e| format!("serialize turns: {e}")) } +pub(crate) async fn load_session_turn_window( + state: &PeerHostState, + args: &Value, +) -> Result { + let request = request_value(args); + let session_id = validated_session_id(request)?; + let workspace_path = resolved_session_storage_path(state, request).await?; + let response = state + .compatibility + .load_session_turn_window_from_storage_path( + &workspace_path, + SessionTurnWindowRequest { + workspace_path: workspace_path.clone(), + session_id, + include_internal: optional_bool(request, "includeInternal").unwrap_or(false), + target_storage_turn_index: request + .get("targetStorageTurnIndex") + .and_then(Value::as_u64) + .ok_or_else(|| "targetStorageTurnIndex is required".to_string())? + as usize, + expected_turn_id: optional_string(request, "expectedTurnId"), + expected_catalog_revision: optional_string(request, "expectedCatalogRevision"), + before: request.get("before").and_then(Value::as_u64).unwrap_or(4) as usize, + after: request.get("after").and_then(Value::as_u64).unwrap_or(12) as usize, + }, + ) + .await + .map_err(|e| format!("Failed to load session Turn window: {e}"))?; + serde_json::to_value(response).map_err(|e| format!("serialize Turn window: {e}")) +} + pub(crate) async fn restore_session_view( state: &PeerHostState, args: &Value, @@ -218,7 +249,7 @@ pub(crate) async fn restore_session_view( .filter(|n| *n > 0) .map(|n| n.min(16)); - let (mut session, turns, total_turn_count, timings) = state + let (mut session, turns, total_turn_count, turn_catalog, timings) = state .compatibility .restore_session_view_for_workspace( storage_request, @@ -239,6 +270,7 @@ pub(crate) async fn restore_session_view( Ok(json!({ "session": session_to_json(session, total_turn_count), "turns": turns, + "turnCatalog": turn_catalog, "contextRestoreState": "pending", "isPartial": is_partial, "loadedTurnCount": loaded_turn_count, diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index ee4a46bf82..cc24189af2 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -52,7 +52,7 @@ use bitfun_core::service::config::project_permission_store::{ use bitfun_core::service::remote_ssh::workspace_state::resolve_workspace_session_identity; use bitfun_core::service::session::{ DialogTurnData, SessionMemoryMode, SessionMetadata, SessionRelationship, - SessionRelationshipKind, + SessionRelationshipKind, SessionTurnCatalog, SessionTurnWindowResponse, }; use bitfun_core::service::workspace::WorkspaceKind; use bitfun_core::service::workspace::{WorkspaceActivityMode, WorkspaceCreateOptions}; @@ -62,9 +62,12 @@ use bitfun_core_types::{ WorktreeError, WorktreeErrorCode, }; use bitfun_product_domains::tool_permissions::PermissionRule; +use bitfun_runtime_ports::SessionTurnWindowRequest; const SESSION_VIEW_TOOL_RESULT_TOTAL_CHAR_BUDGET: usize = 512 * 1024; const SESSION_VIEW_TOOL_RESULT_STRING_CHAR_LIMIT: usize = 16 * 1024; +const SESSION_TURN_WINDOW_DEFAULT_BEFORE: usize = 4; +const SESSION_TURN_WINDOW_DEFAULT_AFTER: usize = 12; const SESSION_VIEW_TRUNCATED_MARKER: &str = "\n... Output truncated for session preview"; const SESSION_VIEW_OMITTED_MARKER: &str = "Output omitted from session preview"; @@ -466,6 +469,7 @@ pub struct RestoreSessionWithTurnsResponse { pub struct RestoreSessionViewResponse { pub session: SessionResponse, pub turns: Vec, + pub turn_catalog: SessionTurnCatalog, pub context_restore_state: String, pub is_partial: bool, pub loaded_turn_count: usize, @@ -782,6 +786,28 @@ pub struct RestoreSessionRequest { pub tail_turn_count: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LoadSessionTurnWindowRequest { + pub session_id: String, + pub workspace_path: String, + #[serde(default)] + pub include_internal: bool, + pub target_storage_turn_index: usize, + #[serde(default)] + pub expected_turn_id: Option, + #[serde(default)] + pub expected_catalog_revision: Option, + #[serde(default)] + pub before: Option, + #[serde(default)] + pub after: Option, + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub remote_ssh_host: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ListSessionsRequest { @@ -2922,9 +2948,16 @@ pub async fn restore_session_view( let session = restored.session; let mut turns = restored.turns; let total_turn_count = restored.total_turn_count; + let turn_catalog = restored.turn_catalog; let timings = restored.timings; let loaded_turn_count = turns.len(); let is_partial = loaded_turn_count < total_turn_count; + let turn_catalog_preview_chars = turn_catalog + .entries + .iter() + .filter_map(|entry| entry.preview.as_deref()) + .map(|preview| preview.chars().count()) + .sum::(); if log::log_enabled!(log::Level::Debug) { let payload_stats = restore_turn_payload_stats(&turns); @@ -2951,18 +2984,22 @@ pub async fn restore_session_view( 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={}", + "restore_session_view completed: trace_id={}, session_id={}, turn_count={}, total_turn_count={}, is_partial={}, turn_catalog_complete={}, turn_catalog_entry_count={}, turn_catalog_preview_chars={}, context_restore_state=pending, duration_ms={}", trace_id, request.session_id, turns.len(), total_turn_count, is_partial, + turn_catalog.complete, + turn_catalog.entries.len(), + turn_catalog_preview_chars, started_at.elapsed().as_millis() ); Ok(RestoreSessionViewResponse { session: session_to_response_with_turn_count(session, total_turn_count), turns, + turn_catalog, context_restore_state: "pending".to_string(), is_partial, loaded_turn_count, @@ -2975,6 +3012,85 @@ pub async fn restore_session_view( result } +#[tauri::command] +pub async fn load_session_turn_window( + runtime: State<'_, DesktopRuntimeContext>, + startup_trace: State<'_, DesktopStartupTrace>, + request: LoadSessionTurnWindowRequest, +) -> Result { + let started_at = Instant::now(); + let result = async { + debug!( + "load_session_turn_window request received: session_id={} target_storage_turn_index={} before={} after={}", + request.session_id, + request.target_storage_turn_index, + request.before.unwrap_or(SESSION_TURN_WINDOW_DEFAULT_BEFORE), + request.after.unwrap_or(SESSION_TURN_WINDOW_DEFAULT_AFTER) + ); + let mut response = runtime + .session_application() + .load_session_turn_window( + desktop_session_scope( + request.workspace_path.clone(), + request.remote_connection_id.clone(), + request.remote_ssh_host.clone(), + ), + SessionTurnWindowRequest { + workspace_path: PathBuf::from(&request.workspace_path), + session_id: request.session_id.clone(), + include_internal: request.include_internal, + target_storage_turn_index: request.target_storage_turn_index, + expected_turn_id: request.expected_turn_id.clone(), + expected_catalog_revision: request.expected_catalog_revision.clone(), + before: request.before.unwrap_or(SESSION_TURN_WINDOW_DEFAULT_BEFORE), + after: request.after.unwrap_or(SESSION_TURN_WINDOW_DEFAULT_AFTER), + }, + ) + .await + .map_err(|error| error.to_string())?; + + if let Some(turns) = response.ready_turns_mut() { + compact_tool_results_for_session_view(turns); + } + match &response { + SessionTurnWindowResponse::Ready { + total_turn_count, + start_ordinal, + end_ordinal_exclusive, + turns, + .. + } => debug!( + "load_session_turn_window completed: session_id={} status=ready target_storage_turn_index={} turn_count={} total_turn_count={} start_ordinal={} end_ordinal_exclusive={} duration_ms={}", + request.session_id, + request.target_storage_turn_index, + turns.len(), + total_turn_count, + start_ordinal, + end_ordinal_exclusive, + started_at.elapsed().as_millis() + ), + SessionTurnWindowResponse::Stale { catalog } => debug!( + "load_session_turn_window completed: session_id={} status=stale target_storage_turn_index={} total_turn_count={} duration_ms={}", + request.session_id, + request.target_storage_turn_index, + catalog.total_turn_count, + started_at.elapsed().as_millis() + ), + SessionTurnWindowResponse::NotFound { catalog } => debug!( + "load_session_turn_window completed: session_id={} status=not-found target_storage_turn_index={} total_turn_count={} duration_ms={}", + request.session_id, + request.target_storage_turn_index, + catalog.total_turn_count, + started_at.elapsed().as_millis() + ), + } + Ok(response) + } + .await; + startup_trace.record_tauri_command_elapsed("load_session_turn_window", None, started_at); + result +} + #[tauri::command] pub async fn restore_session_with_turns( runtime: State<'_, DesktopRuntimeContext>, diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 150ac4b9fa..7025a448d0 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -916,6 +916,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "load_persisted_session_metadata", RemoteWorkspacePolicy::LegacyUnaudited, ), + ( + "load_session_turn_window", + RemoteWorkspacePolicy::RemoteRouted, + ), ("load_session_turns", RemoteWorkspacePolicy::LegacyUnaudited), ( "logout_subscription_account", diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index f8ad8d1063..3b2255b6bf 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1145,6 +1145,7 @@ pub async fn run() { api::agentic_api::delete_session, api::agentic_api::restore_session, api::agentic_api::restore_session_view, + api::agentic_api::load_session_turn_window, api::agentic_api::restore_session_with_turns, api::agentic_api::reset_memory, api::agentic_api::get_memory_paths, diff --git a/src/apps/desktop/src/runtime/session_application.rs b/src/apps/desktop/src/runtime/session_application.rs index 7f71e049b8..eadb0c4e8f 100644 --- a/src/apps/desktop/src/runtime/session_application.rs +++ b/src/apps/desktop/src/runtime/session_application.rs @@ -19,17 +19,19 @@ use bitfun_core::agentic::core::Session; use bitfun_core::agentic::persistence::{SessionBranchResult, SessionMetadataPage}; use bitfun_core::agentic::session::SessionViewRestoreTiming; use bitfun_core::product_runtime::{CoreAgentRuntimeCompatibility, CoreProductAgentRuntime}; -use bitfun_core::service::remote_ssh::workspace_state::get_effective_session_path; +use bitfun_core::service::remote_ssh::workspace_state::{ + get_effective_session_path, LOCAL_WORKSPACE_SSH_HOST, +}; use bitfun_core::service::remote_ssh::SSHConnectionManager; use bitfun_core::service::session::{ DialogTurnData, DialogTurnKind, SessionMetadata, SessionStatus, SessionTranscriptExport, - SessionTranscriptExportOptions, + SessionTranscriptExportOptions, SessionTurnCatalog, SessionTurnWindowResponse, }; use bitfun_core::service::session_usage::SessionUsageReport; use bitfun_core::service::token_usage::TokenUsageService; use bitfun_core::service::workspace::WorkspaceService; use bitfun_core::util::errors::BitFunError; -use bitfun_runtime_ports::AgentContextReloadRequest; +use bitfun_runtime_ports::{AgentContextReloadRequest, SessionTurnWindowRequest}; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; @@ -122,6 +124,7 @@ pub(crate) struct DesktopSessionViewRestore { pub session: Session, pub turns: Vec, pub total_turn_count: usize, + pub turn_catalog: SessionTurnCatalog, pub timings: SessionViewRestoreTiming, } @@ -165,7 +168,10 @@ struct DesktopSessionScopeResolver { impl DesktopSessionScopeResolver { async fn resolve(&self, request: DesktopSessionScopeRequest) -> ResolvedDesktopSessionScope { let remote_connection_id = normalized_optional(request.remote_connection_id.as_deref()); - let requested_remote_ssh_host = normalized_optional(request.remote_ssh_host.as_deref()); + let requested_remote_ssh_host = normalized_remote_ssh_host( + remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ); let registered_remote_ssh_host = if let Some(connection_id) = remote_connection_id.as_deref() { self.workspace_service @@ -223,6 +229,28 @@ fn normalized_optional(value: Option<&str>) -> Option { .map(ToOwned::to_owned) } +fn normalized_remote_ssh_host( + remote_connection_id: Option<&str>, + remote_ssh_host: Option<&str>, +) -> Option { + let host = normalized_optional(remote_ssh_host)?; + if remote_connection_id.is_none() && is_local_workspace_host(&host) { + return None; + } + Some(host) +} + +fn is_local_workspace_host(host: &str) -> bool { + let host = host.to_ascii_lowercase(); + host == LOCAL_WORKSPACE_SSH_HOST + || host.starts_with("localhost:") + || host == "127.0.0.1" + || host.starts_with("127.0.0.1:") + || host == "::1" + || host == "[::1]" + || host.starts_with("[::1]:") +} + fn choose_remote_ssh_host( requested: Option<&str>, registered: Option<&str>, @@ -403,6 +431,20 @@ impl DesktopSessionApplication { .map_err(|error| DesktopSessionApplicationError::Core(error.to_string())) } + pub(crate) async fn load_session_turn_window( + &self, + scope_request: DesktopSessionScopeRequest, + mut request: SessionTurnWindowRequest, + ) -> DesktopSessionApplicationResult { + let scope = self.resolved_scope(scope_request).await; + let storage_path = self.storage_path(&scope); + request.workspace_path = storage_path.clone(); + self.compatibility + .load_session_turn_window_from_storage_path(&storage_path, request) + .await + .map_err(|error| DesktopSessionApplicationError::Core(error.to_string())) + } + pub(crate) async fn load_session_metadata( &self, request: DesktopSessionScopeRequest, @@ -689,7 +731,7 @@ impl DesktopSessionApplication { let resolve_storage_path_duration_ms = path_started_at.elapsed().as_millis().min(u64::MAX as u128) as u64; on_storage_path_resolved(resolve_storage_path_duration_ms); - let (mut session, turns, total_turn_count, mut timings) = self + let (mut session, turns, total_turn_count, turn_catalog, mut timings) = self .compatibility .restore_session_view_from_storage_path( &storage_path, @@ -709,6 +751,7 @@ impl DesktopSessionApplication { session, turns, total_turn_count, + turn_catalog, timings, }) } @@ -1018,6 +1061,29 @@ mod tests { assert_eq!(normalized_optional(None), None); } + #[test] + fn local_host_sentinels_require_a_remote_connection_id() { + for host in [ + "localhost", + "LOCALHOST:22", + "127.0.0.1", + "127.0.0.1:22", + "::1", + "[::1]:22", + ] { + assert_eq!(normalized_remote_ssh_host(None, Some(host)), None); + } + + assert_eq!( + normalized_remote_ssh_host(Some("connection-1"), Some(" localhost ")), + Some("localhost".to_string()) + ); + assert_eq!( + normalized_remote_ssh_host(None, Some(" legacy.example ")), + Some("legacy.example".to_string()) + ); + } + #[test] fn remote_host_resolution_preserves_request_registry_and_offline_saved_precedence() { assert_eq!( diff --git a/src/crates/assembly/core/src/agentic/persistence/manager.rs b/src/crates/assembly/core/src/agentic/persistence/manager.rs index a8277c5fe3..8fc2bbab2a 100644 --- a/src/crates/assembly/core/src/agentic/persistence/manager.rs +++ b/src/crates/assembly/core/src/agentic/persistence/manager.rs @@ -11,7 +11,8 @@ use crate::agentic::memories::db::{MemoryDatabase, MEMORY_PHASE2_GLOBAL_JOB_KEY} use crate::agentic::memories::external_context::dialog_turn_uses_external_context; use crate::agentic::session::revert::{SessionRevertState, SESSION_REVERT_SCHEMA_VERSION}; use crate::agentic::session::transcript_render::{ - render_transcript, rendered_turn_char_count, transcript_fingerprint, + render_transcript, rendered_turn_char_count, transcript_display_user_content, + transcript_fingerprint, }; use crate::agentic::session::{ CoreSessionStorePort, SessionPromptCache, TokenAnchor, PROMPT_CACHE_SCHEMA_VERSION, @@ -25,12 +26,15 @@ use crate::service::remote_ssh::workspace_state::{ }; use crate::service::session::{ DialogTurnData, SessionMetadata, SessionTranscriptExport, SessionTranscriptExportOptions, - TranscriptLineRange, SESSION_STORAGE_SCHEMA_VERSION, + SessionTurnCatalog, SessionTurnCatalogEntry, SessionTurnWindowResponse, TranscriptLineRange, + SESSION_STORAGE_SCHEMA_VERSION, SESSION_TURN_CATALOG_SCHEMA_VERSION, }; use crate::service::workspace_runtime::WorkspaceRuntimeService; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::timing::elapsed_ms_u64; -use bitfun_runtime_ports::{SessionTurnLoadRequest, SessionTurnLoadTiming}; +use bitfun_runtime_ports::{ + SessionTurnLoadRequest, SessionTurnLoadTiming, SessionTurnWindowRequest, +}; use bitfun_services_core::{ json_store::{JsonFileStore, JsonFileStoreError}, session::{ @@ -43,8 +47,9 @@ use bitfun_services_core::{ use futures::{stream, StreamExt}; use log::{debug, info, warn}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::borrow::Cow; -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::io::ErrorKind; use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock, Weak}; @@ -60,6 +65,9 @@ const COMPRESSION_TRANSCRIPT_SCHEMA_VERSION: u32 = 1; const COMPRESSION_TRANSCRIPT_CREATE_ATTEMPTS: usize = 32; const TOKEN_ANCHOR_SCHEMA_VERSION: u32 = 1; const SESSION_TURN_READ_CONCURRENCY: usize = 4; +const SESSION_TURN_CATALOG_PREVIEW_CHAR_LIMIT: usize = 320; +const SESSION_TURN_WINDOW_MAX_BEFORE: usize = 4; +const SESSION_TURN_WINDOW_MAX_TARGET_AND_AFTER: usize = 12; pub const SESSION_REFERENCE_TRANSCRIPT_CHAR_LIMIT: usize = 60_000; static SESSION_PERSISTENCE_LOCKS: OnceLock>>>> = @@ -153,6 +161,133 @@ struct ReadTurnPathsResult { max_turn_read_duration_ms: u64, } +struct BuiltSessionTurnCatalogProjection { + visible: SessionTurnCatalog, + physical: SessionTurnCatalog, + physical_changed: bool, +} + +fn truncate_turn_catalog_preview(content: &str) -> (String, bool) { + let mut chars = content.trim().chars(); + let preview = chars + .by_ref() + .take(SESSION_TURN_CATALOG_PREVIEW_CHAR_LIMIT) + .collect::(); + (preview, chars.next().is_some()) +} + +fn turn_catalog_entry(turn: &DialogTurnData, ordinal: usize) -> SessionTurnCatalogEntry { + let (preview, preview_truncated) = + truncate_turn_catalog_preview(&transcript_display_user_content(turn)); + SessionTurnCatalogEntry { + ordinal, + storage_turn_index: turn.turn_index, + turn_id: Some(turn.turn_id.clone()), + preview: Some(preview), + preview_truncated, + } +} + +fn placeholder_turn_catalog_entry( + storage_turn_index: usize, + ordinal: usize, +) -> SessionTurnCatalogEntry { + SessionTurnCatalogEntry { + ordinal, + storage_turn_index, + turn_id: None, + preview: None, + preview_truncated: false, + } +} + +fn complete_turn_catalog_indices( + indices: impl IntoIterator, + minimum_count: usize, +) -> Vec { + let mut indices = indices.into_iter().collect::>(); + let mut candidate = 0usize; + while indices.len() < minimum_count { + indices.insert(candidate); + candidate = candidate.saturating_add(1); + } + indices.into_iter().collect() +} + +fn turn_catalog_revision(entries: &[SessionTurnCatalogEntry]) -> String { + let mut hasher = Sha256::new(); + hasher.update((entries.len() as u64).to_le_bytes()); + for entry in entries { + hasher.update((entry.ordinal as u64).to_le_bytes()); + hasher.update((entry.storage_turn_index as u64).to_le_bytes()); + } + let digest = hasher.finalize(); + format!("v2-{}", hex::encode(&digest[..8])) +} + +fn build_turn_catalog( + session_id: &str, + mut entries: Vec, +) -> SessionTurnCatalog { + entries.sort_by_key(|entry| entry.storage_turn_index); + for (ordinal, entry) in entries.iter_mut().enumerate() { + entry.ordinal = ordinal; + } + let complete = entries + .iter() + .all(|entry| entry.turn_id.is_some() && entry.preview.is_some()); + SessionTurnCatalog { + schema_version: SESSION_TURN_CATALOG_SCHEMA_VERSION, + session_id: session_id.to_string(), + revision: turn_catalog_revision(&entries), + total_turn_count: entries.len(), + complete, + entries, + } +} + +fn is_well_formed_turn_catalog(catalog: &SessionTurnCatalog) -> bool { + let entries_are_ordered = catalog.entries.iter().enumerate().all(|(ordinal, entry)| { + entry.ordinal == ordinal + && (ordinal == 0 + || catalog.entries[ordinal - 1].storage_turn_index < entry.storage_turn_index) + }); + let entries_are_complete = catalog + .entries + .iter() + .all(|entry| entry.turn_id.is_some() && entry.preview.is_some()); + + catalog.total_turn_count == catalog.entries.len() + && catalog.complete == entries_are_complete + && entries_are_ordered + && catalog.revision == turn_catalog_revision(&catalog.entries) +} + +fn can_incrementally_update_turn_catalog_after_save( + catalog: &SessionTurnCatalog, + physical_indices: &[usize], + saved_turn_index: usize, +) -> bool { + let catalog_len = catalog.entries.len(); + let aligned_prefix = catalog + .entries + .iter() + .zip(physical_indices.iter()) + .all(|(entry, index)| entry.storage_turn_index == *index); + if !aligned_prefix { + return false; + } + + match physical_indices.len().checked_sub(catalog_len) { + Some(0) => catalog + .entries + .iter() + .any(|entry| entry.storage_turn_index == saved_turn_index), + Some(1) => physical_indices.get(catalog_len) == Some(&saved_turn_index), + _ => false, + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct StoredSessionPromptCacheFile { schema_version: u32, @@ -466,6 +601,11 @@ impl PersistenceManager { .prompt_cache_path(session_id) } + fn turn_catalog_path(&self, workspace_path: &Path, session_id: &str) -> PathBuf { + self.session_layout(workspace_path) + .turn_catalog_path(session_id) + } + fn token_anchors_path(&self, workspace_path: &Path, session_id: &str) -> PathBuf { self.session_layout(workspace_path) .session_dir(session_id) @@ -2386,6 +2526,475 @@ impl PersistenceManager { Ok(summaries) } + async fn read_session_turn_catalog_cache( + &self, + workspace_path: &Path, + session_id: &str, + ) -> Option { + match self + .read_json_optional::( + &self.turn_catalog_path(workspace_path, session_id), + ) + .await + { + Ok(Some(catalog)) + if catalog.schema_version == SESSION_TURN_CATALOG_SCHEMA_VERSION + && catalog.session_id == session_id + && is_well_formed_turn_catalog(&catalog) => + { + Some(catalog) + } + Ok(Some(catalog)) => { + warn!( + "Ignoring incompatible Session Turn catalog: session_id={} schema_version={} catalog_session_id={}", + session_id, catalog.schema_version, catalog.session_id + ); + None + } + Ok(None) => None, + Err(error) => { + warn!( + "Ignoring unreadable Session Turn catalog: session_id={} error={}", + session_id, error + ); + None + } + } + } + + /// Build the lightweight navigation catalog for a Session view. + /// + /// Missing or stale legacy entries are returned as index-only placeholders. + /// Metadata for the already-loaded Turns is merged into the derived sidecar + /// so reopening the Session does not discard completed migration work. + pub async fn load_session_turn_catalog( + &self, + workspace_path: &Path, + session_id: &str, + loaded_turns: &[DialogTurnData], + visible_total_turn_count: usize, + ) -> BitFunResult { + Self::validate_session_id(session_id)?; + let _session_write = self.lock_session_write_operation(workspace_path, session_id)?; + + let (physical_indices, can_persist_physical_projection) = match self + .list_indexed_turn_paths(workspace_path, session_id) + .await + { + Ok(paths) => { + let persisted_indices = paths + .into_iter() + .map(|(index, _)| index) + .collect::>() + .into_iter() + .collect::>(); + let projected_indices = complete_turn_catalog_indices( + persisted_indices + .iter() + .copied() + .chain(loaded_turns.iter().map(|turn| turn.turn_index)), + visible_total_turn_count, + ); + let can_persist = projected_indices == persisted_indices; + (projected_indices, can_persist) + } + Err(error) => { + warn!( + "Failed to list Turn files while building Session Turn catalog; using bounded placeholders: session_id={} visible_turn_count={} error={}", + session_id, visible_total_turn_count, error + ); + ( + complete_turn_catalog_indices( + loaded_turns.iter().map(|turn| turn.turn_index), + visible_total_turn_count, + ), + false, + ) + } + }; + + let projection = self + .build_session_turn_catalog_projection_with_physical( + workspace_path, + session_id, + physical_indices, + loaded_turns, + visible_total_turn_count, + ) + .await?; + if can_persist_physical_projection && projection.physical_changed { + if let Err(error) = self + .write_json_atomic( + &self.turn_catalog_path(workspace_path, session_id), + &projection.physical, + ) + .await + { + warn!( + "Failed to persist incrementally repaired Session Turn catalog: session_id={} error={}", + session_id, error + ); + } + } + Ok(projection.visible) + } + + async fn build_session_turn_catalog_projection( + &self, + workspace_path: &Path, + session_id: &str, + physical_indices: Vec, + loaded_turns: &[DialogTurnData], + visible_total_turn_count: usize, + ) -> BitFunResult { + Ok(self + .build_session_turn_catalog_projection_with_physical( + workspace_path, + session_id, + physical_indices, + loaded_turns, + visible_total_turn_count, + ) + .await? + .visible) + } + + async fn build_session_turn_catalog_projection_with_physical( + &self, + workspace_path: &Path, + session_id: &str, + physical_indices: Vec, + loaded_turns: &[DialogTurnData], + visible_total_turn_count: usize, + ) -> BitFunResult { + let cached = self + .read_session_turn_catalog_cache(workspace_path, session_id) + .await; + let mut cached_by_index = cached + .as_ref() + .map(|catalog| { + catalog + .entries + .iter() + .cloned() + .map(|entry| (entry.storage_turn_index, entry)) + .collect::>() + }) + .unwrap_or_default(); + let loaded_by_index = loaded_turns + .iter() + .map(|turn| (turn.turn_index, turn)) + .collect::>(); + + let physical_entries = physical_indices + .into_iter() + .enumerate() + .map(|(ordinal, storage_turn_index)| { + if let Some(turn) = loaded_by_index.get(&storage_turn_index) { + turn_catalog_entry(turn, ordinal) + } else if let Some(mut entry) = cached_by_index.remove(&storage_turn_index) { + entry.ordinal = ordinal; + entry + } else { + placeholder_turn_catalog_entry(storage_turn_index, ordinal) + } + }) + .collect::>(); + let physical_catalog = build_turn_catalog(session_id, physical_entries); + let visible_entries = physical_catalog + .entries + .iter() + .take(visible_total_turn_count) + .cloned() + .collect::>(); + let physical_changed = cached.as_ref() != Some(&physical_catalog); + Ok(BuiltSessionTurnCatalogProjection { + visible: build_turn_catalog(session_id, visible_entries), + physical: physical_catalog, + physical_changed, + }) + } + + /// Load a bounded, contiguous Turn window without materializing the full + /// Session transcript. + /// + /// The operation holds the persisted writer lease while it snapshots the + /// staged-revert boundary, catalog, and selected Turn files. A raced or + /// missing file therefore never produces a sparse ready range. + pub async fn load_session_turn_window( + &self, + request: &SessionTurnWindowRequest, + ) -> BitFunResult { + Self::validate_session_id(&request.session_id)?; + let _session_write = + self.lock_session_write_operation(&request.workspace_path, &request.session_id)?; + let boundary_turn = self + .load_session_revert_state(&request.workspace_path, &request.session_id) + .await? + .map(|state| state.boundary_turn); + let physical_indexed_paths = self + .list_indexed_turn_paths(&request.workspace_path, &request.session_id) + .await?; + let physical_indices = physical_indexed_paths + .iter() + .map(|(index, _)| *index) + .collect::>(); + let indexed_paths = physical_indexed_paths + .into_iter() + .filter(|(index, _)| boundary_turn.is_none_or(|boundary| *index < boundary)) + .collect::>(); + let visible_indices = indexed_paths + .iter() + .map(|(index, _)| *index) + .collect::>(); + let catalog = self + .build_session_turn_catalog_projection( + &request.workspace_path, + &request.session_id, + visible_indices.clone(), + &[], + visible_indices.len(), + ) + .await?; + + if request + .expected_catalog_revision + .as_deref() + .is_some_and(|revision| revision != catalog.revision) + { + return Ok(SessionTurnWindowResponse::Stale { catalog }); + } + + let Some(target_ordinal) = catalog + .entries + .iter() + .position(|entry| entry.storage_turn_index == request.target_storage_turn_index) + else { + return Ok(SessionTurnWindowResponse::NotFound { catalog }); + }; + if let (Some(expected_turn_id), Some(catalog_turn_id)) = ( + request.expected_turn_id.as_deref(), + catalog.entries[target_ordinal].turn_id.as_deref(), + ) { + if expected_turn_id != catalog_turn_id { + return Ok(SessionTurnWindowResponse::Stale { catalog }); + } + } + + let before = request.before.min(SESSION_TURN_WINDOW_MAX_BEFORE); + let target_and_after = request + .after + .clamp(1, SESSION_TURN_WINDOW_MAX_TARGET_AND_AFTER); + let start_ordinal = target_ordinal.saturating_sub(before); + let end_ordinal_exclusive = indexed_paths + .len() + .min(target_ordinal.saturating_add(target_and_after)); + let selected_paths = indexed_paths[start_ordinal..end_ordinal_exclusive].to_vec(); + let selected_indices = selected_paths + .iter() + .map(|(index, _)| *index) + .collect::>(); + let read_result = self.read_turn_paths(selected_paths).await?; + + if read_result.missing_turn_file_count > 0 + || read_result.turns.len() != selected_indices.len() + { + let refreshed_indices = self + .list_indexed_turn_paths(&request.workspace_path, &request.session_id) + .await? + .into_iter() + .filter(|(index, _)| boundary_turn.is_none_or(|boundary| *index < boundary)) + .map(|(index, _)| index) + .collect::>(); + let catalog = self + .build_session_turn_catalog_projection( + &request.workspace_path, + &request.session_id, + refreshed_indices.clone(), + &[], + refreshed_indices.len(), + ) + .await?; + return Ok(SessionTurnWindowResponse::NotFound { catalog }); + } + + for (turn, expected_index) in read_result.turns.iter().zip(selected_indices.iter()) { + if turn.session_id != request.session_id || turn.turn_index != *expected_index { + return Err(BitFunError::Validation(format!( + "Persisted Turn identity does not match its storage path: session_id={} expected_turn_index={} actual_session_id={} actual_turn_index={}", + request.session_id, expected_index, turn.session_id, turn.turn_index + ))); + } + } + + let repaired_projection = self + .build_session_turn_catalog_projection_with_physical( + &request.workspace_path, + &request.session_id, + physical_indices.clone(), + &read_result.turns, + physical_indices.len(), + ) + .await?; + if repaired_projection.physical_changed { + if let Err(error) = self + .write_json_atomic( + &self.turn_catalog_path(&request.workspace_path, &request.session_id), + &repaired_projection.physical, + ) + .await + { + warn!( + "Failed to persist Session Turn catalog metadata loaded by a window request: session_id={} start_ordinal={} end_ordinal_exclusive={} error={}", + request.session_id, start_ordinal, end_ordinal_exclusive, error + ); + } + } + + let target_offset = target_ordinal - start_ordinal; + let target_turn = &read_result.turns[target_offset]; + if request + .expected_turn_id + .as_deref() + .is_some_and(|expected_turn_id| expected_turn_id != target_turn.turn_id) + { + let catalog = self + .build_session_turn_catalog_projection( + &request.workspace_path, + &request.session_id, + visible_indices, + &read_result.turns, + indexed_paths.len(), + ) + .await?; + return Ok(SessionTurnWindowResponse::Stale { catalog }); + } + + Ok(SessionTurnWindowResponse::Ready { + catalog_revision: catalog.revision, + total_turn_count: catalog.total_turn_count, + start_ordinal, + end_ordinal_exclusive, + target_turn_id: target_turn.turn_id.clone(), + turns: read_result.turns, + }) + } + + async fn persist_session_turn_catalog_after_save( + &self, + workspace_path: &Path, + turn: &DialogTurnData, + ) -> BitFunResult<()> { + let cached = self + .read_session_turn_catalog_cache(workspace_path, &turn.session_id) + .await; + if let Some(catalog) = cached.as_ref().filter(|catalog| catalog.complete) { + if catalog + .entries + .iter() + .find(|entry| entry.storage_turn_index == turn.turn_index) + .is_some_and(|entry| turn_catalog_entry(turn, entry.ordinal) == *entry) + { + return Ok(()); + } + } + + let next_entry = turn_catalog_entry(turn, 0); + let indexed_paths = self + .list_indexed_turn_paths(workspace_path, &turn.session_id) + .await?; + let physical_indices = indexed_paths + .iter() + .map(|(index, _)| *index) + .collect::>(); + // Completeness is independent from structural alignment. A legacy + // catalog with placeholders can safely repair only the saved entry as + // long as its indices still match the physical Turn sequence. + let can_update_incrementally = cached.as_ref().is_some_and(|catalog| { + can_incrementally_update_turn_catalog_after_save( + catalog, + &physical_indices, + turn.turn_index, + ) + }); + + let next_catalog = if can_update_incrementally { + let mut entries = cached + .as_ref() + .map(|catalog| catalog.entries.clone()) + .unwrap_or_default(); + if let Some(entry) = entries + .iter_mut() + .find(|entry| entry.storage_turn_index == turn.turn_index) + { + *entry = next_entry; + } else { + entries.push(next_entry); + } + build_turn_catalog(&turn.session_id, entries) + } else { + let read_result = self.read_turn_paths(indexed_paths).await?; + let loaded_by_index = read_result + .turns + .iter() + .map(|loaded_turn| (loaded_turn.turn_index, loaded_turn)) + .collect::>(); + let entries = physical_indices + .into_iter() + .enumerate() + .map(|(ordinal, storage_turn_index)| { + loaded_by_index + .get(&storage_turn_index) + .map(|loaded_turn| turn_catalog_entry(loaded_turn, ordinal)) + .unwrap_or_else(|| { + placeholder_turn_catalog_entry(storage_turn_index, ordinal) + }) + }) + .collect::>(); + build_turn_catalog(&turn.session_id, entries) + }; + + if cached.as_ref() == Some(&next_catalog) { + return Ok(()); + } + + self.write_json_atomic( + &self.turn_catalog_path(workspace_path, &turn.session_id), + &next_catalog, + ) + .await + } + + async fn persist_complete_session_turn_catalog( + &self, + workspace_path: &Path, + session_id: &str, + turns: &[DialogTurnData], + ) -> BitFunResult<()> { + let next_catalog = build_turn_catalog( + session_id, + turns + .iter() + .enumerate() + .map(|(ordinal, turn)| turn_catalog_entry(turn, ordinal)) + .collect(), + ); + if self + .read_session_turn_catalog_cache(workspace_path, session_id) + .await + .as_ref() + == Some(&next_catalog) + { + return Ok(()); + } + + self.write_json_atomic( + &self.turn_catalog_path(workspace_path, session_id), + &next_catalog, + ) + .await + } + pub async fn save_dialog_turn( &self, workspace_path: &Path, @@ -2459,6 +3068,16 @@ impl PersistenceManager { .await?; let write_duration = write_started_at.elapsed(); + if let Err(error) = self + .persist_session_turn_catalog_after_save(workspace_path, turn) + .await + { + warn!( + "Failed to refresh derived Session Turn catalog after Turn save: session_id={} turn_index={} error={}", + turn.session_id, turn.turn_index, error + ); + } + let last_active_at = turn .end_time .unwrap_or_else(|| Self::system_time_to_unix_ms(SystemTime::now())); @@ -2785,6 +3404,17 @@ impl PersistenceManager { .await; let _persistence_guard = persistence_lock.lock().await; if !self.turns_dir(workspace_path, session_id).exists() { + if self.turn_catalog_path(workspace_path, session_id).exists() { + if let Err(error) = self + .persist_complete_session_turn_catalog(workspace_path, session_id, &[]) + .await + { + warn!( + "Failed to clear derived Session Turn catalog without a Turn directory: session_id={} error={}", + session_id, error + ); + } + } return Ok(()); } @@ -2793,12 +3423,12 @@ impl PersistenceManager { .await .map_err(|e| BitFunError::io(format!("Failed to delete dialog turn files: {}", e)))?; + let turns = self.load_session_turns(workspace_path, session_id).await?; if self .load_session_metadata(workspace_path, session_id) .await? .is_some() { - let turns = self.load_session_turns(workspace_path, session_id).await?; let workspace_path_text = workspace_path.to_string_lossy(); self.update_session_metadata_if_present_locked( workspace_path, @@ -2816,6 +3446,16 @@ impl PersistenceManager { .await?; } + if let Err(error) = self + .persist_complete_session_turn_catalog(workspace_path, session_id, &turns) + .await + { + warn!( + "Failed to refresh derived Session Turn catalog after Turn deletion: session_id={} start_turn_index={} error={}", + session_id, turn_index, error + ); + } + Ok(()) } @@ -3328,12 +3968,12 @@ impl PersistenceManager { } } + let remaining_turns = self.load_session_turns(workspace_path, session_id).await?; if self .load_session_metadata(workspace_path, session_id) .await? .is_some() { - let remaining_turns = self.load_session_turns(workspace_path, session_id).await?; let workspace_path_text = workspace_path.to_string_lossy(); self.update_session_metadata_if_present_locked( workspace_path, @@ -3351,13 +3991,25 @@ impl PersistenceManager { .await?; } - Ok(deleted) - } - - pub async fn delete_turns_from( - &self, - workspace_path: &Path, - session_id: &str, + if let Err(error) = self + .persist_complete_session_turn_catalog(workspace_path, session_id, &remaining_turns) + .await + { + warn!( + "Failed to refresh derived Session Turn catalog after Turn deletion: session_id={} start_turn_index={} error={}", + session_id, + turn_index.saturating_add(1), + error + ); + } + + Ok(deleted) + } + + pub async fn delete_turns_from( + &self, + workspace_path: &Path, + session_id: &str, turn_index: usize, ) -> BitFunResult { Self::validate_session_id(session_id)?; @@ -3382,12 +4034,12 @@ impl PersistenceManager { } } + let remaining_turns = self.load_session_turns(workspace_path, session_id).await?; if self .load_session_metadata(workspace_path, session_id) .await? .is_some() { - let remaining_turns = self.load_session_turns(workspace_path, session_id).await?; let workspace_path_text = workspace_path.to_string_lossy(); self.update_session_metadata_if_present_locked( workspace_path, @@ -3405,6 +4057,16 @@ impl PersistenceManager { .await?; } + if let Err(error) = self + .persist_complete_session_turn_catalog(workspace_path, session_id, &remaining_turns) + .await + { + warn!( + "Failed to refresh derived Session Turn catalog after Turn deletion: session_id={} start_turn_index={} error={}", + session_id, turn_index, error + ); + } + Ok(deleted) } @@ -3421,8 +4083,10 @@ impl PersistenceManager { #[cfg(test)] mod tests { use super::{ - context_snapshot_payload_stats, current_unix_secs, PendingSessionDirectory, - PersistenceManager, StoredDialogTurnFile, SESSION_REFERENCE_TRANSCRIPT_CHAR_LIMIT, + build_turn_catalog, context_snapshot_payload_stats, current_unix_secs, + is_well_formed_turn_catalog, placeholder_turn_catalog_entry, truncate_turn_catalog_preview, + turn_catalog_entry, PendingSessionDirectory, PersistenceManager, StoredDialogTurnFile, + SESSION_REFERENCE_TRANSCRIPT_CHAR_LIMIT, SESSION_TURN_CATALOG_PREVIEW_CHAR_LIMIT, }; use crate::agentic::core::{Message, Session, SessionConfig, SessionKind, ToolResult}; use crate::agentic::memories::db::{MemoryDatabase, MemoryRow, MEMORY_PHASE2_GLOBAL_JOB_KEY}; @@ -3436,10 +4100,11 @@ mod tests { use crate::infrastructure::PathManager; use crate::service::session::{ DialogTurnData, ModelRoundData, SessionMemoryMode, SessionMetadata, SessionRelationship, - SessionRelationshipKind, SessionTranscriptExportOptions, StoredSessionIndexFile, - TextItemData, UserMessageData, + SessionRelationshipKind, SessionTranscriptExportOptions, SessionTurnCatalog, + SessionTurnWindowResponse, StoredSessionIndexFile, TextItemData, UserMessageData, }; use crate::BitFunError; + use bitfun_runtime_ports::SessionTurnWindowRequest; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Instant; @@ -4275,6 +4940,720 @@ mod tests { } } + #[test] + fn turn_catalog_preview_truncates_on_unicode_scalar_boundaries() { + let content = "界".repeat(SESSION_TURN_CATALOG_PREVIEW_CHAR_LIMIT + 1); + let (preview, truncated) = truncate_turn_catalog_preview(&content); + + assert_eq!( + preview.chars().count(), + SESSION_TURN_CATALOG_PREVIEW_CHAR_LIMIT + ); + assert!(truncated); + assert!(preview.is_char_boundary(preview.len())); + + let exact = "🙂".repeat(SESSION_TURN_CATALOG_PREVIEW_CHAR_LIMIT); + let (preview, truncated) = truncate_turn_catalog_preview(&exact); + assert_eq!(preview, exact); + assert!(!truncated); + } + + #[tokio::test] + async fn turn_catalog_sidecar_is_complete_idempotent_and_truncates_with_turns() { + let workspace = TestWorkspace::new(); + let manager = + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"); + let session_id = Uuid::new_v4().to_string(); + let session = Session::new_with_id( + session_id.clone(), + "Catalog persistence".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + + let turn_0 = DialogTurnData::new( + "turn-0".to_string(), + 0, + session_id.clone(), + user_message("first prompt"), + ); + let turn_1 = DialogTurnData::new( + "turn-1".to_string(), + 1, + session_id.clone(), + user_message("second prompt"), + ); + manager + .save_dialog_turn(workspace.path(), &turn_0) + .await + .expect("first turn should save"); + manager + .save_dialog_turn(workspace.path(), &turn_1) + .await + .expect("second turn should save"); + + let catalog_path = manager.turn_catalog_path(workspace.path(), &session_id); + let catalog: SessionTurnCatalog = serde_json::from_str( + &std::fs::read_to_string(&catalog_path).expect("catalog should be readable"), + ) + .expect("catalog should deserialize"); + assert!(catalog.complete); + assert_eq!(catalog.total_turn_count, 2); + assert_eq!(catalog.entries[0].turn_id.as_deref(), Some("turn-0")); + assert_eq!(catalog.entries[1].preview.as_deref(), Some("second prompt")); + + let serialized_with_trailing_whitespace = format!( + "{}\n ", + std::fs::read_to_string(&catalog_path).expect("catalog should be readable") + ); + std::fs::write(&catalog_path, &serialized_with_trailing_whitespace) + .expect("catalog whitespace fixture should write"); + manager + .save_dialog_turn(workspace.path(), &turn_1) + .await + .expect("repeated save should succeed"); + assert_eq!( + std::fs::read_to_string(&catalog_path).expect("catalog should remain readable"), + serialized_with_trailing_whitespace, + "unchanged user input must not rewrite the catalog during streaming checkpoints" + ); + + manager + .delete_dialog_turns_from(workspace.path(), &session_id, 1) + .await + .expect("turn suffix should delete"); + let catalog: SessionTurnCatalog = serde_json::from_str( + &std::fs::read_to_string(&catalog_path).expect("truncated catalog should be readable"), + ) + .expect("truncated catalog should deserialize"); + assert!(catalog.complete); + assert_eq!(catalog.total_turn_count, 1); + assert_eq!(catalog.entries[0].turn_id.as_deref(), Some("turn-0")); + } + + #[tokio::test] + async fn turn_catalog_restore_projects_placeholders_and_repairs_from_loaded_turns() { + let workspace = TestWorkspace::new(); + let manager = + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"); + let session_id = Uuid::new_v4().to_string(); + let session = Session::new_with_id( + session_id.clone(), + "Catalog restore".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + for index in 0..5 { + manager + .save_dialog_turn( + workspace.path(), + &DialogTurnData::new( + format!("turn-{index}"), + index, + session_id.clone(), + user_message(&format!("prompt {index}")), + ), + ) + .await + .expect("turn should save"); + } + + let catalog_path = manager.turn_catalog_path(workspace.path(), &session_id); + std::fs::remove_file(&catalog_path).expect("legacy fixture should omit catalog"); + let tail = manager + .load_session_tail_turns(workspace.path(), &session_id, 2) + .await + .expect("tail turns should load"); + let catalog = manager + .load_session_turn_catalog(workspace.path(), &session_id, &tail, 5) + .await + .expect("partial catalog should load"); + assert!(!catalog.complete); + assert_eq!(catalog.total_turn_count, 5); + assert!(catalog.entries[..3] + .iter() + .all(|entry| entry.turn_id.is_none() && entry.preview.is_none())); + assert_eq!(catalog.entries[3].turn_id.as_deref(), Some("turn-3")); + assert_eq!(catalog.entries[4].preview.as_deref(), Some("prompt 4")); + let persisted_tail: SessionTurnCatalog = serde_json::from_str( + &std::fs::read_to_string(&catalog_path) + .expect("tail repair should create the catalog sidecar"), + ) + .expect("tail-repaired catalog should deserialize"); + assert_eq!(persisted_tail, catalog); + + std::fs::write(&catalog_path, "{ not valid json") + .expect("corrupt catalog fixture should write"); + let fallback = manager + .load_session_turn_catalog(workspace.path(), &session_id, &tail, 5) + .await + .expect("corrupt catalog should fall back safely"); + assert_eq!(fallback, catalog); + let repaired_fallback: SessionTurnCatalog = serde_json::from_str( + &std::fs::read_to_string(&catalog_path) + .expect("corrupt catalog should be repaired during restore"), + ) + .expect("repaired catalog should deserialize"); + assert_eq!(repaired_fallback, catalog); + + let all_turns = manager + .load_session_turns(workspace.path(), &session_id) + .await + .expect("full history should load"); + let complete = manager + .load_session_turn_catalog(workspace.path(), &session_id, &all_turns, 5) + .await + .expect("loaded turns should repair catalog projection"); + assert!(complete.complete); + assert_eq!(complete.entries.len(), 5); + assert_eq!(complete.entries[0].turn_id.as_deref(), Some("turn-0")); + let persisted_complete: SessionTurnCatalog = serde_json::from_str( + &std::fs::read_to_string(&catalog_path) + .expect("full restore should persist the complete catalog"), + ) + .expect("complete catalog should deserialize"); + assert_eq!(persisted_complete, complete); + } + + #[tokio::test] + async fn legacy_turn_catalog_window_avoids_synthetic_stale_and_persists_loaded_metadata() { + let workspace = TestWorkspace::new(); + let manager = + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"); + let session_id = Uuid::new_v4().to_string(); + let session = Session::new_with_id( + session_id.clone(), + "Legacy catalog window".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + for index in 0..6 { + manager + .save_dialog_turn( + workspace.path(), + &DialogTurnData::new( + format!("turn-{index}"), + index, + session_id.clone(), + user_message(&format!("prompt {index}")), + ), + ) + .await + .expect("turn should save"); + } + + let catalog_path = manager.turn_catalog_path(workspace.path(), &session_id); + std::fs::remove_file(&catalog_path).expect("legacy fixture should omit catalog"); + let tail = manager + .load_session_tail_turns(workspace.path(), &session_id, 2) + .await + .expect("tail turns should load"); + let initial_catalog = manager + .load_session_turn_catalog(workspace.path(), &session_id, &tail, 6) + .await + .expect("legacy catalog should project"); + assert!(initial_catalog.entries[1].turn_id.is_none()); + assert_eq!( + initial_catalog.entries[4].turn_id.as_deref(), + Some("turn-4") + ); + + let response = manager + .load_session_turn_window(&SessionTurnWindowRequest { + workspace_path: workspace.path().to_path_buf(), + session_id: session_id.clone(), + include_internal: false, + target_storage_turn_index: 1, + expected_turn_id: None, + expected_catalog_revision: Some(initial_catalog.revision.clone()), + before: 0, + after: 1, + }) + .await + .expect("legacy window should load without a retry"); + assert!(matches!( + response, + SessionTurnWindowResponse::Ready { + target_turn_id, + .. + } if target_turn_id == "turn-1" + )); + + let repaired: SessionTurnCatalog = serde_json::from_str( + &std::fs::read_to_string(&catalog_path) + .expect("window metadata should persist to the catalog sidecar"), + ) + .expect("window-repaired catalog should deserialize"); + assert_eq!(repaired.revision, initial_catalog.revision); + assert_eq!(repaired.entries[1].turn_id.as_deref(), Some("turn-1")); + assert_eq!(repaired.entries[1].preview.as_deref(), Some("prompt 1")); + assert_eq!(repaired.entries[4].turn_id.as_deref(), Some("turn-4")); + assert!(!repaired.complete); + + let reopened = manager + .load_session_turn_catalog(workspace.path(), &session_id, &tail, 6) + .await + .expect("reopened catalog should retain repaired metadata"); + assert_eq!(reopened.entries[1].turn_id.as_deref(), Some("turn-1")); + assert_eq!(reopened.entries[1].preview.as_deref(), Some("prompt 1")); + } + + #[tokio::test] + async fn turn_catalog_restore_does_not_persist_padded_missing_file_indices() { + let workspace = TestWorkspace::new(); + let manager = + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"); + let session_id = Uuid::new_v4().to_string(); + let session = Session::new_with_id( + session_id.clone(), + "Catalog missing file".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + let turns = (0..3) + .map(|index| { + DialogTurnData::new( + format!("turn-{index}"), + index, + session_id.clone(), + user_message(&format!("prompt {index}")), + ) + }) + .collect::>(); + for turn in &turns { + manager + .save_dialog_turn(workspace.path(), turn) + .await + .expect("turn should save"); + } + + let catalog_path = manager.turn_catalog_path(workspace.path(), &session_id); + std::fs::remove_file(&catalog_path).expect("fixture should omit catalog"); + std::fs::remove_file(manager.turn_path(workspace.path(), &session_id, 1)) + .expect("fixture should omit one Turn file"); + + let projected = manager + .load_session_turn_catalog( + workspace.path(), + &session_id, + std::slice::from_ref(&turns[2]), + 3, + ) + .await + .expect("missing file catalog should still project safely"); + + assert_eq!(projected.total_turn_count, 3); + assert!(!catalog_path.exists()); + } + + #[tokio::test] + async fn incomplete_turn_catalog_repairs_saved_entries_without_rebuilding_placeholders() { + let workspace = TestWorkspace::new(); + let manager = + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"); + let session_id = Uuid::new_v4().to_string(); + let session = Session::new_with_id( + session_id.clone(), + "Incremental catalog repair".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + + let turns = (0..5) + .map(|index| { + DialogTurnData::new( + format!("turn-{index}"), + index, + session_id.clone(), + user_message(&format!("prompt {index}")), + ) + }) + .collect::>(); + for turn in &turns { + manager + .save_dialog_turn(workspace.path(), turn) + .await + .expect("turn should save"); + } + + let incomplete = build_turn_catalog( + &session_id, + turns + .iter() + .enumerate() + .map(|(ordinal, turn)| { + if ordinal < 3 { + placeholder_turn_catalog_entry(turn.turn_index, ordinal) + } else { + turn_catalog_entry(turn, ordinal) + } + }) + .collect(), + ); + assert!(!incomplete.complete); + let catalog_path = manager.turn_catalog_path(workspace.path(), &session_id); + manager + .write_json_atomic(&catalog_path, &incomplete) + .await + .expect("incomplete catalog fixture should save"); + + let updated_turn = DialogTurnData::new( + "turn-1".to_string(), + 1, + session_id.clone(), + user_message("updated prompt 1"), + ); + manager + .save_dialog_turn(workspace.path(), &updated_turn) + .await + .expect("existing turn should update"); + + let repaired: SessionTurnCatalog = serde_json::from_str( + &std::fs::read_to_string(&catalog_path).expect("catalog should be readable"), + ) + .expect("catalog should deserialize"); + assert!(is_well_formed_turn_catalog(&repaired)); + assert!(!repaired.complete); + assert_eq!(repaired.total_turn_count, 5); + assert!(repaired.entries[0].turn_id.is_none()); + assert_eq!(repaired.entries[1].turn_id.as_deref(), Some("turn-1")); + assert_eq!( + repaired.entries[1].preview.as_deref(), + Some("updated prompt 1") + ); + assert!(repaired.entries[2].preview.is_none()); + assert_eq!(repaired.entries[4].turn_id.as_deref(), Some("turn-4")); + + let appended_turn = DialogTurnData::new( + "turn-5".to_string(), + 5, + session_id.clone(), + user_message("prompt 5"), + ); + manager + .save_dialog_turn(workspace.path(), &appended_turn) + .await + .expect("new tail turn should append"); + + let appended: SessionTurnCatalog = serde_json::from_str( + &std::fs::read_to_string(&catalog_path).expect("catalog should be readable"), + ) + .expect("catalog should deserialize"); + assert!(is_well_formed_turn_catalog(&appended)); + assert!(!appended.complete); + assert_eq!(appended.total_turn_count, 6); + assert!(appended.entries[0].turn_id.is_none()); + assert!(appended.entries[2].preview.is_none()); + assert_eq!(appended.entries[5].turn_id.as_deref(), Some("turn-5")); + assert_eq!(appended.entries[5].preview.as_deref(), Some("prompt 5")); + } + + #[tokio::test] + async fn misaligned_incomplete_turn_catalog_falls_back_to_full_rebuild() { + let workspace = TestWorkspace::new(); + let manager = + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"); + let session_id = Uuid::new_v4().to_string(); + let session = Session::new_with_id( + session_id.clone(), + "Catalog rebuild fallback".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + + let turns = (0..3) + .map(|index| { + DialogTurnData::new( + format!("turn-{index}"), + index, + session_id.clone(), + user_message(&format!("prompt {index}")), + ) + }) + .collect::>(); + for turn in &turns { + manager + .save_dialog_turn(workspace.path(), turn) + .await + .expect("turn should save"); + } + + let misaligned = build_turn_catalog( + &session_id, + vec![ + placeholder_turn_catalog_entry(0, 0), + placeholder_turn_catalog_entry(2, 1), + ], + ); + assert!(!misaligned.complete); + let catalog_path = manager.turn_catalog_path(workspace.path(), &session_id); + manager + .write_json_atomic(&catalog_path, &misaligned) + .await + .expect("misaligned catalog fixture should save"); + + manager + .save_dialog_turn(workspace.path(), &turns[0]) + .await + .expect("saved turn should trigger safe fallback"); + + let rebuilt: SessionTurnCatalog = serde_json::from_str( + &std::fs::read_to_string(&catalog_path).expect("catalog should be readable"), + ) + .expect("catalog should deserialize"); + assert!(is_well_formed_turn_catalog(&rebuilt)); + assert!(rebuilt.complete); + assert_eq!(rebuilt.total_turn_count, 3); + assert_eq!(rebuilt.entries[0].storage_turn_index, 0); + assert_eq!(rebuilt.entries[1].storage_turn_index, 1); + assert_eq!(rebuilt.entries[2].storage_turn_index, 2); + assert_eq!(rebuilt.entries[2].preview.as_deref(), Some("prompt 2")); + } + + #[tokio::test] + async fn staged_revert_catalog_projection_hides_the_physical_suffix() { + let workspace = TestWorkspace::new(); + let manager = + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"); + let session_id = Uuid::new_v4().to_string(); + let session = Session::new_with_id( + session_id.clone(), + "Catalog staged revert".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + let visible_turn = DialogTurnData::new( + "turn-0".to_string(), + 0, + session_id.clone(), + user_message("visible"), + ); + let hidden_turn = DialogTurnData::new( + "turn-1".to_string(), + 1, + session_id.clone(), + user_message("hidden"), + ); + manager + .save_dialog_turn(workspace.path(), &visible_turn) + .await + .expect("visible turn should save"); + manager + .save_dialog_turn(workspace.path(), &hidden_turn) + .await + .expect("hidden turn should save before staging"); + manager + .save_session_revert_state( + workspace.path(), + &session_id, + &SessionRevertState { + schema_version: SESSION_REVERT_SCHEMA_VERSION, + boundary_turn: 1, + original_turn_end: 2, + phase: SessionRevertPhase::Staged, + workspace_checkpoint: Vec::new(), + }, + ) + .await + .expect("staged revert should save"); + + let projected = manager + .load_session_turn_catalog( + workspace.path(), + &session_id, + std::slice::from_ref(&visible_turn), + 1, + ) + .await + .expect("visible catalog should project"); + assert_eq!(projected.total_turn_count, 1); + assert_eq!(projected.entries[0].turn_id.as_deref(), Some("turn-0")); + + let physical: SessionTurnCatalog = serde_json::from_str( + &std::fs::read_to_string(manager.turn_catalog_path(workspace.path(), &session_id)) + .expect("physical catalog should remain readable"), + ) + .expect("physical catalog should deserialize"); + assert_eq!(physical.total_turn_count, 2); + assert_eq!(physical.entries[1].turn_id.as_deref(), Some("turn-1")); + } + + #[tokio::test] + async fn turn_window_is_bounded_revision_aware_and_staged_revert_safe() { + let workspace = TestWorkspace::new(); + let manager = + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"); + let session_id = Uuid::new_v4().to_string(); + let session = Session::new_with_id( + session_id.clone(), + "Turn window".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + for index in 0..30 { + manager + .save_dialog_turn( + workspace.path(), + &DialogTurnData::new( + format!("turn-{index}"), + index, + session_id.clone(), + user_message(&format!("prompt {index}")), + ), + ) + .await + .expect("turn should save"); + } + + let catalog = manager + .load_session_turn_catalog(workspace.path(), &session_id, &[], 30) + .await + .expect("catalog should load"); + let request = SessionTurnWindowRequest { + workspace_path: workspace.path().to_path_buf(), + session_id: session_id.clone(), + include_internal: false, + target_storage_turn_index: 10, + expected_turn_id: Some("turn-10".to_string()), + expected_catalog_revision: Some(catalog.revision.clone()), + before: usize::MAX, + after: usize::MAX, + }; + + let ready = manager + .load_session_turn_window(&request) + .await + .expect("window should load"); + match ready { + SessionTurnWindowResponse::Ready { + catalog_revision, + total_turn_count, + start_ordinal, + end_ordinal_exclusive, + target_turn_id, + turns, + } => { + assert_eq!(catalog_revision, catalog.revision); + assert_eq!(total_turn_count, 30); + assert_eq!(start_ordinal, 6); + assert_eq!(end_ordinal_exclusive, 22); + assert_eq!(target_turn_id, "turn-10"); + assert_eq!(turns.len(), 16); + assert_eq!(turns.first().map(|turn| turn.turn_index), Some(6)); + assert_eq!(turns.last().map(|turn| turn.turn_index), Some(21)); + } + response => panic!("unexpected response: {response:?}"), + } + + let mut stale_revision_request = request.clone(); + stale_revision_request.expected_catalog_revision = Some("obsolete".to_string()); + assert!(matches!( + manager + .load_session_turn_window(&stale_revision_request) + .await + .expect("stale revision should be structured"), + SessionTurnWindowResponse::Stale { .. } + )); + + let mut stale_turn_request = request.clone(); + stale_turn_request.expected_turn_id = Some("replaced-turn".to_string()); + assert!(matches!( + manager + .load_session_turn_window(&stale_turn_request) + .await + .expect("stale Turn ID should be structured"), + SessionTurnWindowResponse::Stale { .. } + )); + + manager + .save_session_revert_state( + workspace.path(), + &session_id, + &SessionRevertState { + schema_version: SESSION_REVERT_SCHEMA_VERSION, + boundary_turn: 8, + original_turn_end: 30, + phase: SessionRevertPhase::Staged, + workspace_checkpoint: Vec::new(), + }, + ) + .await + .expect("staged revert should save"); + let hidden = manager + .load_session_turn_window(&SessionTurnWindowRequest { + expected_catalog_revision: None, + ..request + }) + .await + .expect("hidden target should be structured"); + match hidden { + SessionTurnWindowResponse::NotFound { catalog } => { + assert_eq!(catalog.total_turn_count, 8); + assert!(catalog + .entries + .iter() + .all(|entry| entry.storage_turn_index < 8)); + } + response => panic!("unexpected response: {response:?}"), + } + } + #[test] fn compression_transcript_file_name_parser_is_strict() { assert_eq!( diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index b16ac5f279..b63a872d9b 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -4751,6 +4751,7 @@ impl SessionManager { visibility_metadata_duration_ms, load_session_with_turns_duration_ms, normalize_turn_ids_duration_ms, + turn_catalog_duration_ms: 0, total_duration_ms, turn_load, }; diff --git a/src/crates/assembly/core/src/product_runtime.rs b/src/crates/assembly/core/src/product_runtime.rs index fd5d4b9ff6..49a7ef57bf 100644 --- a/src/crates/assembly/core/src/product_runtime.rs +++ b/src/crates/assembly/core/src/product_runtime.rs @@ -9,7 +9,7 @@ mod runtime_services; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::OnceLock; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use bitfun_agent_runtime::permission::PermissionRequestManager; use bitfun_agent_runtime::sdk::{ @@ -24,7 +24,7 @@ use bitfun_runtime_ports::{ LocalWorkspaceSnapshotSessionRequest, LocalWorkspaceSnapshotStats, LocalWorkspaceSnapshotTurnRequest, PortError, PortErrorKind, PortResult, RuntimeServiceCapability, RuntimeServicePort, SessionStoragePathRequest, SessionStorePort, - SessionViewRestoreTiming, + SessionTurnWindowRequest, SessionViewRestoreTiming, }; use bitfun_runtime_services::RuntimeServices; use bitfun_services_core::permission_store::ProjectPermissionSqliteStore; @@ -42,6 +42,7 @@ use crate::agentic::session::{CoreSessionStorePort, PromptCacheScope}; use crate::agentic::tools::implementations::skills::SkillRegistry; use crate::service::session::{ DialogTurnData, SessionMetadata, SessionTranscriptExport, SessionTranscriptExportOptions, + SessionTurnCatalog, SessionTurnWindowResponse, }; use crate::service::session_usage::{ generate_session_usage_report_from_storage_path, SessionUsageReport, @@ -711,10 +712,13 @@ impl CoreAgentRuntimeCompatibility { Session, Vec, usize, + SessionTurnCatalog, SessionViewRestoreTiming, )> { validate_persisted_session_id(session_id)?; - if let Some(tail_turn_count) = tail_turn_count { + let (session, turns, total_turn_count, mut timing) = if let Some(tail_turn_count) = + tail_turn_count + { if include_internal { self.coordinator .restore_internal_session_view_from_storage_path_tail_timed( @@ -722,7 +726,7 @@ impl CoreAgentRuntimeCompatibility { session_id, tail_turn_count, ) - .await + .await? } else { self.coordinator .restore_session_view_from_storage_path_tail_timed( @@ -730,7 +734,7 @@ impl CoreAgentRuntimeCompatibility { session_id, tail_turn_count, ) - .await + .await? } } else { let (session, turns, timing) = if include_internal { @@ -743,8 +747,48 @@ impl CoreAgentRuntimeCompatibility { .await? }; let total_turn_count = turns.len(); - Ok((session, turns, total_turn_count, timing)) + (session, turns, total_turn_count, timing) + }; + let turn_catalog_started_at = Instant::now(); + let turn_catalog = self + .persistence + .load_session_turn_catalog(storage_path, session_id, &turns, total_turn_count) + .await?; + timing.turn_catalog_duration_ms = turn_catalog_started_at + .elapsed() + .as_millis() + .min(u64::MAX as u128) as u64; + timing.total_duration_ms = timing + .total_duration_ms + .saturating_add(timing.turn_catalog_duration_ms); + Ok((session, turns, total_turn_count, turn_catalog, timing)) + } + + pub async fn load_session_turn_window_from_storage_path( + &self, + storage_path: &Path, + mut request: SessionTurnWindowRequest, + ) -> BitFunResult { + validate_persisted_session_id(&request.session_id)?; + if self + .persistence + .load_session_metadata(storage_path, &request.session_id) + .await? + .is_some_and(|metadata| { + !request.include_internal && metadata.should_hide_from_user_lists() + }) + { + return Err(BitFunError::NotFound(format!( + "Session not found: {}", + request.session_id + ))); } + + let _read = self + .begin_persisted_session_read(storage_path, &request.session_id) + .await?; + request.workspace_path = storage_path.to_path_buf(); + self.persistence.load_session_turn_window(&request).await } pub async fn restore_session_with_turns_from_storage_path( @@ -775,41 +819,18 @@ impl CoreAgentRuntimeCompatibility { Session, Vec, usize, + SessionTurnCatalog, SessionViewRestoreTiming, )> { validate_persisted_session_id(session_id)?; - if let Some(tail_turn_count) = tail_turn_count { - let storage_path = self.resolve_persisted_session_storage_path(request).await?; - if include_internal { - self.coordinator - .restore_internal_session_view_from_storage_path_tail_timed( - &storage_path, - session_id, - tail_turn_count, - ) - .await - } else { - self.coordinator - .restore_session_view_from_storage_path_tail_timed( - &storage_path, - session_id, - tail_turn_count, - ) - .await - } - } else { - let (session, turns, timing) = if include_internal { - self.coordinator - .restore_internal_session_view_for_workspace_timed(request, session_id) - .await? - } else { - self.coordinator - .restore_session_view_for_workspace_timed(request, session_id) - .await? - }; - let total_turn_count = turns.len(); - Ok((session, turns, total_turn_count, timing)) - } + let storage_path = self.resolve_persisted_session_storage_path(request).await?; + self.restore_session_view_from_storage_path( + &storage_path, + session_id, + include_internal, + tail_turn_count, + ) + .await } pub async fn restore_session_with_turns_for_workspace( diff --git a/src/crates/contracts/runtime-ports/src/lib.rs b/src/crates/contracts/runtime-ports/src/lib.rs index 11576585b0..b29119fdac 100644 --- a/src/crates/contracts/runtime-ports/src/lib.rs +++ b/src/crates/contracts/runtime-ports/src/lib.rs @@ -228,6 +228,21 @@ pub struct SessionTurnLoadRequest { pub tail_turn_count: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionTurnWindowRequest { + pub workspace_path: PathBuf, + pub session_id: String, + pub include_internal: bool, + pub target_storage_turn_index: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_turn_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_catalog_revision: Option, + pub before: usize, + pub after: usize, +} + #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionTurnLoadTiming { @@ -253,6 +268,8 @@ pub struct SessionViewRestoreTiming { pub visibility_metadata_duration_ms: u64, pub load_session_with_turns_duration_ms: u64, pub normalize_turn_ids_duration_ms: u64, + #[serde(default)] + pub turn_catalog_duration_ms: u64, pub total_duration_ms: u64, pub turn_load: SessionTurnLoadTiming, } diff --git a/src/crates/contracts/runtime-ports/tests/session_store_contracts.rs b/src/crates/contracts/runtime-ports/tests/session_store_contracts.rs index 63c96743cb..dadfa796c3 100644 --- a/src/crates/contracts/runtime-ports/tests/session_store_contracts.rs +++ b/src/crates/contracts/runtime-ports/tests/session_store_contracts.rs @@ -3,9 +3,30 @@ use std::path::PathBuf; use bitfun_runtime_ports::{ RuntimeServiceCapability, RuntimeServicePort, SessionStorageKind, SessionStoragePathRequest, SessionStoragePathResolution, SessionStorePort, SessionTurnLoadTiming, - SessionViewRestoreTiming, + SessionTurnWindowRequest, SessionViewRestoreTiming, }; +#[test] +fn session_turn_window_request_serializes_stable_camel_case_fields() { + let encoded = serde_json::to_value(SessionTurnWindowRequest { + workspace_path: PathBuf::from("/workspace"), + session_id: "session-1".to_string(), + include_internal: false, + target_storage_turn_index: 7, + expected_turn_id: Some("turn-7".to_string()), + expected_catalog_revision: Some("catalog-1".to_string()), + before: 4, + after: 12, + }) + .expect("window request should serialize"); + + assert_eq!(encoded["workspacePath"], "/workspace"); + assert_eq!(encoded["sessionId"], "session-1"); + assert_eq!(encoded["targetStorageTurnIndex"], 7); + assert_eq!(encoded["expectedTurnId"], "turn-7"); + assert_eq!(encoded["expectedCatalogRevision"], "catalog-1"); +} + #[test] fn session_storage_path_resolution_carries_local_and_remote_facts() { let local = SessionStoragePathResolution::new( @@ -40,7 +61,8 @@ fn session_restore_timing_serializes_camel_case_fields() { visibility_metadata_duration_ms: 2, load_session_with_turns_duration_ms: 3, normalize_turn_ids_duration_ms: 4, - total_duration_ms: 5, + turn_catalog_duration_ms: 5, + total_duration_ms: 10, turn_load: SessionTurnLoadTiming { requested_tail_turn_count: Some(8), loaded_turn_count: 8, @@ -60,6 +82,7 @@ fn session_restore_timing_serializes_camel_case_fields() { let encoded = serde_json::to_value(&timing).expect("timing should serialize"); assert_eq!(encoded["resolveStoragePathDurationMs"], 1); + assert_eq!(encoded["turnCatalogDurationMs"], 5); assert_eq!(encoded["turnLoad"]["requestedTailTurnCount"], 8); assert_eq!(encoded["turnLoad"]["fastPath"], true); } diff --git a/src/crates/services/services-core/src/session/layout.rs b/src/crates/services/services-core/src/session/layout.rs index d658dcb4ee..3e2731b972 100644 --- a/src/crates/services/services-core/src/session/layout.rs +++ b/src/crates/services/services-core/src/session/layout.rs @@ -43,6 +43,10 @@ impl SessionStorageLayout { self.session_dir(session_id).join("prompt_cache.json") } + pub fn turn_catalog_path(&self, session_id: &str) -> PathBuf { + self.session_dir(session_id).join("turn-catalog.json") + } + pub fn request_traces_dir(&self, session_id: &str) -> PathBuf { self.session_dir(session_id).join("request-traces") } diff --git a/src/crates/services/services-core/src/session/types.rs b/src/crates/services/services-core/src/session/types.rs index e00281685a..0d4029e001 100644 --- a/src/crates/services/services-core/src/session/types.rs +++ b/src/crates/services/services-core/src/session/types.rs @@ -8,6 +8,7 @@ use bitfun_events::ModelRoundAttemptDiagnostic; use serde::{Deserialize, Serialize}; pub const SESSION_STORAGE_SCHEMA_VERSION: u32 = 2; +pub const SESSION_TURN_CATALOG_SCHEMA_VERSION: u32 = 1; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -366,6 +367,77 @@ impl Default for SessionList { } } +/// Lightweight, rebuildable navigation metadata for one persisted dialog turn. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SessionTurnCatalogEntry { + /// Zero-based position in the current catalog projection. + pub ordinal: usize, + /// Absolute persisted Turn index used by the storage layout. + pub storage_turn_index: usize, + /// Missing only while a legacy catalog is being reconstructed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + /// Bounded, user-readable input preview. Never contains model or tool output. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preview: Option, + #[serde(default)] + pub preview_truncated: bool, +} + +/// Lightweight navigation catalog for a persisted Session. +/// +/// This is a derived cache. Persisted Turn files and the staged-revert boundary +/// remain authoritative and may be used to rebuild this value at any time. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SessionTurnCatalog { + pub schema_version: u32, + pub session_id: String, + /// Changes only when the visible storage sequence changes. Repairing + /// optional Turn ids or previews does not invalidate an in-flight window. + pub revision: String, + pub total_turn_count: usize, + pub complete: bool, + pub entries: Vec, +} + +/// Result of loading one bounded, contiguous window around a persisted Turn. +/// +/// Catalog changes caused by live appends, revert operations, or external +/// writers are ordinary synchronization outcomes rather than transport errors. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde( + tag = "status", + rename_all = "kebab-case", + rename_all_fields = "camelCase" +)] +pub enum SessionTurnWindowResponse { + Ready { + catalog_revision: String, + total_turn_count: usize, + start_ordinal: usize, + end_ordinal_exclusive: usize, + target_turn_id: String, + turns: Vec, + }, + Stale { + catalog: SessionTurnCatalog, + }, + NotFound { + catalog: SessionTurnCatalog, + }, +} + +impl SessionTurnWindowResponse { + pub fn ready_turns_mut(&mut self) -> Option<&mut Vec> { + match self { + Self::Ready { turns, .. } => Some(turns), + Self::Stale { .. } | Self::NotFound { .. } => None, + } + } +} + /// Full dialog turn data #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -1071,8 +1143,8 @@ impl DialogTurnData { mod tests { use super::{ DialogTurnData, DialogTurnKind, ModelRoundData, SessionMemoryMode, SessionMetadata, - SessionRelationship, SessionRelationshipKind, TextItemData, ThinkingItemData, ToolItemData, - UserMessageData, + SessionRelationship, SessionRelationshipKind, SessionTurnWindowResponse, TextItemData, + ThinkingItemData, ToolItemData, UserMessageData, }; use bitfun_core_types::{SessionContinuationPolicy, SessionKind}; @@ -1116,6 +1188,37 @@ mod tests { assert_eq!(turn.kind, DialogTurnKind::UserDialog); } + #[test] + fn session_turn_window_response_uses_tagged_camel_case_wire_shape() { + let turn = DialogTurnData::new( + "turn-4".to_string(), + 4, + "session-1".to_string(), + UserMessageData { + id: "user-4".to_string(), + content: "hello".to_string(), + timestamp: 1, + metadata: None, + }, + ); + let serialized = serde_json::to_value(SessionTurnWindowResponse::Ready { + catalog_revision: "catalog-1".to_string(), + total_turn_count: 20, + start_ordinal: 2, + end_ordinal_exclusive: 10, + target_turn_id: "turn-4".to_string(), + turns: vec![turn], + }) + .expect("window response should serialize"); + + assert_eq!(serialized["status"], "ready"); + assert_eq!(serialized["catalogRevision"], "catalog-1"); + assert_eq!(serialized["totalTurnCount"], 20); + assert_eq!(serialized["startOrdinal"], 2); + assert_eq!(serialized["endOrdinalExclusive"], 10); + assert_eq!(serialized["targetTurnId"], "turn-4"); + } + #[test] fn dialog_turn_token_usage_round_trips_camel_case_payloads() { let payload = serde_json::json!({ diff --git a/src/crates/services/services-core/tests/session_layout_contracts.rs b/src/crates/services/services-core/tests/session_layout_contracts.rs index 9c500ed5c7..a5df9fb14c 100644 --- a/src/crates/services/services-core/tests/session_layout_contracts.rs +++ b/src/crates/services/services-core/tests/session_layout_contracts.rs @@ -58,6 +58,13 @@ fn session_layout_preserves_legacy_file_names() { .join("session-1") .join("prompt_cache.json") ); + assert_eq!( + layout.turn_catalog_path("session-1"), + root.path() + .join("sessions") + .join("session-1") + .join("turn-catalog.json") + ); assert_eq!( layout.request_trace_path("session-1", 7), root.path() diff --git a/src/web-ui/src/app/scenes/session/ChatPane.tsx b/src/web-ui/src/app/scenes/session/ChatPane.tsx index 26be13eec3..4c83daffc7 100644 --- a/src/web-ui/src/app/scenes/session/ChatPane.tsx +++ b/src/web-ui/src/app/scenes/session/ChatPane.tsx @@ -158,6 +158,7 @@ const ChatPaneInner: React.FC = ({ > { diff --git a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx index 57c188e8ed..2a859fd646 100644 --- a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx +++ b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx @@ -931,6 +931,7 @@ export const BtwSessionPanel: React.FC = ({ const requestId = btwOrigin?.requestId; const request: FlowChatFocusItemRequest = { sessionId: resolvedParentSessionId, + turnId: btwOrigin?.parentDialogTurnId, turnIndex: btwOrigin?.parentTurnIndex, itemId: requestId ? `btw_marker_${requestId}` : undefined, source: 'btw-back', diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md index e34a8e9001..d6e32c028c 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md @@ -265,17 +265,34 @@ new request exits tail follow, but it does not remove the previous established pin reservation before the target DOM exists. That reservation remains only as physical scroll range; the active request prevents the old sticky target from reconciling it. Once the requested user message is rendered, the shared pin -resolver replaces the old reservation, aligns the message to the 57px viewport -offset, and starts bounded transient stabilization. An expired request releases -semantic ownership while preserving the current physical range, so failure -cannot silently clamp the pane to the bottom. +resolver applies the request's alignment policy. Exact requests replace the old +reservation, align the message to the 57px viewport offset, and start bounded +transient stabilization. Turn-rail requests use best-effort alignment: they +still align exactly when the natural range is sufficient, but when the target +cannot reach the 57px offset without synthetic tail space, they remove the +transient pin reservation, clamp to the natural maximum, and release +`pinned-item` ownership immediately. The natural boundary is an expected +content limit, not a pending transaction, so it must not retry until TTL expiry. +`sticky-latest` always uses the exact policy because streaming follow-output +depends on its protected pin range. An expired request releases semantic +ownership while preserving the current physical range, so failure cannot +silently clamp the pane to the bottom. `rangeChanged` is a target-materialization signal, not a source of turn identity. It retries the active generation against real DOM geometry. RAF retries remain as a bounded fallback for browsers that coalesce range updates. -When the static initial-history renderer hands off to Virtuoso with a pending -target, that target becomes Virtuoso's `initialTopMostItemIndex`; the normal pin -resolver then performs exact alignment after mount. The left-side +Transient navigation remains pending until the requested turn stays aligned for +two consecutive geometry samples. During that bounded transaction, Virtuoso's +materialization range expands to two viewport heights in both directions so +height-estimate reconciliation cannot immediately evict the target. If the +pinned DOM element still disconnects, the coordinator drops the stale element +anchor but retains logical `pinned-item` ownership while the active generation +rematerializes it. User intent, replacement, expiry, and explicit handoff still +release that ownership. +Virtuoso mounts on the first initial-history commit. A target prepared before +its ref is available becomes `initialTopMostItemIndex`; targets selected after +mount enter the normal immediate materialize-then-align transaction. The +left-side `FlowChatTurnRail` is mounted outside the scroller and delegates navigation to the same container-owned turn-pin request, so it does not need to rebind across renderer handoffs or write the FlowChat viewport directly. @@ -364,31 +381,27 @@ If a shrink happens without a collapse intent: This path is safer than doing nothing, but it is more likely to show visible movement than the pre-compensation path. -## C. Static Initial-History Turn Navigation +## C. Initial-History Snapshot Handoff -The initial-history path renders a bounded static window plus estimated leading -and trailing spacers before handing the complete projection to Virtuoso. The -left-side turn rail may target a turn outside that window. The list first -materializes a new window around the target and then issues the requested -scroll. +Virtuoso is the only initial-history scroller and mounts on the first commit. +For sessions that still need the initial history render budget, a bounded recent +projection is rendered above it as a non-interactive snapshot. The snapshot: -A smooth scroll does not update `scrollTop` synchronously. The window swap can -therefore emit a scroll event at the old, browser-clamped physical bottom before -the target motion begins. That geometry is not evidence that the user returned -to the latest turn. While a static anchor window is active: +- has no scroll container, spacers, pagination handlers, or viewport writer +- uses `pointer-events: none` and cannot consume wheel, touch, keyboard, or + scrollbar intent +- keeps the previous pixels visible while Virtuoso measures its initial range +- releases immediately when the user starts scrolling so the real Virtuoso + motion is never hidden behind a frozen frame +- retargets its release condition when Turn navigation begins during handoff +- disappears only after the requested Turn has visible text, the session + changes, or the bounded handoff timeout expires -- physical `atBottom` does not make the viewport semantically latest -- programmatic turn navigation never releases the anchor window -- programmatic turn navigation must not start older-history pagination when it - crosses the leading spacer; only an explicit upward user gesture may do so -- explicit jump-to-latest navigation releases it directly -- wheel, touch, keyboard, or scrollbar motion must establish a recent downward - user intent before arrival at the physical bottom may release it -- session reset clears the anchor window and any pending bottom-return intent - -Keep the user-intent window bounded. It exists only to classify the scroll event -that follows an input gesture; it must not become a persistent scroll lock or a -second viewport writer. +All Turn navigation, search materialization, boundary pagination, bottom state, +and follow-output transitions run through the mounted Virtuoso instance even +while the snapshot is visible. A catalog-backed partial session still keeps +only its restored tail as the default data presentation; this rendering change +does not imply full-history hydration. ## D. Arbitrary Turn Navigation Through Virtuoso @@ -407,6 +420,14 @@ transaction: 7. cancel stale work on a newer request, user intent, session switch, jump to latest, or timeout +Every turn-rail marker, including the canonical latest Turn, uses this same +immediate transient top-pin transaction. Selecting the latest marker means +"show this Turn header"; it does not restore the tail presentation or resume +follow-output. Only the explicit jump-to-latest action restores the canonical +tail presentation and re-enters live-tail following. This separation keeps +turn navigation consistent and treats every rail selection as user reading +intent, including while the latest Turn is streaming. + Do not clear the previous pin/footer range in step 2. The target may be outside the current Virtuoso range, and removing the footer first lets the browser clamp the old position to the physical bottom before materialization succeeds. @@ -424,6 +445,81 @@ uses the same rail emphasis. Publish a new ordered `visibleTurnIds` snapshot only when membership or order changes so ordinary scroll frames do not cause redundant rail renders. +### Catalog-backed history loading + +Catalog, loaded Turn cache, and active presentation are separate layers. Keep +these ownership rules intact: + +- `Session.dialogTurns` remains the live restored tail unless an explicit + full-history consumer calls `ensureSessionFullHistory`. +- Data residency, viewport intent, and follow-output ownership are independent. + A cached history presentation may remain resident after the viewport returns + to the live tail, but it must not keep the UI in history-reading mode, + suppress live-tail anchoring, or imply that follow-output is active. +- For a small session whose cached presentation is contiguous from ordinal zero + through the current total (`[0, totalTurnCount)`) and stays within the + continuous projection budgets (24 Turns and 200 virtual items), explicit + jump-to-latest changes only the viewport intent and follow-output ownership. + The rendered projection and its stable virtual-item keys remain unchanged; + `historyWindow` is disabled so boundary loading cannot start while following + the tail. Canonical overlapping Turns are still overlaid by stable id, and a + newly appended canonical Turn extends the projection at the end. +- Incomplete, discontinuous, or over-budget presentations retain the fallback + behavior: explicit jump-to-latest clears the Store's `activeRange`, restores + the canonical tail data source, and keeps the most recent component + presentation only as a reactivation hint. The Store LRU remains authoritative: + reactivation must find the complete range in `loadedRanges`, touch it as MRU, + and otherwise fall back to the ordinary window-load transaction. +- Turn-rail navigation and sequential boundary loading use + `load_session_turn_window`; neither path writes the FlowChat scroller. +- Upward user intent at the restored-tail boundary loads the adjacent ordinal + window without holding viewport ownership. Presentation activation then waits + for a bounded 320 ms quiet window after the latest wheel, touch, keyboard, or + scrollbar intent. New input resets that wait; session changes and newer + presentation-owner generations cancel it. Only after the quiet window is + acquired does the list capture the current element anchor and change to one + contiguous history-window presentation. This keeps a multi-thousand-pixel + prepend commit out of an active wheel gesture while still allowing the data + request itself to prefetch in parallel. Never expose a later cached range + across an unloaded gap. +- Derive the restored-tail boundary from the canonical `Session.dialogTurns` + ordinal interval, never from the start of a merged `loadedRanges` entry. + Cache residency may extend to the first Turn while the canonical tail still + renders only recent Turns. Reaching ordinal zero is an exhausted boundary, + not a not-ready or failed load. +- Appending below the current presentation does not require compensation. + Prepending or trimming above it must retain the existing element-anchor + transaction until the same user message returns to its captured viewport + offset. +- A rejected or failed adjacent-window request must release only the element + anchor lease created during its commit preparation, if any. A stale + completion must never release a newer navigation or layout-preservation + transaction. +- The non-tail loaded Turn cache uses a 48-Turn soft budget and a 64-Turn hard + budget. Crossing the hard budget evicts least-recently-used ordinals back + toward the soft budget. The live tail, active presentation, pending target, + and in-flight request intervals are protected; merged cached ranges may be + sliced, but the active presentation is never trimmed by cache eviction. +- Passive live-tail updates outside the presented history range remain hidden + while the user reads history. When the history range overlaps canonical live + Turns, stable Turn ids select the canonical objects instead of cached + snapshots so streaming or recently completed content stays current without + changing the viewport intent. An explicit `send-message` Turn-pin request + first restores the tail presentation, then lets the existing sticky-latest + pin materialize the newly submitted Turn. +- Cross-feature focus requests identify a Turn by stable `turnId` whenever one + is available, with `turnIndex` reserved for the absolute one-based visible + ordinal. They delegate to the same catalog/window materialization transaction + as the Turn rail. Never pass that absolute ordinal to `scrollToTurn` on a + partial tail or bounded history presentation; that method only understands + the currently rendered local list. +- Search, edit, rollback, and compatibility fallback are explicit full-history + consumers. Their shared ensure operation deduplicates an existing request and + applies the completed projection only after the caller asks for it. +- A Host without `turnCatalog`, or without `load_session_turn_window`, retains + the legacy full-restore fallback. This compatibility path must not cause a + catalog-capable Host to resume unconditional background hydration. + ## Why Transition Tracking Exists User-initiated expand/collapse still uses animated layout properties such as: diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatCollapseAlignment.test.ts b/src/web-ui/src/flow_chat/components/modern/FlowChatCollapseAlignment.test.ts index 38cda7c617..e37f21d765 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatCollapseAlignment.test.ts +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatCollapseAlignment.test.ts @@ -114,3 +114,13 @@ describe('FlowChat collapse spacing', () => { ); }); }); + +describe('FlowChat initial projection alignment', () => { + it('reserves the Virtuoso scrollbar gutter in the handoff overlay', () => { + const stylesheet = readSource('./VirtualMessageList.scss'); + + expect(stylesheet).toMatch( + /&__projection-handoff-overlay\s*\{[\s\S]*?scrollbar-gutter:\s*stable;/, + ); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.scss b/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.scss index 3c0338e5ea..0f555cf2b1 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.scss +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.scss @@ -12,10 +12,7 @@ &__list { width: 100%; max-height: 100%; - display: grid; - grid-auto-rows: 12px; - align-content: start; - padding: 3px 1px; + padding: 0 1px; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; @@ -27,8 +24,15 @@ } } - &__item { + &__track { position: relative; + width: 100%; + min-height: 100%; + } + + &__item { + position: absolute; + left: 0; width: 26px; height: 12px; margin: 0; diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.test.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.test.tsx index 44266f89de..b73a1208c1 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.test.tsx @@ -5,6 +5,7 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FlowChatTurnRail, type FlowChatTurnRailItem } from './FlowChatTurnRail'; +import { FLOWCHAT_TURN_RAIL_ROW_HEIGHT_PX } from './flowChatTurnRailWindow'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -35,12 +36,22 @@ vi.mock('@/component-library', () => ({ })); const turns: FlowChatTurnRailItem[] = [ - { turnId: 'turn-1', turnIndex: 1, content: 'First user message' }, - { turnId: 'turn-2', turnIndex: 2, content: 'Second user message' }, - { turnId: 'turn-3', turnIndex: 3, content: 'Third user message' }, - { turnId: 'turn-4', turnIndex: 4, content: 'Fourth user message' }, + { itemKey: 'storage:0', turnId: 'turn-1', ordinal: 0, turnIndex: 1, content: 'First user message' }, + { itemKey: 'storage:1', turnId: 'turn-2', ordinal: 1, turnIndex: 2, content: 'Second user message' }, + { itemKey: 'storage:2', turnId: 'turn-3', ordinal: 2, turnIndex: 3, content: 'Third user message' }, + { itemKey: 'storage:3', turnId: 'turn-4', ordinal: 3, turnIndex: 4, content: 'Fourth user message' }, ]; +function createTurns(count: number): FlowChatTurnRailItem[] { + return Array.from({ length: count }, (_, ordinal) => ({ + itemKey: `storage:${ordinal}`, + turnId: `turn-${ordinal + 1}`, + ordinal, + turnIndex: ordinal + 1, + content: `Message ${ordinal + 1}`, + })); +} + describe('FlowChatTurnRail', () => { let container: HTMLDivElement; let root: Root; @@ -114,7 +125,75 @@ describe('FlowChatTurnRail', () => { }); expect(onNavigate).toHaveBeenCalledOnce(); - expect(onNavigate).toHaveBeenCalledWith('turn-3'); + expect(onNavigate).toHaveBeenCalledWith(turns[2]); + }); + + it('keeps placeholder markers visible and navigable by ordinal', () => { + const onNavigate = vi.fn(); + act(() => { + root.render( + , + ); + }); + + const placeholder = container.querySelector('[data-turn-key="storage:1"]'); + expect(placeholder).not.toBeNull(); + expect(placeholder?.getAttribute('aria-disabled')).toBeNull(); + expect(placeholder?.getAttribute('data-turn-id')).toBeNull(); + + act(() => placeholder?.click()); + + expect(onNavigate).toHaveBeenCalledWith(expect.objectContaining({ + ordinal: 1, + turnId: null, + })); + const tooltipMessages = container.querySelectorAll('.flowchat-turn-rail__tooltip-message'); + expect(tooltipMessages).toHaveLength(1); + }); + + it('keeps marker identity stable when a placeholder resolves', () => { + act(() => { + root.render( + , + ); + }); + const placeholder = container.querySelector('[data-turn-key="storage:7"]'); + expect(placeholder?.getAttribute('aria-disabled')).toBeNull(); + + act(() => { + root.render( + , + ); + }); + + const resolved = container.querySelector('[data-turn-key="storage:7"]'); + expect(resolved).toBe(placeholder); + expect(resolved?.getAttribute('data-turn-id')).toBe('turn-8'); + expect(resolved?.getAttribute('aria-disabled')).toBeNull(); }); it('keeps the active turn visible by scrolling only the rail list', () => { @@ -130,14 +209,10 @@ describe('FlowChatTurnRail', () => { }); const list = container.querySelector('.flowchat-turn-rail__list'); - const target = container.querySelector('[data-turn-id="turn-4"]'); expect(list).not.toBeNull(); - expect(target).not.toBeNull(); - if (!list || !target) return; + if (!list) return; Object.defineProperty(list, 'clientHeight', { configurable: true, value: 40 }); - Object.defineProperty(target, 'offsetTop', { configurable: true, value: 60 }); - Object.defineProperty(target, 'offsetHeight', { configurable: true, value: 20 }); list.scrollTop = 0; act(() => { @@ -151,7 +226,7 @@ describe('FlowChatTurnRail', () => { ); }); - expect(list.scrollTop).toBe(40); + expect(list.scrollTop).toBe(11); }); it('moves keyboard focus through the vertical turn list', () => { @@ -184,4 +259,73 @@ describe('FlowChatTurnRail', () => { expect(next.tabIndex).toBe(0); expect(current.tabIndex).toBe(-1); }); + + it('bounds rendered markers to the viewport plus overscan', () => { + const manyTurns = createTurns(100); + act(() => { + root.render( + , + ); + }); + + const rail = container.querySelector('[data-testid="flowchat-turn-rail"]'); + const list = container.querySelector('.flowchat-turn-rail__list'); + expect(rail?.dataset.totalTurnCount).toBe('100'); + expect(list).not.toBeNull(); + if (!list) return; + + Object.defineProperty(list, 'clientHeight', { configurable: true, value: 56 }); + list.scrollTop = 50 * FLOWCHAT_TURN_RAIL_ROW_HEIGHT_PX; + act(() => { + list.dispatchEvent(new Event('scroll', { bubbles: true })); + }); + + const renderedItems = container.querySelectorAll('.flowchat-turn-rail__item'); + expect(renderedItems.length).toBeLessThanOrEqual(18); + expect(renderedItems.length).toBeGreaterThanOrEqual(10); + expect(container.querySelector('[data-turn-ordinal="50"]')).not.toBeNull(); + expect(container.querySelector('[data-turn-ordinal="0"]')).toBeNull(); + expect(Array.from(renderedItems).filter(item => item.tabIndex === 0)).toHaveLength(1); + }); + + it('moves virtual keyboard focus across unmounted markers', () => { + const manyTurns = createTurns(100); + act(() => { + root.render( + , + ); + }); + + const list = container.querySelector('.flowchat-turn-rail__list'); + const first = container.querySelector('[data-turn-ordinal="0"]'); + expect(list).not.toBeNull(); + expect(first).not.toBeNull(); + if (!list || !first) return; + Object.defineProperty(list, 'clientHeight', { configurable: true, value: 42 }); + + act(() => { + first.focus(); + first.dispatchEvent(new KeyboardEvent('keydown', { + key: 'End', + bubbles: true, + })); + }); + + const last = container.querySelector('[data-turn-ordinal="99"]'); + expect(last).not.toBeNull(); + expect(document.activeElement).toBe(last); + expect(last?.getAttribute('aria-posinset')).toBe('100'); + expect(last?.getAttribute('aria-setsize')).toBe('100'); + expect(container.querySelector('[data-turn-ordinal="0"]')).toBeNull(); + }); }); diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.tsx index 03803ddca3..e5efbcfcb6 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.tsx @@ -2,19 +2,33 @@ import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useSta import { useTranslation } from 'react-i18next'; import { Tooltip } from '@/component-library'; import { observeElementResize } from '@/shared/utils/sharedResizeObserver'; +import { + FLOWCHAT_TURN_RAIL_ROW_HEIGHT_PX, + FLOWCHAT_TURN_RAIL_VERTICAL_PADDING_PX, + getFlowChatTurnRailScrollTopForOrdinal, + getFlowChatTurnRailTotalHeight, + getFlowChatTurnRailWindowRange, +} from './flowChatTurnRailWindow'; import './FlowChatTurnRail.scss'; export interface FlowChatTurnRailItem { - turnId: string; + itemKey: string; + turnId: string | null; + ordinal: number; turnIndex: number; - content: string; + content: string | null; } interface FlowChatTurnRailProps { turns: readonly FlowChatTurnRailItem[]; currentTurnId: string | null; visibleTurnIds: readonly string[]; - onNavigate: (turnId: string) => void; + onNavigate: (turn: FlowChatTurnRailItem) => void; +} + +interface FlowChatTurnRailViewportMetrics { + scrollTop: number; + clientHeight: number; } export const FlowChatTurnRail: React.FC = ({ @@ -26,46 +40,114 @@ export const FlowChatTurnRail: React.FC = ({ const { t } = useTranslation('flow-chat'); const railRef = useRef(null); const listRef = useRef(null); - const itemRefs = useRef(new Map()); - const [focusTurnId, setFocusTurnId] = useState( - currentTurnId ?? turns[0]?.turnId ?? null, + const itemRefs = useRef(new Map()); + const pendingFocusOrdinalRef = useRef(null); + const currentTurnOrdinal = useMemo( + () => turns.find(turn => turn.turnId === currentTurnId)?.ordinal ?? null, + [currentTurnId, turns], + ); + const totalOrdinalCount = useMemo(() => turns.reduce( + (count, turn) => Math.max(count, turn.ordinal + 1), + turns.length, + ), [turns]); + const turnByOrdinal = useMemo( + () => new Map(turns.map(turn => [turn.ordinal, turn])), + [turns], + ); + const turnArrayIndexByOrdinal = useMemo( + () => new Map(turns.map((turn, index) => [turn.ordinal, index])), + [turns], ); + const initialFocusOrdinal = currentTurnOrdinal ?? turns[0]?.ordinal ?? null; + const [focusOrdinal, setFocusOrdinal] = useState(initialFocusOrdinal); + const [viewportMetrics, setViewportMetrics] = useState(() => ({ + scrollTop: initialFocusOrdinal === null + ? 0 + : initialFocusOrdinal * FLOWCHAT_TURN_RAIL_ROW_HEIGHT_PX, + clientHeight: FLOWCHAT_TURN_RAIL_ROW_HEIGHT_PX, + })); const visibleTurnIdSet = useMemo(() => new Set(visibleTurnIds), [visibleTurnIds]); + const renderedRange = useMemo(() => getFlowChatTurnRailWindowRange({ + ...viewportMetrics, + totalOrdinalCount, + }), [totalOrdinalCount, viewportMetrics]); + const renderedTurns = useMemo(() => { + const windowTurns: FlowChatTurnRailItem[] = []; + for ( + let ordinal = renderedRange.startOrdinal; + ordinal < renderedRange.endOrdinalExclusive; + ordinal += 1 + ) { + const turn = turnByOrdinal.get(ordinal); + if (turn) { + windowTurns.push(turn); + } + } + return windowTurns; + }, [renderedRange.endOrdinalExclusive, renderedRange.startOrdinal, turnByOrdinal]); + const tabStopOrdinal = focusOrdinal !== null + && focusOrdinal >= renderedRange.startOrdinal + && focusOrdinal < renderedRange.endOrdinalExclusive + && turnByOrdinal.has(focusOrdinal) + ? focusOrdinal + : renderedTurns[0]?.ordinal ?? null; + + const updateViewportMetrics = useCallback((list: HTMLDivElement) => { + if (list.clientHeight <= 0) { + return; + } + const nextMetrics = { + scrollTop: list.scrollTop, + clientHeight: list.clientHeight, + }; + setViewportMetrics(previous => ( + previous.scrollTop === nextMetrics.scrollTop + && previous.clientHeight === nextMetrics.clientHeight + ? previous + : nextMetrics + )); + }, []); + + const scrollOrdinalIntoView = useCallback((ordinal: number) => { + const list = listRef.current; + if (!list || list.clientHeight <= 0) { + return; + } + + const nextScrollTop = getFlowChatTurnRailScrollTopForOrdinal({ + ordinal, + currentScrollTop: list.scrollTop, + clientHeight: list.clientHeight, + totalOrdinalCount, + }); + if (nextScrollTop !== list.scrollTop) { + list.scrollTop = nextScrollTop; + } + updateViewportMetrics(list); + }, [totalOrdinalCount, updateViewportMetrics]); useEffect(() => { - const focusTurnStillExists = focusTurnId !== null && turns.some(turn => turn.turnId === focusTurnId); - if (!focusTurnStillExists) { - setFocusTurnId(currentTurnId ?? turns[0]?.turnId ?? null); + const focusOrdinalStillExists = focusOrdinal !== null && turnByOrdinal.has(focusOrdinal); + if (!focusOrdinalStillExists) { + setFocusOrdinal(currentTurnOrdinal ?? turns[0]?.ordinal ?? null); return; } if ( - currentTurnId && - railRef.current && - !railRef.current.contains(document.activeElement) + currentTurnOrdinal !== null + && railRef.current + && !railRef.current.contains(document.activeElement) ) { - setFocusTurnId(currentTurnId); + setFocusOrdinal(currentTurnOrdinal); } - }, [currentTurnId, focusTurnId, turns]); + }, [currentTurnOrdinal, focusOrdinal, turnByOrdinal, turns]); const keepCurrentTurnVisible = useCallback(() => { - if (!currentTurnId) return; - - const list = listRef.current; - const activeItem = itemRefs.current.get(currentTurnId); - if (!list || !activeItem || list.clientHeight <= 0) return; - - const itemTop = activeItem.offsetTop; - const itemBottom = itemTop + activeItem.offsetHeight; - const visibleTop = list.scrollTop; - const visibleBottom = visibleTop + list.clientHeight; - - if (itemTop < visibleTop) { - list.scrollTop = itemTop; - } else if (itemBottom > visibleBottom) { - list.scrollTop = itemBottom - list.clientHeight; + if (currentTurnOrdinal === null) { + return; } - }, [currentTurnId]); + scrollOrdinalIntoView(currentTurnOrdinal); + }, [currentTurnOrdinal, scrollOrdinalIntoView]); useLayoutEffect(() => { keepCurrentTurnVisible(); @@ -75,16 +157,40 @@ export const FlowChatTurnRail: React.FC = ({ const list = listRef.current; if (!list) return; - return observeElementResize(list, keepCurrentTurnVisible); - }, [keepCurrentTurnVisible]); + updateViewportMetrics(list); + return observeElementResize(list, () => { + keepCurrentTurnVisible(); + updateViewportMetrics(list); + }); + }, [keepCurrentTurnVisible, updateViewportMetrics]); const focusTurnAt = useCallback((index: number) => { const turn = turns[index]; if (!turn) return; - setFocusTurnId(turn.turnId); - itemRefs.current.get(turn.turnId)?.focus(); - }, [turns]); + pendingFocusOrdinalRef.current = turn.ordinal; + setFocusOrdinal(turn.ordinal); + scrollOrdinalIntoView(turn.ordinal); + const renderedItem = itemRefs.current.get(turn.ordinal); + if (renderedItem) { + renderedItem.focus(); + pendingFocusOrdinalRef.current = null; + } + }, [scrollOrdinalIntoView, turns]); + + useLayoutEffect(() => { + const pendingFocusOrdinal = pendingFocusOrdinalRef.current; + if (pendingFocusOrdinal === null || pendingFocusOrdinal !== focusOrdinal) { + return; + } + + const item = itemRefs.current.get(pendingFocusOrdinal); + if (!item) { + return; + } + item.focus(); + pendingFocusOrdinalRef.current = null; + }, [focusOrdinal, renderedRange.endOrdinalExclusive, renderedRange.startOrdinal]); const handleKeyDown = useCallback((event: React.KeyboardEvent, index: number) => { let nextIndex: number | null = null; @@ -111,6 +217,13 @@ export const FlowChatTurnRail: React.FC = ({ focusTurnAt(nextIndex); }, [focusTurnAt, turns.length]); + const handleScroll = useCallback(() => { + const list = listRef.current; + if (list) { + updateViewportMetrics(list); + } + }, [updateViewportMetrics]); + if (turns.length === 0) return null; const navigationLabel = t('flowChatTurnRail.label'); @@ -122,51 +235,77 @@ export const FlowChatTurnRail: React.FC = ({ className="flowchat-turn-rail" aria-label={navigationLabel} data-testid="flowchat-turn-rail" + data-rendered-start-ordinal={renderedRange.startOrdinal} + data-rendered-end-ordinal={renderedRange.endOrdinalExclusive} + data-total-turn-count={totalOrdinalCount} > -
- {turns.map((turn, index) => { - const isCurrent = turn.turnId === currentTurnId; - const isVisible = visibleTurnIdSet.has(turn.turnId); - const turnLabel = t('flowChatHeader.turnBadge', { current: turn.turnIndex }); - const content = turn.content.trim() || untitledTurnLabel; - - return ( - - {turnLabel} - {content} - - )} - > - - - ); - })} + + + ); + })} +
); diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.test.ts b/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.test.ts index 513cfc0e17..a5a68e37d4 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.test.ts +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.test.ts @@ -201,6 +201,36 @@ describe('FlowChatViewportCoordinator', () => { expect(coordinator.getMode()).toBe('idle'); }); + it('does not let a stale element-anchor lease release a newer preservation transaction', () => { + const scroller = document.createElement('div'); + scroller.dataset.virtuosoScroller = 'true'; + const firstCard = document.createElement('div'); + const secondCard = document.createElement('div'); + scroller.append(firstCard, secondCard); + document.body.append(scroller); + setScrollerGeometry(scroller, 900); + setRect(scroller, 0); + setRect(firstCard, 120); + setRect(secondCard, 180); + + const coordinator = new FlowChatViewportCoordinator(); + const firstLease = coordinator.preserveElementWithLease(firstCard); + const secondLease = coordinator.preserveElementWithLease(secondCard); + + expect(firstLease).not.toBeNull(); + expect(secondLease).not.toBeNull(); + expect(coordinator.releaseElementPreservationLease( + firstLease!, + 'stale-request-finished', + )).toBe(false); + expect(coordinator.ownsElementAnchor()).toBe(true); + expect(coordinator.releaseElementPreservationLease( + secondLease!, + 'current-request-finished', + )).toBe(true); + expect(coordinator.getMode()).toBe('idle'); + }); + it('keeps a pinned item anchored until follow mode takes ownership', () => { const scroller = document.createElement('div'); scroller.dataset.virtuosoScroller = 'true'; @@ -224,6 +254,27 @@ describe('FlowChatViewportCoordinator', () => { expect(coordinator.restoreElementAnchor(scroller)).toBe(false); }); + it('retains logical pin ownership while Virtuoso rematerializes a disconnected item', () => { + const scroller = document.createElement('div'); + scroller.dataset.virtuosoScroller = 'true'; + const item = document.createElement('div'); + scroller.append(item); + document.body.append(scroller); + setScrollerGeometry(scroller, 700); + setRect(scroller, 0); + setRect(item, 57); + + const coordinator = new FlowChatViewportCoordinator(); + expect(coordinator.pinElement(item)).toBe(true); + + item.remove(); + expect(coordinator.ownsElementAnchor()).toBe(false); + expect(coordinator.getMode()).toBe('pinned-item'); + + coordinator.release('test-cleanup'); + expect(coordinator.getMode()).toBe('idle'); + }); + it('does not let a tool-card collapse replace an active pinned-item anchor', () => { const scroller = document.createElement('div'); scroller.dataset.virtuosoScroller = 'true'; diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts b/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts index 04acb89a51..32e4589cd8 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts @@ -14,11 +14,14 @@ export interface FlowChatViewportRangeHost { }): boolean; } +export type FlowChatElementAnchorLease = number; + type ElementAnchor = { element: HTMLElement; scroller: HTMLElement; offsetFromScrollerTop: number; preservationPhase: 'active' | 'retained' | null; + lease: FlowChatElementAnchorLease; }; type PendingElementAnchorRestore = { @@ -76,6 +79,7 @@ export class FlowChatViewportCoordinator { private anchorGuardFrame: number | null = null; private pendingElementAnchorRestore: PendingElementAnchorRestore | null = null; private rangeHost: FlowChatViewportRangeHost | null = null; + private nextElementAnchorLease = 0; setRangeHost(host: FlowChatViewportRangeHost | null): void { this.rangeHost = host; @@ -110,7 +114,7 @@ export class FlowChatViewportCoordinator { } pinElement(element: HTMLElement | null | undefined): boolean { - return this.captureElement(element, 'pinned-item'); + return this.captureElement(element, 'pinned-item') !== null; } followTail(options?: { force?: boolean }): boolean { @@ -151,6 +155,12 @@ export class FlowChatViewportCoordinator { } preserveElement(element: HTMLElement | null | undefined): boolean { + return this.preserveElementWithLease(element) !== null; + } + + preserveElementWithLease( + element: HTMLElement | null | undefined, + ): FlowChatElementAnchorLease | null { this.validateElementAnchor('preserve-element'); if (!element || this.mode === 'following-tail' || this.mode === 'pinned-item') { if (flowChatDiagnostics.isEnabled()) { @@ -161,7 +171,7 @@ export class FlowChatViewportCoordinator { data: () => ({ hasElement: Boolean(element), mode: this.mode }), }); } - return false; + return null; } return this.captureElement( @@ -170,6 +180,22 @@ export class FlowChatViewportCoordinator { ); } + releaseElementPreservationLease( + lease: FlowChatElementAnchorLease, + reason = 'unspecified', + ): boolean { + this.validateElementAnchor(`release-element-preservation-lease:${reason}`); + if ( + this.mode !== 'preserving-element' + || this.elementAnchor?.lease !== lease + ) { + return false; + } + + this.release(reason); + return true; + } + settleElementPreservation(source = 'unspecified'): boolean { this.validateElementAnchor('settle-element-preservation'); const anchor = this.elementAnchor; @@ -195,9 +221,9 @@ export class FlowChatViewportCoordinator { private captureElement( element: HTMLElement | null | undefined, mode: 'pinned-item' | 'preserving-element', - ): boolean { + ): FlowChatElementAnchorLease | null { if (!element) { - return false; + return null; } const scroller = element.closest('[data-virtuoso-scroller="true"]'); @@ -210,17 +236,19 @@ export class FlowChatViewportCoordinator { data: () => ({ mode }), }); } - return false; + return null; } const elementRect = element.getBoundingClientRect(); const scrollerRect = scroller.getBoundingClientRect(); + const lease = ++this.nextElementAnchorLease; this.cancelElementAnchorRestoreWork(); this.elementAnchor = { element, scroller, offsetFromScrollerTop: elementRect.top - scrollerRect.top, preservationPhase: mode === 'preserving-element' ? 'active' : null, + lease, }; this.mode = mode; this.startAnchorGuard(); @@ -232,6 +260,7 @@ export class FlowChatViewportCoordinator { data: () => ({ mode, preservationPhase: this.elementAnchor?.preservationPhase ?? null, + lease, elementConnected: element.isConnected, offsetFromScrollerTop: this.elementAnchor?.offsetFromScrollerTop ?? null, scrollTop: scroller.scrollTop, @@ -240,7 +269,7 @@ export class FlowChatViewportCoordinator { }), }); } - return true; + return lease; } restoreElementAnchor(scroller: HTMLElement, source = 'external'): boolean { @@ -385,6 +414,11 @@ export class FlowChatViewportCoordinator { private validateElementAnchor(source: string): void { const anchor = this.elementAnchor; if (anchor && (!anchor.element.isConnected || !anchor.scroller.isConnected)) { + if (this.mode === 'pinned-item' && anchor.scroller.isConnected) { + this.cancelElementAnchorRestoreWork(); + this.elementAnchor = null; + return; + } this.release(`element-anchor-disconnected:${source}`); } } 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 9c3b1f0305..0f2e7e2613 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 @@ -4,6 +4,7 @@ import React, { act } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createRoot, type Root } from 'react-dom/client'; import { ModernFlowChatContainer } from './ModernFlowChatContainer'; +import type { HistoryWindowBoundaryIntentResult } from './VirtualMessageList'; import type { Session } from '../../types/flow-chat'; import { flowChatStore } from '../../store/FlowChatStore'; import { @@ -11,6 +12,7 @@ import { dispatchHistorySessionOpenIntent, HISTORY_SESSION_OPEN_INTENT_EVENT, } from '../../services/sessionOpenIntent'; +import { FLOWCHAT_TURN_RAIL_ROW_HEIGHT_PX } from './flowChatTurnRailWindow'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -33,6 +35,7 @@ const virtualListMock = vi.hoisted(() => ({ isTurnTextRenderedInViewport: vi.fn(() => false), pinTurnToTop: vi.fn(() => true), pinTurnToTopWithStatus: vi.fn(() => 'settled' as const), + prepareTurnPinToTop: vi.fn(() => 'pending' as const), })); const virtualListActionClickMock = vi.hoisted(() => vi.fn()); const startupTraceMock = vi.hoisted(() => ({ @@ -60,6 +63,9 @@ const headerPropsMock = vi.hoisted(() => ({ const virtualListPropsMock = vi.hoisted(() => ({ latest: null as Record | null, })); +const navigationOptionsMock = vi.hoisted(() => ({ + latest: null as Record | null, +})); const agentApiMock = vi.hoisted(() => ({ listBackgroundCommandActivities: vi.fn(() => Promise.resolve({ activities: [] })), onPermissionRequestEvent: vi.fn(() => vi.fn()), @@ -123,6 +129,11 @@ vi.mock('../../utils/acpSession', () => ({ })); vi.mock('../../store/modernFlowChatStore', () => ({ + sessionToVirtualItems: (session: Session | null) => (session?.dialogTurns ?? []).map(turn => ({ + type: 'user-message', + turnId: turn.id, + data: turn.userMessage, + })), useVirtualItems: () => stateMocks.virtualItems, useActiveSession: () => stateMocks.activeSession, useVisibleTurnInfo: () => stateMocks.visibleTurnInfo, @@ -177,7 +188,9 @@ vi.mock('./useFlowChatFileActions', () => ({ })); vi.mock('./useFlowChatNavigation', () => ({ - useFlowChatNavigation: vi.fn(), + useFlowChatNavigation: (options: Record) => { + navigationOptionsMock.latest = options; + }, })); vi.mock('./useFlowChatCopyDialog', () => ({ @@ -249,11 +262,28 @@ function clickTurnRailItem(container: HTMLElement, turnId: string) { }); } +function scrollTurnRailToOrdinal( + container: HTMLElement, + ordinal: number, + clientHeight = FLOWCHAT_TURN_RAIL_ROW_HEIGHT_PX * 5, +) { + const list = container.querySelector('.flowchat-turn-rail__list'); + expect(list).not.toBeNull(); + if (!list) return; + + Object.defineProperty(list, 'clientHeight', { configurable: true, value: clientHeight }); + list.scrollTop = ordinal * FLOWCHAT_TURN_RAIL_ROW_HEIGHT_PX; + act(() => { + list.dispatchEvent(new Event('scroll', { bubbles: true })); + }); +} + describe('ModernFlowChatContainer historical empty state', () => { let container: HTMLDivElement; let root: Root; beforeEach(() => { + vi.restoreAllMocks(); rafCallbacks = []; vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => { rafCallbacks.push(callback); @@ -293,6 +323,8 @@ describe('ModernFlowChatContainer historical empty state', () => { virtualListMock.pinTurnToTop.mockReturnValue(true); virtualListMock.pinTurnToTopWithStatus.mockReset(); virtualListMock.pinTurnToTopWithStatus.mockReturnValue('settled'); + virtualListMock.prepareTurnPinToTop.mockReset(); + virtualListMock.prepareTurnPinToTop.mockReturnValue('pending'); virtualListActionClickMock.mockReset(); startupTraceMock.markPhase.mockReset(); historySessionDiagnosticsMock.beginHistorySessionDiagnostics.mockReset(); @@ -312,6 +344,7 @@ describe('ModernFlowChatContainer historical empty state', () => { searchStateMock.clearSearch.mockReset(); headerPropsMock.latest = null; virtualListPropsMock.latest = null; + navigationOptionsMock.latest = null; clearHistorySessionOpenTransition(); }); @@ -339,6 +372,34 @@ describe('ModernFlowChatContainer historical empty state', () => { expect(container.querySelector('[data-testid="welcome-panel"]')).toBeNull(); }); + it('defers viewport anchoring while the host scene is inactive', () => { + const turn = createTurn('turn-1', 'One'); + stateMocks.activeSession = createSession({ + dialogTurns: [turn], + historyState: 'ready', + contextRestoreState: 'ready', + }); + stateMocks.virtualItems = [{ + type: 'user-message', + turnId: turn.id, + data: turn.userMessage, + }]; + + act(() => { + root.render(); + }); + + expect(virtualListPropsMock.latest).toMatchObject({ isViewportActive: false }); + expect(virtualListMock.scrollToTurnEndAndClearPin).not.toHaveBeenCalled(); + + act(() => { + root.render(); + }); + + expect(virtualListPropsMock.latest).toMatchObject({ isViewportActive: true }); + expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenCalledWith(turn.id); + }); + it('keeps the loading shell while historical sessions are hydrating', () => { stateMocks.activeSession = createSession({ historyState: 'hydrating' } as Partial); @@ -828,19 +889,16 @@ describe('ModernFlowChatContainer historical empty state', () => { releaseSpy.mockRestore(); }); - it('keeps partial-session search scoped to the loaded window', async () => { - const pendingSpy = vi - .spyOn(flowChatStore, 'hasPendingSessionHistoryCompletion') - .mockReturnValue(true); - const projectionSpy = vi - .spyOn(flowChatStore, 'requestSessionFullHistoryProjection') - .mockReturnValue(true); + it('requests full history when search starts from a partial session', async () => { + const ensureSpy = vi + .spyOn(flowChatStore, 'ensureSessionFullHistory') + .mockResolvedValue(true); - searchStateMock.searchQuery = 'older prompt'; stateMocks.activeSession = createSession({ isHistorical: false, historyState: 'ready', contextRestoreState: 'ready', + isPartial: true, dialogTurns: [ createTurn('turn-2', 'Latest restored prompt'), ], @@ -854,14 +912,15 @@ describe('ModernFlowChatContainer historical empty state', () => { root.render(); }); - expect(projectionSpy).not.toHaveBeenCalled(); - expect(startupTraceMock.markPhase).not.toHaveBeenCalledWith( - 'historical_session_full_hydrate_released_for_search', - expect.anything(), - ); + await act(async () => { + (headerPropsMock.latest?.onSearchChange as ((query: string) => void) | undefined)?.( + 'older prompt', + ); + await Promise.resolve(); + }); - projectionSpy.mockRestore(); - pendingSpy.mockRestore(); + expect(searchStateMock.onSearchChange).toHaveBeenCalledWith('older prompt'); + expect(ensureSpy).toHaveBeenCalledWith('session-1', 'flowchat-search'); }); it('repositions an unchanged virtual match when the search query changes', async () => { @@ -987,12 +1046,360 @@ describe('ModernFlowChatContainer historical empty state', () => { clickTurnRailItem(container, 'turn-99'); expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-99', { - behavior: 'smooth', + behavior: 'auto', pinMode: 'transient', + alignmentPolicy: 'best-effort', }); }); + it('treats the latest streaming Turn marker as transient immediate navigation', async () => { + const streamingTurn = { + ...createTurn('turn-2', 'Streaming prompt', 'processing'), + modelRounds: [{ + id: 'round-2', + index: 0, + items: [{ + id: 'text-2', + type: 'text' as const, + content: 'Streaming output', + isStreaming: true, + timestamp: 1, + status: 'streaming' as const, + }], + isStreaming: true, + isComplete: false, + status: 'streaming' as const, + startTime: 1, + }], + } as Session['dialogTurns'][number]; + stateMocks.activeSession = createSession({ + isHistorical: false, + historyState: 'ready', + dialogTurns: [ + createTurn('turn-1', 'Older prompt'), + streamingTurn, + ], + } as Partial); + stateMocks.virtualItems = [ + { type: 'user-message', turnId: 'turn-1', data: { id: 'user-turn-1', content: 'Older prompt' } }, + { type: 'user-message', turnId: 'turn-2', data: { id: 'user-turn-2', content: 'Streaming prompt' } }, + ]; + stateMocks.visibleTurnInfo = { + turnId: 'turn-1', + turnIndex: 1, + totalTurns: 2, + userMessage: 'Older prompt', + }; + + await act(async () => { + root.render(); + }); + + const restoreTailSpy = vi.spyOn(flowChatStore, 'restoreSessionTailPresentation'); + const latestEndCallCount = virtualListMock.scrollToLatestEndPosition.mock.calls.length; + clickTurnRailItem(container, 'turn-2'); + + expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-2', { + behavior: 'auto', + pinMode: 'transient', + alignmentPolicy: 'best-effort', + }); + expect(restoreTailSpy).not.toHaveBeenCalled(); + expect(virtualListMock.scrollToLatestEndPosition.mock.calls.length).toBe(latestEndCallCount); + + restoreTailSpy.mockRestore(); + }); + + it('keeps an active history presentation when its latest Turn marker is selected', async () => { + const presentationTurns = Array.from( + { length: 8 }, + (_, index) => createTurn(`turn-${index + 3}`, `Prompt ${index + 3}`), + ); + const catalog = { + schemaVersion: 1, + sessionId: 'session-1', + revision: 'catalog-v1', + totalTurnCount: 10, + complete: true, + entries: Array.from({ length: 10 }, (_, ordinal) => ({ + ordinal, + storageTurnIndex: ordinal, + turnId: `turn-${ordinal + 1}`, + preview: `Prompt ${ordinal + 1}`, + previewTruncated: false, + })), + }; + const loadSpy = vi.spyOn(flowChatStore, 'loadSessionTurnWindow').mockResolvedValue({ + status: 'ready', + sessionId: 'session-1', + targetOrdinal: 4, + targetTurnId: 'turn-5', + navigationGeneration: 7, + isCurrent: true, + cacheHit: true, + catalog, + range: { + startOrdinal: 2, + endOrdinalExclusive: 10, + turns: presentationTurns, + lastAccessedAt: 1, + source: 'target', + }, + }); + const activateSpy = vi.spyOn(flowChatStore, 'activateSessionHistoryWindow').mockReturnValue({ + range: { + startOrdinal: 2, + endOrdinalExclusive: 10, + targetTurnId: 'turn-5', + mode: 'history-window', + }, + turns: presentationTurns, + }); + stateMocks.activeSession = createSession({ + historyState: 'ready', + isPartial: true, + totalTurnCount: 10, + turnCatalog: catalog, + dialogTurns: [ + createTurn('turn-9', 'Recent prompt'), + createTurn('turn-10', 'Latest prompt'), + ], + } as Partial); + stateMocks.virtualItems = stateMocks.activeSession.dialogTurns.map(turn => ({ + type: 'user-message', + turnId: turn.id, + data: turn.userMessage, + })); + + await act(async () => { + root.render(); + }); + await act(async () => { + container.querySelector('[data-turn-id="turn-5"]')?.click(); + await Promise.resolve(); + }); + expect(virtualListPropsMock.latest).toMatchObject({ presentationMode: 'history-window' }); + + const restoreTailSpy = vi.spyOn(flowChatStore, 'restoreSessionTailPresentation'); + const latestEndCallCount = virtualListMock.scrollToLatestEndPosition.mock.calls.length; + scrollTurnRailToOrdinal(container, 9); + clickTurnRailItem(container, 'turn-10'); + + expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-10', { + behavior: 'auto', + pinMode: 'transient', + alignmentPolicy: 'best-effort', + }); + expect(restoreTailSpy).not.toHaveBeenCalled(); + expect(virtualListPropsMock.latest).toMatchObject({ presentationMode: 'history-window' }); + expect(virtualListMock.scrollToLatestEndPosition.mock.calls.length).toBe(latestEndCallCount); + + stateMocks.activeSession = { + ...stateMocks.activeSession, + dialogTurns: [ + createTurn('turn-9', 'Recent prompt'), + createTurn('turn-10', 'Latest live update', 'processing'), + ], + }; + stateMocks.virtualItems = stateMocks.activeSession.dialogTurns.map(turn => ({ + type: 'user-message', + turnId: turn.id, + data: turn.userMessage, + })); + await act(async () => { + root.render(); + }); + const liveLatestItem = (virtualListPropsMock.latest?.items as Array<{ + type: string; + turnId?: string; + data?: { content?: string }; + }>).find(item => item.type === 'user-message' && item.turnId === 'turn-10'); + expect(liveLatestItem?.data?.content).toBe('Latest live update'); + expect(virtualListPropsMock.latest).toMatchObject({ presentationMode: 'history-window' }); + + await act(async () => { + (virtualListPropsMock.latest?.onRequestJumpToLatest as (() => void) | undefined)?.(); + }); + flushAnimationFrame(); + expect(restoreTailSpy).toHaveBeenCalledOnce(); + expect(restoreTailSpy).toHaveBeenLastCalledWith('session-1'); + expect(virtualListPropsMock.latest).toMatchObject({ presentationMode: 'tail' }); + expect(virtualListMock.scrollToLatestEndPosition.mock.calls.length).toBe(latestEndCallCount + 1); + + const tailAnchorCallCount = virtualListMock.scrollToTurnEndAndClearPin.mock.calls.length; + stateMocks.activeSession = { + ...stateMocks.activeSession, + totalTurnCount: 11, + dialogTurns: [ + ...stateMocks.activeSession.dialogTurns, + createTurn('turn-11', 'New completed prompt'), + ], + }; + stateMocks.virtualItems = stateMocks.activeSession.dialogTurns.map(turn => ({ + type: 'user-message', + turnId: turn.id, + data: turn.userMessage, + })); + await act(async () => { + root.render(); + }); + flushAnimationFrame(); + expect(virtualListPropsMock.latest).toMatchObject({ presentationMode: 'tail' }); + expect(virtualListMock.scrollToTurnEndAndClearPin.mock.calls.length).toBe(tailAnchorCallCount + 1); + expect(virtualListMock.scrollToTurnEndAndClearPin).toHaveBeenLastCalledWith('turn-11'); + + const reactivateSpy = vi.spyOn(flowChatStore, 'reactivateSessionHistoryWindow').mockReturnValue({ + range: { + startOrdinal: 2, + endOrdinalExclusive: 10, + targetTurnId: 'turn-5', + mode: 'history-window', + }, + turns: presentationTurns, + }); + scrollTurnRailToOrdinal(container, 4); + clickTurnRailItem(container, 'turn-5'); + expect(reactivateSpy).toHaveBeenCalledWith('session-1', { + startOrdinal: 2, + endOrdinalExclusive: 10, + targetTurnId: 'turn-5', + mode: 'history-window', + }); + expect(loadSpy).toHaveBeenCalledTimes(1); + expect(virtualListPropsMock.latest).toMatchObject({ presentationMode: 'history-window' }); + + reactivateSpy.mockRestore(); + restoreTailSpy.mockRestore(); + loadSpy.mockRestore(); + activateSpy.mockRestore(); + }); + + it('retains a complete small history projection when jumping to the latest Turn', async () => { + const presentationTurns = Array.from( + { length: 10 }, + (_, index) => createTurn(`turn-${index + 1}`, `Prompt ${index + 1}`), + ); + const catalog = { + schemaVersion: 1, + sessionId: 'session-1', + revision: 'catalog-complete-v1', + totalTurnCount: 10, + complete: true, + entries: Array.from({ length: 10 }, (_, ordinal) => ({ + ordinal, + storageTurnIndex: ordinal, + turnId: `turn-${ordinal + 1}`, + preview: `Prompt ${ordinal + 1}`, + previewTruncated: false, + })), + }; + const loadSpy = vi.spyOn(flowChatStore, 'loadSessionTurnWindow').mockResolvedValue({ + status: 'ready', + sessionId: 'session-1', + targetOrdinal: 4, + targetTurnId: 'turn-5', + navigationGeneration: 8, + isCurrent: true, + cacheHit: true, + catalog, + range: { + startOrdinal: 0, + endOrdinalExclusive: 10, + turns: presentationTurns, + lastAccessedAt: 1, + source: 'target', + }, + }); + const activateSpy = vi.spyOn(flowChatStore, 'activateSessionHistoryWindow').mockReturnValue({ + range: { + startOrdinal: 0, + endOrdinalExclusive: 10, + targetTurnId: 'turn-5', + mode: 'history-window', + }, + turns: presentationTurns, + }); + stateMocks.activeSession = createSession({ + historyState: 'ready', + isPartial: true, + totalTurnCount: 10, + turnCatalog: catalog, + dialogTurns: [ + createTurn('turn-9', 'Recent prompt'), + createTurn('turn-10', 'Latest prompt'), + ], + } as Partial); + stateMocks.virtualItems = stateMocks.activeSession.dialogTurns.map(turn => ({ + type: 'user-message', + turnId: turn.id, + data: turn.userMessage, + })); + + await act(async () => { + root.render(); + }); + await act(async () => { + container.querySelector('[data-turn-id="turn-5"]')?.click(); + await Promise.resolve(); + }); + + expect(loadSpy).toHaveBeenCalledWith('session-1', 4, { source: 'target' }); + expect(virtualListPropsMock.latest).toMatchObject({ + presentationMode: 'history-window', + viewportMode: 'history-reading', + }); + expect((virtualListPropsMock.latest?.items as Array<{ turnId: string }>).map(item => item.turnId)) + .toEqual(presentationTurns.map(turn => turn.id)); + + const restoreTailSpy = vi.spyOn(flowChatStore, 'restoreSessionTailPresentation'); + const initialItems = virtualListPropsMock.latest?.items; + await act(async () => { + (virtualListPropsMock.latest?.onRequestJumpToLatest as (() => void) | undefined)?.(); + }); + + expect(restoreTailSpy).not.toHaveBeenCalled(); + expect(virtualListPropsMock.latest).toMatchObject({ + presentationMode: 'history-window', + viewportMode: 'live-tail', + historyWindow: null, + }); + expect(virtualListPropsMock.latest?.items).toBe(initialItems); + + stateMocks.activeSession = { + ...stateMocks.activeSession, + totalTurnCount: 11, + dialogTurns: [ + createTurn('turn-10', 'Latest live update', 'processing'), + createTurn('turn-11', 'New live prompt', 'processing'), + ], + }; + stateMocks.virtualItems = stateMocks.activeSession.dialogTurns.map(turn => ({ + type: 'user-message', + turnId: turn.id, + data: turn.userMessage, + })); + await act(async () => { + root.render(); + }); + + expect(virtualListPropsMock.latest).toMatchObject({ + presentationMode: 'history-window', + viewportMode: 'live-tail', + }); + expect((virtualListPropsMock.latest?.items as Array<{ turnId: string }>).map(item => item.turnId)) + .toEqual([...presentationTurns.map(turn => turn.id), 'turn-11']); + const liveLatestItem = (virtualListPropsMock.latest?.items as Array<{ + turnId: string; + data?: { content?: string }; + }>).find(item => item.turnId === 'turn-10'); + expect(liveLatestItem?.data?.content).toBe('Latest live update'); + + restoreTailSpy.mockRestore(); + loadSpy.mockRestore(); + activateSpy.mockRestore(); + }); + it('retries turn-rail selection without advancing visible-turn state until the virtual list accepts it', async () => { stateMocks.activeSession = createSession({ isHistorical: false, @@ -1025,8 +1432,9 @@ describe('ModernFlowChatContainer historical empty state', () => { clickTurnRailItem(container, 'turn-1'); expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-1', { - behavior: 'smooth', + behavior: 'auto', pinMode: 'transient', + alignmentPolicy: 'best-effort', }); expect(headerPropsMock.latest).toMatchObject({ currentTurn: 2, @@ -1046,6 +1454,7 @@ describe('ModernFlowChatContainer historical empty state', () => { expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-1', { behavior: 'auto', pinMode: 'transient', + alignmentPolicy: 'best-effort', }); expect(headerPropsMock.latest).toMatchObject({ currentTurn: 2, @@ -1096,8 +1505,9 @@ describe('ModernFlowChatContainer historical empty state', () => { clickTurnRailItem(container, 'turn-1'); expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-1', { - behavior: 'smooth', + behavior: 'auto', pinMode: 'transient', + alignmentPolicy: 'best-effort', }); expect(headerPropsMock.latest).toMatchObject({ currentTurn: 2, @@ -1155,8 +1565,9 @@ describe('ModernFlowChatContainer historical empty state', () => { clickTurnRailItem(container, 'turn-1'); expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-1', { - behavior: 'smooth', + behavior: 'auto', pinMode: 'transient', + alignmentPolicy: 'best-effort', }); const pendingCallCount = virtualListMock.pinTurnToTopWithStatus.mock.calls.length; flushAnimationFrame(); @@ -1224,14 +1635,20 @@ describe('ModernFlowChatContainer historical empty state', () => { currentTurn: 25, totalTurns: 25, }); - expect(container.querySelectorAll('.flowchat-turn-rail__item')).toHaveLength(25); + expect(container.querySelector('[data-testid="flowchat-turn-rail"]')?.getAttribute( + 'data-total-turn-count', + )).toBe('25'); + expect(container.querySelectorAll('.flowchat-turn-rail__item').length).toBeLessThan(25); + + scrollTurnRailToOrdinal(container, 6); const beforeSelectionCallCount = virtualListMock.pinTurnToTopWithStatus.mock.calls.length; clickTurnRailItem(container, 'turn-7'); expect(virtualListMock.pinTurnToTopWithStatus.mock.calls.length).toBe(beforeSelectionCallCount + 1); expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-7', { - behavior: 'smooth', + behavior: 'auto', pinMode: 'transient', + alignmentPolicy: 'best-effort', }); flushAnimationFrame(); @@ -1287,7 +1704,7 @@ describe('ModernFlowChatContainer historical empty state', () => { expect(virtualListMock.pinTurnToTopWithStatus.mock.calls.length).toBe(retryCallCount); }); - it('does not synthesize unloaded turn-rail targets in partial history', async () => { + it('renders ordinal navigation placeholders for old hosts without a turn catalog', async () => { stateMocks.activeSession = createSession({ isHistorical: false, historyState: 'ready', @@ -1318,11 +1735,439 @@ describe('ModernFlowChatContainer historical empty state', () => { currentTurn: 99, totalTurns: 100, }); - expect(container.querySelectorAll('.flowchat-turn-rail__item')).toHaveLength(2); + expect(container.querySelector('[data-testid="flowchat-turn-rail"]')?.getAttribute( + 'data-total-turn-count', + )).toBe('100'); + expect(container.querySelectorAll('.flowchat-turn-rail__item').length).toBeLessThan(100); + expect(container.querySelector('[data-turn-key="storage:0"]')).toBeNull(); expect(container.querySelector('[data-turn-id="turn-98"]')).toBeNull(); + expect(container.querySelector('[data-turn-id="turn-99"]')?.getAttribute('aria-disabled')).toBeNull(); + expect(container.querySelector('[data-turn-id="turn-100"]')?.getAttribute('aria-disabled')).toBeNull(); + + scrollTurnRailToOrdinal(container, 0); + + expect(container.querySelector('[data-turn-key="storage:0"]')?.getAttribute('aria-disabled')).toBeNull(); + expect(container.querySelector('[data-turn-id="turn-99"]')).toBeNull(); expect(virtualListMock.pinTurnToTopWithStatus).not.toHaveBeenCalled(); }); + it('windows catalog markers while resolving loaded tail identities', async () => { + stateMocks.activeSession = createSession({ + isHistorical: false, + historyState: 'ready', + isPartial: true, + loadedTurnCount: 2, + totalTurnCount: 100, + turnCatalog: { + schemaVersion: 1, + sessionId: 'session-1', + revision: 'catalog-1', + totalTurnCount: 100, + complete: false, + entries: Array.from({ length: 100 }, (_, ordinal) => ({ + ordinal, + storageTurnIndex: ordinal, + ...(ordinal === 98 + ? { turnId: 'turn-99', preview: 'Stale catalog preview' } + : ordinal === 99 + ? { turnId: 'turn-100', preview: 'Latest catalog preview' } + : {}), + previewTruncated: false, + })), + }, + 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', + visibleTurnIds: ['turn-99', 'turn-100'], + }; + + await act(async () => { + root.render(); + }); + + expect(container.querySelector('[data-testid="flowchat-turn-rail"]')?.getAttribute( + 'data-total-turn-count', + )).toBe('100'); + expect(container.querySelectorAll('.flowchat-turn-rail__item').length).toBeLessThan(100); + expect(container.querySelector('[data-turn-key="storage:0"]')).toBeNull(); + expect(container.querySelector('[data-turn-id="turn-99"]')?.getAttribute('aria-disabled')).toBeNull(); + expect(container.querySelector('[data-turn-id="turn-100"]')?.getAttribute('aria-disabled')).toBeNull(); + + scrollTurnRailToOrdinal(container, 0); + + expect(container.querySelector('[data-turn-key="storage:0"]')?.getAttribute('aria-disabled')).toBeNull(); + expect(container.querySelector('[data-turn-id="turn-100"]')).toBeNull(); + }); + + it('requests the unified full-history fallback for an unloaded catalog target', async () => { + const ensureSpy = vi + .spyOn(flowChatStore, 'ensureSessionFullHistory') + .mockResolvedValue(true); + stateMocks.activeSession = createSession({ + isHistorical: false, + historyState: 'ready', + isPartial: true, + loadedTurnCount: 2, + totalTurnCount: 100, + turnCatalog: { + schemaVersion: 1, + sessionId: 'session-1', + revision: 'complete-catalog', + totalTurnCount: 100, + complete: true, + entries: Array.from({ length: 100 }, (_, ordinal) => ({ + ordinal, + storageTurnIndex: ordinal, + turnId: `turn-${ordinal + 1}`, + preview: `Prompt ${ordinal + 1}`, + previewTruncated: false, + })), + }, + 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' } }, + ]; + + await act(async () => { + root.render(); + }); + + await act(async () => { + container.querySelector('[data-turn-id="turn-1"]')?.click(); + await Promise.resolve(); + }); + + expect(ensureSpy).toHaveBeenCalledWith('session-1', 'turn-rail-navigation'); + expect(virtualListMock.pinTurnToTopWithStatus).not.toHaveBeenCalledWith( + 'turn-1', + expect.anything(), + ); + }); + + it('materializes a loaded Turn window for cross-feature focus before reusing the shared pin transaction', async () => { + const targetTurn = createTurn('turn-5', 'Target prompt'); + const loadSpy = vi.spyOn(flowChatStore, 'loadSessionTurnWindow').mockResolvedValue({ + status: 'ready', + sessionId: 'session-1', + targetOrdinal: 4, + targetTurnId: 'turn-5', + navigationGeneration: 7, + isCurrent: true, + cacheHit: false, + range: { + startOrdinal: 2, + endOrdinalExclusive: 7, + turns: Array.from({ length: 5 }, (_, index) => createTurn(`turn-${index + 3}`, `Prompt ${index + 3}`)), + lastAccessedAt: 1, + source: 'target', + }, + }); + const activateSpy = vi.spyOn(flowChatStore, 'activateSessionHistoryWindow').mockReturnValue({ + range: { + startOrdinal: 2, + endOrdinalExclusive: 7, + targetTurnId: targetTurn.id, + mode: 'history-window', + }, + turns: Array.from({ length: 5 }, (_, index) => createTurn(`turn-${index + 3}`, `Prompt ${index + 3}`)), + }); + stateMocks.activeSession = createSession({ + historyState: 'ready', + isPartial: true, + totalTurnCount: 10, + turnCatalog: { + schemaVersion: 1, + sessionId: 'session-1', + revision: 'catalog-v1', + totalTurnCount: 10, + complete: true, + entries: Array.from({ length: 10 }, (_, ordinal) => ({ + ordinal, + storageTurnIndex: ordinal, + turnId: `turn-${ordinal + 1}`, + preview: `Prompt ${ordinal + 1}`, + previewTruncated: false, + })), + }, + dialogTurns: [ + createTurn('turn-9', 'Recent prompt'), + createTurn('turn-10', 'Latest prompt'), + ], + } as Partial); + stateMocks.virtualItems = stateMocks.activeSession.dialogTurns.map(turn => ({ + type: 'user-message', + turnId: turn.id, + data: turn.userMessage, + })); + + await act(async () => { + root.render(); + }); + const target = container.querySelector('[data-turn-id="turn-5"]'); + expect(target).not.toBeNull(); + await act(async () => { + const onNavigateToFocusTurn = navigationOptionsMock.latest?.onNavigateToFocusTurn as ( + request: { + sessionId: string; + turnIndex: number; + source: 'usage-report'; + }, + ) => Promise; + await expect(onNavigateToFocusTurn({ + sessionId: 'session-1', + turnIndex: 5, + source: 'usage-report', + })).resolves.toBe(true); + }); + + expect(loadSpy).toHaveBeenCalledWith('session-1', 4, { source: 'target' }); + expect(virtualListMock.prepareTurnPinToTop).toHaveBeenCalledWith('turn-5', { + behavior: 'auto', + pinMode: 'transient', + alignmentPolicy: 'best-effort', + }); + expect(activateSpy).toHaveBeenCalledWith('session-1', 4, 7); + expect(virtualListPropsMock.latest).toMatchObject({ + presentationMode: 'history-window', + presentationRevision: 1, + }); + expect((virtualListPropsMock.latest?.items as Array<{ turnId: string }>).map(item => item.turnId)).toEqual([ + 'turn-3', + 'turn-4', + 'turn-5', + 'turn-6', + 'turn-7', + ]); + expect(stateMocks.activeSession.dialogTurns.map(turn => turn.id)).toEqual(['turn-9', 'turn-10']); + expect(virtualListMock.prepareTurnPinToTop.mock.invocationCallOrder[0]).toBeLessThan( + activateSpy.mock.invocationCallOrder[0], + ); + + const latestTailPinCallCount = virtualListMock.pinTurnToTop.mock.calls.length; + const latestTailEndCallCount = virtualListMock.scrollToTurnEndAndClearPin.mock.calls.length; + stateMocks.activeSession = { + ...stateMocks.activeSession, + totalTurnCount: 11, + dialogTurns: [ + ...stateMocks.activeSession.dialogTurns, + createTurn('turn-11', 'Streaming prompt', 'processing'), + ], + }; + stateMocks.virtualItems = stateMocks.activeSession.dialogTurns.map(turn => ({ + type: 'user-message', + turnId: turn.id, + data: turn.userMessage, + })); + await act(async () => { + root.render(); + }); + flushAnimationFrame(); + expect(virtualListPropsMock.latest).toMatchObject({ presentationMode: 'history-window' }); + expect(virtualListMock.pinTurnToTop.mock.calls.length).toBe(latestTailPinCallCount); + expect(virtualListMock.scrollToTurnEndAndClearPin.mock.calls.length).toBe(latestTailEndCallCount); + + const restoreTailSpy = vi.spyOn(flowChatStore, 'restoreSessionTailPresentation'); + const latestEndCallCountBeforeSend = virtualListMock.scrollToLatestEndPosition.mock.calls.length; + await act(async () => { + const onBeforeTurnPinRequest = navigationOptionsMock.latest?.onBeforeTurnPinRequest as ( + request: { + sessionId: string; + turnId: string; + source: 'send-message'; + behavior: 'auto'; + pinMode: 'sticky-latest'; + }, + ) => void; + onBeforeTurnPinRequest({ + sessionId: 'session-1', + turnId: 'turn-11', + source: 'send-message', + behavior: 'auto', + pinMode: 'sticky-latest', + }); + }); + expect(restoreTailSpy).toHaveBeenCalledTimes(1); + expect(restoreTailSpy).toHaveBeenLastCalledWith('session-1'); + expect(virtualListPropsMock.latest).toMatchObject({ presentationMode: 'tail' }); + expect((virtualListPropsMock.latest?.items as Array<{ turnId: string }>).map(item => item.turnId)).toEqual([ + 'turn-9', + 'turn-10', + 'turn-11', + ]); + expect(virtualListMock.scrollToLatestEndPosition.mock.calls.length).toBe(latestEndCallCountBeforeSend); + + await act(async () => { + container.querySelector('[data-turn-id="turn-5"]')?.click(); + await Promise.resolve(); + }); + expect(virtualListPropsMock.latest).toMatchObject({ presentationMode: 'history-window' }); + + await act(async () => { + (virtualListPropsMock.latest?.onRequestJumpToLatest as (() => void) | undefined)?.(); + }); + flushAnimationFrame(); + expect(restoreTailSpy).toHaveBeenCalledTimes(2); + expect(restoreTailSpy).toHaveBeenLastCalledWith('session-1'); + expect(virtualListPropsMock.latest).toMatchObject({ presentationMode: 'tail' }); + expect(virtualListMock.scrollToLatestEndPosition.mock.calls.length).toBe(latestEndCallCountBeforeSend + 1); + + restoreTailSpy.mockRestore(); + loadSpy.mockRestore(); + activateSpy.mockRestore(); + }); + + it('materializes an adjacent catalog window when tail history requests older turns', async () => { + const catalog = { + schemaVersion: 1, + sessionId: 'session-1', + revision: 'catalog-1', + totalTurnCount: 10, + complete: true, + entries: Array.from({ length: 10 }, (_, ordinal) => ({ + ordinal, + storageTurnIndex: ordinal, + turnId: `turn-${ordinal + 1}`, + preview: `Prompt ${ordinal + 1}`, + previewTruncated: false, + })), + }; + stateMocks.activeSession = createSession({ + isHistorical: false, + historyState: 'ready', + isPartial: true, + loadedTurnCount: 2, + totalTurnCount: 10, + turnCatalog: catalog, + dialogTurns: [ + createTurn('turn-9', 'Recent prompt'), + createTurn('turn-10', 'Latest prompt'), + ], + } as Partial); + stateMocks.virtualItems = stateMocks.activeSession.dialogTurns.map(turn => ({ + type: 'user-message', + turnId: turn.id, + data: turn.userMessage, + })); + const presentationTurns = Array.from( + { length: 8 }, + (_, index) => createTurn(`turn-${index + 3}`, `Prompt ${index + 3}`), + ); + const cachedTurns = Array.from( + { length: 10 }, + (_, index) => createTurn(`turn-${index + 1}`, `Prompt ${index + 1}`), + ); + vi.spyOn(flowChatStore, 'getState').mockReturnValue({ + sessions: new Map([['session-1', stateMocks.activeSession]]), + activeSessionId: 'session-1', + }); + vi.spyOn(flowChatStore, 'getSessionHistoryViewState').mockReturnValue({ + catalog, + loadedRanges: [{ + startOrdinal: 0, + endOrdinalExclusive: 10, + turns: cachedTurns, + lastAccessedAt: 1, + source: 'prefetch', + }], + activeRange: null, + pendingTargetOrdinal: null, + navigationGeneration: 0, + }); + vi.spyOn(flowChatStore, 'getSessionCanonicalTailRange').mockReturnValue({ + startOrdinal: 8, + endOrdinalExclusive: 10, + }); + const loadSpy = vi.spyOn(flowChatStore, 'loadSessionTurnWindow').mockResolvedValue({ + status: 'ready', + sessionId: 'session-1', + targetOrdinal: 7, + targetTurnId: 'turn-8', + navigationGeneration: 0, + isCurrent: true, + cacheHit: true, + catalog, + }); + const activateSpy = vi.spyOn( + flowChatStore, + 'activateSessionHistoryWindowFromTail', + ).mockReturnValue({ + range: { + startOrdinal: 2, + endOrdinalExclusive: 10, + targetTurnId: null, + mode: 'history-window', + }, + turns: presentationTurns, + }); + let resolveViewportPreparation: ((ready: boolean) => void) | undefined; + const viewportPreparation = new Promise(resolve => { + resolveViewportPreparation = resolve; + }); + const prepareViewportForPresentationCommit = vi.fn(() => viewportPreparation); + const cancelViewportPresentationCommit = vi.fn(); + + await act(async () => { + root.render(); + }); + let boundaryIntent: Promise | undefined; + await act(async () => { + boundaryIntent = ( + virtualListPropsMock.latest?.onHistoryWindowBoundaryIntent as + | (( + direction: 'before' | 'after', + options?: { + prepareViewportForPresentationCommit?: () => ( + boolean | void | Promise + ); + cancelViewportPresentationCommit?: () => void; + }, + ) => Promise) + | undefined + )?.('before', { + prepareViewportForPresentationCommit, + cancelViewportPresentationCommit, + }); + await Promise.resolve(); + }); + + expect(prepareViewportForPresentationCommit).toHaveBeenCalledOnce(); + expect(cancelViewportPresentationCommit).not.toHaveBeenCalled(); + expect(activateSpy).not.toHaveBeenCalled(); + expect(virtualListPropsMock.latest).toMatchObject({ presentationMode: 'tail' }); + + await act(async () => { + resolveViewportPreparation?.(true); + expect(await boundaryIntent).toBe('applied'); + }); + + expect(loadSpy).toHaveBeenCalledWith('session-1', 7, { + source: 'prefetch', + before: 12, + after: 1, + }); + expect(activateSpy).toHaveBeenCalledWith('session-1', 7); + expect(virtualListPropsMock.latest).toMatchObject({ + presentationMode: 'history-window', + presentationRevision: 1, + }); + }); + it('lets streaming restored sessions use follow-output instead of container sticky anchoring', async () => { stateMocks.activeSession = createSession({ historyState: 'ready', 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 38d28bfa17..d494268a98 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -11,6 +11,8 @@ import { useSessionModeStore } from '@/app/stores/sessionModeStore'; import { VirtualMessageList, type FlowChatTurnPinRequestStatus, + type HistoryWindowBoundaryIntentResult, + type HistoryWindowBoundaryIntentOptions, type VirtualMessageListRef, } from './VirtualMessageList'; import { @@ -35,8 +37,23 @@ import { useFlowChatCopyDialog } from './useFlowChatCopyDialog'; import { useFlowChatSync } from './useFlowChatSync'; import { useFlowChatToolActions } from './useFlowChatToolActions'; import { useFlowChatSearch } from './useFlowChatSearch'; -import { useVirtualItems, useActiveSession, useVisibleTurnInfo, type VisibleTurnInfo } from '../../store/modernFlowChatStore'; -import type { FlowChatConfig, DialogTurn } from '../../types/flow-chat'; +import { + sessionToVirtualItems, + useVirtualItems, + useActiveSession, + useVisibleTurnInfo, + type VisibleTurnInfo, +} from '../../store/modernFlowChatStore'; +import type { + FlowChatConfig, + DialogTurn, + SessionHistoryPresentation, +} from '../../types/flow-chat'; +import type { SessionHistoryWindowDirection } from '../../store/FlowChatStore'; +import type { + FlowChatFocusItemRequest, + FlowChatPinTurnToTopRequest, +} from '../../events/flowchatNavigation'; import { useBackgroundCommandActivityStore, visibleBackgroundCommandActivitiesForSession, @@ -84,12 +101,18 @@ import './ModernFlowChatContainer.scss'; import { PermissionRequestPanel } from './PermissionRequestPanel'; import { pendingPermissionToolCallIdsForSession } from './permissionRequestRouting'; import { usePermissionRequests } from './usePermissionRequests'; +import { + buildContinuousHistoryProjection, + canRetainContinuousHistoryProjection, +} from './continuousHistoryProjection'; const log = createLogger('ModernFlowChatContainer'); + interface ModernFlowChatContainerProps { className?: string; config?: Partial; + isViewportActive?: boolean; permissionPanelAboveChatInput?: boolean; /** Host-owned replacement for the ordinary new-session WelcomePanel. */ emptyState?: React.ReactNode; @@ -109,6 +132,39 @@ interface FlowChatTurnSummary { backendTurnIndex?: number; } +interface FlowChatHistoryPresentationState extends SessionHistoryPresentation { + sessionId: string; + revision: number; +} + +type FlowChatViewportIntent = + | { + kind: 'live-tail'; + sessionId: string; + } + | { + kind: 'turn'; + sessionId: string; + ordinal: number; + turnId: string | null; + source: 'canonical-tail' | 'history-range'; + }; + +interface QueuedTurnNavigation { + ordinal: number; + turnId: string | null; +} + +type FlowChatHistoryBoundaryState = Record< + SessionHistoryWindowDirection, + 'idle' | 'loading' | 'error' +>; + +const IDLE_HISTORY_BOUNDARY_STATE: FlowChatHistoryBoundaryState = { + before: 'idle', + after: 'idle', +}; + type BackgroundCommandSummary = { execSessionKey: string; execSessionId: number; @@ -237,6 +293,7 @@ function backgroundCommandSummaryFromActivity(activity: BackgroundCommandActivit export const ModernFlowChatContainer: React.FC = ({ className = '', config, + isViewportActive = true, permissionPanelAboveChatInput = false, emptyState, onFileViewRequest, @@ -245,8 +302,137 @@ export const ModernFlowChatContainer: React.FC = ( onSwitchToChatPanel, }) => { const { t } = useTranslation('flow-chat'); - const virtualItems = useVirtualItems(); + const canonicalVirtualItems = useVirtualItems(); const activeSession = useActiveSession(); + const [historyPresentation, setHistoryPresentation] = useState(null); + const [viewportIntent, setViewportIntent] = useState(null); + const [continuousProjectionSessionId, setContinuousProjectionSessionId] = useState(null); + const [historyBoundaryState, setHistoryBoundaryState] = useState( + IDLE_HISTORY_BOUNDARY_STATE, + ); + const historyPresentationRef = useRef(null); + const viewportIntentRef = useRef(null); + const updateViewportIntent = useCallback((next: FlowChatViewportIntent | null) => { + viewportIntentRef.current = next; + setViewportIntent(next); + }, []); + const historyBoundaryRequestsRef = useRef | null + >>({ + before: null, + after: null, + }); + const historyPresentationOwnerGenerationRef = useRef(0); + const activeHistoryPresentation = historyPresentation?.sessionId === activeSession?.sessionId + ? historyPresentation + : null; + const activeViewportIntent = viewportIntent?.sessionId === activeSession?.sessionId + ? viewportIntent + : null; + const activeSessionKnownTurnCount = activeSession + ? Math.max( + activeSession.totalTurnCount ?? 0, + activeSession.turnCatalog?.totalTurnCount ?? 0, + activeSession.dialogTurns.length, + ) + : 0; + const activeHistoryPresentationFitsSession = Boolean( + activeHistoryPresentation + && activeHistoryPresentation.range.endOrdinalExclusive <= activeSessionKnownTurnCount + ); + const isShowingHistoryPresentation = Boolean( + activeHistoryPresentation + && activeHistoryPresentationFitsSession + && activeViewportIntent?.kind === 'turn' + && activeViewportIntent.source === 'history-range' + ); + const isReadingTurnViewport = activeViewportIntent?.kind === 'turn'; + const canonicalizedHistoryPresentation = useMemo(() => { + if (!activeSession || !activeHistoryPresentation) { + return null; + } + + const canonicalTurnById = new Map( + activeSession.dialogTurns.map(turn => [turn.id, turn]), + ); + let changed = false; + const turns = activeHistoryPresentation.turns.map(turn => { + const canonicalTurn = canonicalTurnById.get(turn.id); + if (!canonicalTurn || canonicalTurn === turn) { + return turn; + } + changed = true; + return canonicalTurn; + }); + return changed + ? { ...activeHistoryPresentation, turns } + : activeHistoryPresentation; + }, [activeHistoryPresentation, activeSession]); + const continuousHistoryPresentation = useMemo(() => { + if (!activeSession || !canonicalizedHistoryPresentation) { + return null; + } + const presentation = buildContinuousHistoryProjection( + activeSession, + canonicalizedHistoryPresentation, + ); + return presentation ? { + ...presentation, + sessionId: canonicalizedHistoryPresentation.sessionId, + revision: canonicalizedHistoryPresentation.revision, + } : null; + }, [activeSession, canonicalizedHistoryPresentation]); + const continuousHistoryVirtualItems = useMemo(() => { + if (!activeSession || !continuousHistoryPresentation) { + return null; + } + return sessionToVirtualItems({ + ...activeSession, + dialogTurns: continuousHistoryPresentation.turns, + }); + }, [activeSession, continuousHistoryPresentation]); + const continuousHistoryProjectionEligible = canRetainContinuousHistoryProjection( + continuousHistoryPresentation, + continuousHistoryVirtualItems?.length ?? Number.POSITIVE_INFINITY, + ); + const isRetainingContinuousHistoryProjection = Boolean( + activeSession + && continuousProjectionSessionId === activeSession.sessionId + && continuousHistoryProjectionEligible + ); + const isRenderingContinuousHistoryProjection = Boolean( + continuousHistoryProjectionEligible + && (isShowingHistoryPresentation || isRetainingContinuousHistoryProjection) + ); + const renderedHistoryPresentation = isRenderingContinuousHistoryProjection + ? continuousHistoryPresentation + : isShowingHistoryPresentation + ? canonicalizedHistoryPresentation + : null; + const isRenderingHistoryProjection = Boolean(renderedHistoryPresentation); + const virtualItems = useMemo(() => { + if (!activeSession || !renderedHistoryPresentation) { + return canonicalVirtualItems; + } + if ( + isRenderingContinuousHistoryProjection + && continuousHistoryVirtualItems + ) { + return continuousHistoryVirtualItems; + } + return sessionToVirtualItems({ + ...activeSession, + dialogTurns: renderedHistoryPresentation.turns, + }); + }, [ + activeSession, + canonicalVirtualItems, + continuousHistoryVirtualItems, + isRenderingContinuousHistoryProjection, + renderedHistoryPresentation, + ]); + const { requests: permissionRequests, activeBatch: activePermissionBatch, @@ -254,7 +440,7 @@ export const ModernFlowChatContainer: React.FC = ( respondBatch: respondPermissionBatch, } = usePermissionRequests(activeSession?.sessionId); const visibleTurnInfo = useVisibleTurnInfo(); - const [queuedTurnPinId, setQueuedTurnPinId] = useState(null); + const [queuedTurnNavigation, setQueuedTurnNavigation] = useState(null); const [pendingHistoryOpenSession, setPendingHistoryOpenSession] = useState(null); const [searchOpenRequest, setSearchOpenRequest] = useState(0); // Track whether a slash-command or @-mention popup is open in ChatInput. @@ -277,9 +463,12 @@ export const ModernFlowChatContainer: React.FC = ( const releasedHistoryCompletionKeyRef = useRef(null); const visibleTurnInfoRef = useRef(visibleTurnInfo); const turnSummariesRef = useRef([]); - const requestTurnPinRef = useRef<((turnId: string, behavior?: ScrollBehavior) => FlowChatTurnPinRequestStatus) | null>(null); + const turnRailTurnIdsRef = useRef>(new Set()); + const requestTurnNavigationPinRef = useRef<((turnId: string) => FlowChatTurnPinRequestStatus) | null>(null); + const searchFullHistorySessionIdRef = useRef(null); const virtualListRef = useRef(null); const chatScopeRef = useRef(null); + const activeSessionIdRef = useRef(null); const [historyInitialContentReadyKey, setHistoryInitialContentReadyKey] = useState(null); const [historyInitialContentPostPaintKey, setHistoryInitialContentPostPaintKey] = useState(null); const { workspacePath, activeWorkspace } = useWorkspaceContext(); @@ -327,7 +516,7 @@ export const ModernFlowChatContainer: React.FC = ( }, [activeSession?.workspacePath, workspacePath]); const { searchQuery, - onSearchChange, + onSearchChange: setSearchQuery, matches: searchMatches, matchIndices: searchMatchIndices, currentMatchIndex: searchCurrentMatchIndex, @@ -346,12 +535,109 @@ export const ModernFlowChatContainer: React.FC = ( useFlowChatSync(); useFlowChatCopyDialog(); - useFlowChatNavigation({ - activeSessionId: activeSession?.sessionId, - virtualItems, - virtualListRef, - onExpandExploreGroup: handleExpandGroup, - }); + const switchToLiveTailForSession = useCallback(( + sessionId: string, + options?: { discardRecentHistory?: boolean }, + ) => { + historyPresentationOwnerGenerationRef.current += 1; + const retainContinuousProjection = ( + options?.discardRecentHistory !== true + && activeSession?.sessionId === sessionId + && continuousHistoryProjectionEligible + ); + if (retainContinuousProjection) { + setContinuousProjectionSessionId(sessionId); + } else { + setContinuousProjectionSessionId(null); + flowChatStore.restoreSessionTailPresentation(sessionId); + } + if (options?.discardRecentHistory === true) { + historyPresentationRef.current = null; + setHistoryPresentation(null); + } + updateViewportIntent({ kind: 'live-tail', sessionId }); + setHistoryBoundaryState(IDLE_HISTORY_BOUNDARY_STATE); + setQueuedTurnNavigation(null); + }, [activeSession?.sessionId, continuousHistoryProjectionEligible, updateViewportIntent]); + + const handleBeforeTurnPinRequest = useCallback((request: FlowChatPinTurnToTopRequest) => { + const currentViewportIntent = viewportIntentRef.current; + if ( + request.source === 'send-message' + && currentViewportIntent?.sessionId === request.sessionId + && currentViewportIntent.kind === 'turn' + && currentViewportIntent.source === 'history-range' + ) { + switchToLiveTailForSession(request.sessionId); + } + }, [switchToLiveTailForSession]); + + useEffect(() => { + historyPresentationRef.current = historyPresentation; + }, [historyPresentation]); + + useLayoutEffect(() => { + const sessionId = activeSession?.sessionId; + historyPresentationOwnerGenerationRef.current += 1; + historyPresentationRef.current = null; + setHistoryPresentation(null); + setContinuousProjectionSessionId(null); + updateViewportIntent(sessionId ? { kind: 'live-tail', sessionId } : null); + setHistoryBoundaryState(IDLE_HISTORY_BOUNDARY_STATE); + historyBoundaryRequestsRef.current = { before: null, after: null }; + if (sessionId) { + flowChatStore.restoreSessionTailPresentation(sessionId); + } + }, [activeSession?.sessionId, updateViewportIntent]); + + useEffect(() => { + const retainedSessionId = continuousProjectionSessionId; + if (!retainedSessionId) { + return; + } + if (retainedSessionId !== activeSession?.sessionId) { + flowChatStore.restoreSessionTailPresentation(retainedSessionId); + setContinuousProjectionSessionId(null); + return; + } + if (continuousHistoryProjectionEligible) { + return; + } + if (activeViewportIntent?.kind === 'live-tail') { + flowChatStore.restoreSessionTailPresentation(retainedSessionId); + } + setContinuousProjectionSessionId(null); + }, [ + activeSession?.sessionId, + activeViewportIntent?.kind, + continuousHistoryProjectionEligible, + continuousProjectionSessionId, + ]); + + useEffect(() => { + if (!activeHistoryPresentation || activeHistoryPresentationFitsSession) { + return; + } + historyPresentationOwnerGenerationRef.current += 1; + historyPresentationRef.current = null; + setHistoryPresentation(null); + setContinuousProjectionSessionId(null); + if ( + activeViewportIntent?.kind === 'turn' + && activeViewportIntent.source === 'history-range' + ) { + updateViewportIntent(activeSession?.sessionId + ? { kind: 'live-tail', sessionId: activeSession.sessionId } + : null); + } + setHistoryBoundaryState(IDLE_HISTORY_BOUNDARY_STATE); + }, [ + activeHistoryPresentation, + activeHistoryPresentationFitsSession, + activeSession?.sessionId, + activeViewportIntent, + updateViewportIntent, + ]); useEffect(() => { const handleHistorySessionOpenIntent = (event: Event) => { @@ -539,40 +825,145 @@ export const ModernFlowChatContainer: React.FC = ( } return result; }, [activeSession?.dialogTurns]); - const sessionTotalTurnCount = activeSession?.isPartial === true - ? Math.max(activeSession.totalTurnCount ?? turnSummaries.length, turnSummaries.length) - : turnSummaries.length; + const renderedTurns = useMemo( + () => renderedHistoryPresentation + ? renderedHistoryPresentation.turns + : activeSession?.dialogTurns ?? [], + [activeSession?.dialogTurns, renderedHistoryPresentation], + ); + const renderedTurnSummaries = useMemo(() => { + const result: FlowChatTurnSummary[] = []; + for (const turn of renderedTurns) { + if (!turn.userMessage) continue; + result.push({ + turnId: turn.id, + turnIndex: result.length + 1, + backendTurnIndex: turn.backendTurnIndex, + }); + } + return result; + }, [renderedTurns]); + const activeTurnCatalog = activeSession?.turnCatalog; + const turnCatalog = activeTurnCatalog?.sessionId === activeSession?.sessionId + ? activeTurnCatalog + : undefined; + const sessionTotalTurnCount = Math.max( + activeSession?.totalTurnCount ?? 0, + turnCatalog?.totalTurnCount ?? 0, + turnSummaries.length, + ); const absoluteTurnIndexOffset = activeSession?.isPartial === true ? Math.max(0, sessionTotalTurnCount - turnSummaries.length) : 0; - const absoluteTurnSummaries = useMemo(() => { + const absoluteRenderedTurnSummaries = useMemo(() => { + if (renderedHistoryPresentation) { + return renderedTurnSummaries.map((turn, index) => ({ + ...turn, + turnIndex: renderedHistoryPresentation.range.startOrdinal + index + 1, + })); + } if (absoluteTurnIndexOffset === 0 && activeSession?.isPartial !== true) { - return turnSummaries; + return renderedTurnSummaries; } - return turnSummaries.map(turn => ({ + return renderedTurnSummaries.map(turn => ({ ...turn, turnIndex: typeof turn.backendTurnIndex === 'number' ? turn.backendTurnIndex + 1 : turn.turnIndex + absoluteTurnIndexOffset, })); - }, [absoluteTurnIndexOffset, activeSession?.isPartial, turnSummaries]); - const absoluteTurnSummaryById = useMemo(() => { - return new Map(absoluteTurnSummaries.map(turn => [turn.turnId, turn])); - }, [absoluteTurnSummaries]); + }, [ + absoluteTurnIndexOffset, + activeSession?.isPartial, + renderedHistoryPresentation, + renderedTurnSummaries, + ]); + const absoluteRenderedTurnSummaryById = useMemo(() => { + return new Map(absoluteRenderedTurnSummaries.map(turn => [turn.turnId, turn])); + }, [absoluteRenderedTurnSummaries]); const turnRailItems = useMemo(() => { - const userMessageByTurnId = new Map( - (activeSession?.dialogTurns ?? []).map(turn => [ - turn.id, - turn.userMessage?.content ?? '', - ]), + const historyView = activeSession?.sessionId + ? flowChatStore.getSessionHistoryViewState(activeSession.sessionId) + : undefined; + const loadedTurns = [ + ...(activeSession?.dialogTurns ?? []), + ...(historyView?.loadedRanges.flatMap(range => range.turns) ?? []), + ]; + const dialogTurnById = new Map(loadedTurns.map(turn => [turn.id, turn])); + const loadedByStorageIndex = new Map(); + const loadedByOrdinal = new Map(); + for (const range of historyView?.loadedRanges ?? []) { + range.turns.forEach((turn, index) => { + const loaded = { turnId: turn.id, content: turn.userMessage?.content ?? '' }; + loadedByOrdinal.set(range.startOrdinal + index, loaded); + if (typeof turn.backendTurnIndex === 'number') { + loadedByStorageIndex.set(turn.backendTurnIndex, loaded); + } + }); + } + for (const summary of absoluteRenderedTurnSummaries) { + const loaded = { + turnId: summary.turnId, + content: dialogTurnById.get(summary.turnId)?.userMessage?.content ?? '', + }; + loadedByOrdinal.set(Math.max(0, summary.turnIndex - 1), loaded); + if (typeof summary.backendTurnIndex === 'number') { + loadedByStorageIndex.set(summary.backendTurnIndex, loaded); + } + } + + const catalogEntryByOrdinal = new Map( + (turnCatalog?.entries ?? []).map(entry => [entry.ordinal, entry]), ); + const itemCount = Math.max(sessionTotalTurnCount, turnCatalog?.entries.length ?? 0); + const usedLoadedTurnIds = new Set(); + const items = Array.from({ length: itemCount }, (_, ordinal): FlowChatTurnRailItem => { + const catalogEntry = catalogEntryByOrdinal.get(ordinal); + const storageTurnIndex = catalogEntry?.storageTurnIndex ?? ordinal; + const catalogDialogTurn = catalogEntry?.turnId + ? dialogTurnById.get(catalogEntry.turnId) + : undefined; + const loaded = loadedByStorageIndex.get(storageTurnIndex) + ?? (catalogEntry?.turnId && catalogDialogTurn ? { + turnId: catalogEntry.turnId, + content: catalogDialogTurn.userMessage?.content ?? '', + } : undefined) + ?? loadedByOrdinal.get(ordinal); + const turnId = loaded?.turnId ?? catalogEntry?.turnId ?? null; + if (loaded?.turnId) { + usedLoadedTurnIds.add(loaded.turnId); + } + return { + itemKey: `storage:${storageTurnIndex}`, + turnId, + ordinal, + turnIndex: ordinal + 1, + content: loaded?.content ?? catalogEntry?.preview ?? null, + }; + }); - return absoluteTurnSummaries.map(turn => ({ - turnId: turn.turnId, - turnIndex: turn.turnIndex, - content: userMessageByTurnId.get(turn.turnId) ?? '', - })); - }, [absoluteTurnSummaries, activeSession?.dialogTurns]); + for (const summary of absoluteRenderedTurnSummaries) { + if (usedLoadedTurnIds.has(summary.turnId)) { + continue; + } + items.push({ + itemKey: typeof summary.backendTurnIndex === 'number' + ? `storage:${summary.backendTurnIndex}` + : `live:${summary.turnId}`, + turnId: summary.turnId, + ordinal: Math.max(0, summary.turnIndex - 1), + turnIndex: summary.turnIndex, + content: dialogTurnById.get(summary.turnId)?.userMessage?.content ?? '', + }); + } + + return items.sort((left, right) => left.turnIndex - right.turnIndex); + }, [ + absoluteRenderedTurnSummaries, + activeSession?.dialogTurns, + activeSession?.sessionId, + sessionTotalTurnCount, + turnCatalog, + ]); const latestTurnId = turnSummaries[turnSummaries.length - 1]?.turnId; const hasPendingHistoryCompletion = activeSession?.sessionId ? flowChatStore.hasPendingSessionHistoryCompletion(activeSession.sessionId) @@ -684,7 +1075,7 @@ export const ModernFlowChatContainer: React.FC = ( return null; } - const localTurn = turnSummaries.find(turn => turn.turnId === visibleTurnInfo.turnId); + const localTurn = renderedTurnSummaries.find(turn => turn.turnId === visibleTurnInfo.turnId); if (!localTurn) { return visibleTurnInfo; } @@ -692,9 +1083,9 @@ export const ModernFlowChatContainer: React.FC = ( return { ...visibleTurnInfo, turnIndex: localTurn.turnIndex, - totalTurns: turnSummaries.length, + totalTurns: renderedTurnSummaries.length, }; - }, [turnSummaries, visibleTurnInfo]); + }, [renderedTurnSummaries, visibleTurnInfo]); const effectiveVisibleTurnInfo = useMemo(() => { if (!navigationVisibleTurnInfo) { return null; @@ -702,53 +1093,57 @@ export const ModernFlowChatContainer: React.FC = ( return { ...navigationVisibleTurnInfo, - turnIndex: absoluteTurnSummaryById.get(navigationVisibleTurnInfo.turnId)?.turnIndex + turnIndex: absoluteRenderedTurnSummaryById.get(navigationVisibleTurnInfo.turnId)?.turnIndex ?? navigationVisibleTurnInfo.turnIndex + absoluteTurnIndexOffset, totalTurns: sessionTotalTurnCount, }; - }, [absoluteTurnIndexOffset, absoluteTurnSummaryById, navigationVisibleTurnInfo, sessionTotalTurnCount]); + }, [absoluteTurnIndexOffset, absoluteRenderedTurnSummaryById, navigationVisibleTurnInfo, sessionTotalTurnCount]); useEffect(() => { visibleTurnInfoRef.current = visibleTurnInfo; }, [visibleTurnInfo]); useEffect(() => { - turnSummariesRef.current = turnSummaries; - }, [turnSummaries]); + turnSummariesRef.current = renderedTurnSummaries; + }, [renderedTurnSummaries]); + + useEffect(() => { + turnRailTurnIdsRef.current = new Set( + turnRailItems.flatMap(turn => turn.turnId ? [turn.turnId] : []), + ); + }, [turnRailItems]); const currentHeaderMessage = useMemo(() => { const turnId = effectiveVisibleTurnInfo?.turnId; if (!turnId) { return effectiveVisibleTurnInfo?.userMessage ?? ''; } - const turn = activeSession?.dialogTurns.find(item => item.id === turnId); + const turn = renderedTurns.find(item => item.id === turnId); const localCommandTitle = resolveLocalCommandHeaderTitle(turn?.userMessage?.metadata); if (localCommandTitle) { return localCommandTitle; } return effectiveVisibleTurnInfo?.userMessage ?? ''; - }, [activeSession?.dialogTurns, effectiveVisibleTurnInfo?.turnId, effectiveVisibleTurnInfo?.userMessage, resolveLocalCommandHeaderTitle]); - - const requestTurnPin = useCallback((turnId: string, behavior: ScrollBehavior = 'smooth'): FlowChatTurnPinRequestStatus => { - const isLatestTurn = turnSummaries[turnSummaries.length - 1]?.turnId === turnId; - const targetTurn = findDialogTurn(activeSession?.dialogTurns, turnId); - const pinMode = isLatestTurn && shouldUseStickyLatestPin(targetTurn) - ? 'sticky-latest' - : 'transient'; + }, [effectiveVisibleTurnInfo?.turnId, effectiveVisibleTurnInfo?.userMessage, renderedTurns, resolveLocalCommandHeaderTitle]); + const requestTurnNavigationPin = useCallback((turnId: string): FlowChatTurnPinRequestStatus => { + if (!isViewportActive) { + return 'rejected'; + } return virtualListRef.current?.pinTurnToTopWithStatus(turnId, { - behavior, - pinMode, + behavior: 'auto', + pinMode: 'transient', + alignmentPolicy: 'best-effort', }) ?? 'rejected'; - }, [activeSession?.dialogTurns, turnSummaries]); + }, [isViewportActive]); useEffect(() => { - requestTurnPinRef.current = requestTurnPin; - }, [requestTurnPin]); + requestTurnNavigationPinRef.current = requestTurnNavigationPin; + }, [requestTurnNavigationPin]); const handleVirtualListUserScrollIntent = useCallback(() => { - setQueuedTurnPinId(null); + setQueuedTurnNavigation(null); }, []); useEffect(() => { - if (!queuedTurnPinId) return; + if (!isViewportActive || !queuedTurnNavigation) return; let cancelled = false; let frameId: number | null = null; @@ -757,26 +1152,44 @@ export const ModernFlowChatContainer: React.FC = ( const retry = () => { if (cancelled) return; - if (visibleTurnInfoRef.current?.turnId === queuedTurnPinId) { - setQueuedTurnPinId(null); + const queuedTurnId = queuedTurnNavigation.turnId + ?? turnSummariesRef.current.find( + turn => turn.turnIndex === queuedTurnNavigation.ordinal + 1, + )?.turnId + ?? null; + if (!queuedTurnId) { + attempts += 1; + if (attempts >= TURN_PIN_RETRY_MAX_ATTEMPTS) { + setQueuedTurnNavigation(null); + return; + } + frameId = requestAnimationFrame(retry); + return; + } + + if (visibleTurnInfoRef.current?.turnId === queuedTurnId) { + setQueuedTurnNavigation(null); return; } - const targetStillExists = turnSummariesRef.current.some(turn => turn.turnId === queuedTurnPinId); - if (!targetStillExists) { - setQueuedTurnPinId(null); + if (!turnRailTurnIdsRef.current.has(queuedTurnId)) { + setQueuedTurnNavigation(null); + return; + } + const targetIsLoaded = turnSummariesRef.current.some(turn => turn.turnId === queuedTurnId); + if (!targetIsLoaded) { return; } - const pinStatus = requestTurnPinRef.current?.(queuedTurnPinId, 'auto') ?? 'rejected'; + const pinStatus = requestTurnNavigationPinRef.current?.(queuedTurnId) ?? 'rejected'; if (pinStatus === 'settled' || pinStatus === 'pending') { - setQueuedTurnPinId(null); + setQueuedTurnNavigation(null); return; } attempts += 1; if (attempts >= TURN_PIN_RETRY_MAX_ATTEMPTS) { - setQueuedTurnPinId(null); + setQueuedTurnNavigation(null); return; } @@ -792,18 +1205,21 @@ export const ModernFlowChatContainer: React.FC = ( } }; }, [ - queuedTurnPinId, + isViewportActive, + queuedTurnNavigation, + renderedTurnSummaries.length, ]); useLayoutEffect(() => { autoPinnedTurnKeyRef.current = null; releasedHistoryCompletionKeyRef.current = null; + searchFullHistorySessionIdRef.current = null; }, [activeSession?.sessionId]); useEffect(() => { setHistoryInitialContentReadyKey(null); setHistoryInitialContentPostPaintKey(null); - setQueuedTurnPinId(null); + setQueuedTurnNavigation(null); }, [activeSession?.sessionId]); useLayoutEffect(() => { @@ -811,7 +1227,14 @@ export const ModernFlowChatContainer: React.FC = ( const latestTurnKey = sessionId && latestTurnId ? `${sessionId}:${latestTurnId}:${turnSummaries.length}` : null; - if (!sessionId || !latestTurnId || autoPinnedTurnKeyRef.current === latestTurnKey) { + if ( + !isViewportActive + || + !sessionId + || isReadingTurnViewport + || !latestTurnId + || autoPinnedTurnKeyRef.current === latestTurnKey + ) { return; } @@ -955,6 +1378,8 @@ export const ModernFlowChatContainer: React.FC = ( activeSession?.remoteConnectionId, activeSession?.remoteSshHost, hasPendingHistoryCompletion, + isViewportActive, + isReadingTurnViewport, latestTurnId, latestTurnUsesFollowOutput, latestTurnUsesStickyPin, @@ -965,6 +1390,7 @@ export const ModernFlowChatContainer: React.FC = ( useEffect(() => { const sessionId = activeSession?.sessionId; if ( + !isViewportActive || !sessionId || activeSession.historyState !== 'ready' || ( @@ -1044,6 +1470,7 @@ export const ModernFlowChatContainer: React.FC = ( activeSession?.contextRestoreState, activeSession?.sessionId, hasPendingHistoryCompletion, + isViewportActive, latestTurnId, turnSummaries.length, ]); @@ -1077,29 +1504,320 @@ export const ModernFlowChatContainer: React.FC = ( searchQuery, ]); - const handleJumpToTurn = useCallback((turnId: string) => { - if (!turnId) return false; + const applyHistoryPresentation = useCallback(( + sessionId: string, + presentation: SessionHistoryPresentation, + options?: { + completedBoundary?: SessionHistoryWindowDirection; + viewportTarget?: { + ordinal: number; + turnId: string | null; + }; + }, + ) => { + historyPresentationOwnerGenerationRef.current += 1; + setHistoryPresentation(previous => { + const next: FlowChatHistoryPresentationState = { + ...presentation, + sessionId, + revision: (previous?.sessionId === sessionId ? previous.revision : 0) + 1, + }; + historyPresentationRef.current = next; + return next; + }); + if (options?.viewportTarget) { + updateViewportIntent({ + kind: 'turn', + sessionId, + ordinal: options.viewportTarget.ordinal, + turnId: options.viewportTarget.turnId, + source: 'history-range', + }); + } + const completedBoundary = options?.completedBoundary; + if (completedBoundary) { + setHistoryBoundaryState(previous => ({ + ...previous, + [completedBoundary]: 'idle', + })); + } else { + setHistoryBoundaryState(IDLE_HISTORY_BOUNDARY_STATE); + } + }, [updateViewportIntent]); - const targetStillExists = turnSummaries.some(turn => turn.turnId === turnId); - if (!targetStillExists) { - setQueuedTurnPinId(null); + const restoreTailPresentation = useCallback((options?: { + followLatest?: boolean; + discardRecentHistory?: boolean; + }) => { + const sessionId = activeSession?.sessionId; + if (!sessionId) { return false; } - const pinStatus = requestTurnPin(turnId); - if (pinStatus === 'settled') { - setQueuedTurnPinId(null); + switchToLiveTailForSession(sessionId, { + discardRecentHistory: options?.discardRecentHistory, + }); + + if (options?.followLatest) { + requestAnimationFrame(() => { + if (activeSessionIdRef.current === sessionId) { + virtualListRef.current?.scrollToLatestEndPosition(); + } + }); + } + return true; + }, [activeSession?.sessionId, switchToLiveTailForSession]); + + const jumpToLiveTail = useCallback(() => { + return restoreTailPresentation({ followLatest: true }); + }, [restoreTailPresentation]); + + const handleSearchChange = useCallback((query: string) => { + setSearchQuery(query); + const sessionId = activeSession?.sessionId; + if ( + !query.trim() + || !sessionId + || activeSession.isPartial !== true + || searchFullHistorySessionIdRef.current === sessionId + ) { + return; + } + + searchFullHistorySessionIdRef.current = sessionId; + void flowChatStore.ensureSessionFullHistory(sessionId, 'flowchat-search').then(ready => { + if (ready && activeSessionIdRef.current === sessionId) { + restoreTailPresentation({ discardRecentHistory: true }); + } else if (activeSessionIdRef.current === sessionId) { + searchFullHistorySessionIdRef.current = null; + } + }); + }, [activeSession?.isPartial, activeSession?.sessionId, restoreTailPresentation, setSearchQuery]); + + const navigateToTurn = useCallback(async (target: FlowChatTurnRailItem | string) => { + const targetItem = typeof target === 'string' + ? turnRailItems.find(turn => turn.turnId === target) + : target; + const sessionId = activeSession?.sessionId; + if (!targetItem || !sessionId) return false; + + const renderedTargetId = targetItem.turnId; + const targetIsRendered = Boolean( + renderedTargetId + && renderedTurnSummaries.some(turn => turn.turnId === renderedTargetId), + ); + if (renderedTargetId && targetIsRendered) { + updateViewportIntent({ + kind: 'turn', + sessionId, + ordinal: targetItem.ordinal, + turnId: renderedTargetId, + source: isRenderingHistoryProjection ? 'history-range' : 'canonical-tail', + }); + const pinStatus = requestTurnNavigationPin(renderedTargetId); + if (pinStatus === 'settled' || pinStatus === 'pending') { + setQueuedTurnNavigation(null); + return true; + } + setQueuedTurnNavigation({ + ordinal: targetItem.ordinal, + turnId: renderedTargetId, + }); return true; } - if (pinStatus === 'pending') { - setQueuedTurnPinId(null); + const recentHistoryPresentation = historyPresentationRef.current?.sessionId === sessionId + ? historyPresentationRef.current + : null; + const recentHistoryTurn = recentHistoryPresentation + ? recentHistoryPresentation.turns[ + targetItem.ordinal - recentHistoryPresentation.range.startOrdinal + ] + : undefined; + const targetIsInRecentHistory = Boolean( + recentHistoryPresentation + && targetItem.ordinal >= recentHistoryPresentation.range.startOrdinal + && targetItem.ordinal < recentHistoryPresentation.range.endOrdinalExclusive + && recentHistoryTurn + && (!targetItem.turnId || recentHistoryTurn.id === targetItem.turnId) + ); + if (recentHistoryPresentation && targetIsInRecentHistory && recentHistoryTurn) { + const reactivatedPresentation = flowChatStore.reactivateSessionHistoryWindow( + sessionId, + recentHistoryPresentation.range, + ); + if (reactivatedPresentation) { + const preparedPin = virtualListRef.current?.prepareTurnPinToTop(recentHistoryTurn.id, { + behavior: 'auto', + pinMode: 'transient', + alignmentPolicy: 'best-effort', + }) ?? 'rejected'; + if (preparedPin !== 'rejected') { + applyHistoryPresentation(sessionId, reactivatedPresentation, { + viewportTarget: { + ordinal: targetItem.ordinal, + turnId: recentHistoryTurn.id, + }, + }); + setQueuedTurnNavigation(null); + return true; + } + flowChatStore.restoreSessionTailPresentation(sessionId); + } + } + + let result; + try { + result = await flowChatStore.loadSessionTurnWindow(sessionId, targetItem.ordinal, { + source: 'target', + }); + } catch (error) { + log.warn('Failed to load the requested session Turn window', { + sessionId, + targetOrdinal: targetItem.ordinal, + error, + }); + return false; + } + + if (result.status === 'ready' && result.isCurrent) { + const targetTurnId = result.targetTurnId + ?? result.range?.turns[result.targetOrdinal - (result.range?.startOrdinal ?? 0)]?.id + ?? targetItem.turnId; + if (!targetTurnId) { + return false; + } + + const preparedPin = virtualListRef.current?.prepareTurnPinToTop(targetTurnId, { + behavior: 'auto', + pinMode: 'transient', + alignmentPolicy: 'best-effort', + }) ?? 'rejected'; + if (preparedPin === 'rejected') { + return false; + } + const presentation = flowChatStore.activateSessionHistoryWindow( + sessionId, + result.targetOrdinal, + result.navigationGeneration, + ); + if (!presentation) { + return false; + } + + applyHistoryPresentation(sessionId, presentation, { + viewportTarget: { + ordinal: result.targetOrdinal, + turnId: targetTurnId, + }, + }); + setQueuedTurnNavigation(null); return true; } - setQueuedTurnPinId(turnId); + if (result.status === 'unsupported' || result.status === 'not-found') { + const historyReady = await flowChatStore.ensureSessionFullHistory( + sessionId, + 'turn-rail-navigation', + ); + if (historyReady && activeSessionIdRef.current === sessionId) { + restoreTailPresentation({ discardRecentHistory: true }); + updateViewportIntent({ + kind: 'turn', + sessionId, + ordinal: targetItem.ordinal, + turnId: targetItem.turnId, + source: 'canonical-tail', + }); + setQueuedTurnNavigation({ + ordinal: targetItem.ordinal, + turnId: targetItem.turnId, + }); + return true; + } + } + return false; - }, [requestTurnPin, turnSummaries]); + }, [ + activeSession?.sessionId, + applyHistoryPresentation, + isRenderingHistoryProjection, + renderedTurnSummaries, + requestTurnNavigationPin, + restoreTailPresentation, + turnRailItems, + updateViewportIntent, + ]); + + const handleNavigateToFocusTurn = useCallback(async (request: FlowChatFocusItemRequest) => { + const sessionId = activeSession?.sessionId; + if (!sessionId || request.sessionId !== sessionId) { + return false; + } + + const requestedTurnId = request.turnId?.trim() || null; + const targetById = requestedTurnId + ? turnRailItems.find(turn => turn.turnId === requestedTurnId) + : undefined; + if (targetById) { + return navigateToTurn(targetById); + } + + if (requestedTurnId) { + const historyReady = await flowChatStore.ensureSessionFullHistory( + sessionId, + 'flowchat-focus-navigation', + ); + if (!historyReady || flowChatStore.getState().activeSessionId !== sessionId) { + return false; + } + const hydratedSession = flowChatStore.getState().sessions.get(sessionId); + const hydratedTurnIndex = hydratedSession?.dialogTurns.findIndex( + turn => turn.id === requestedTurnId, + ) ?? -1; + if (hydratedTurnIndex < 0) { + return false; + } + + restoreTailPresentation({ discardRecentHistory: true }); + updateViewportIntent({ + kind: 'turn', + sessionId, + ordinal: hydratedTurnIndex, + turnId: requestedTurnId, + source: 'canonical-tail', + }); + setQueuedTurnNavigation({ + ordinal: hydratedTurnIndex, + turnId: requestedTurnId, + }); + return true; + } + + const requestedOrdinal = typeof request.turnIndex === 'number' + ? Math.max(0, Math.floor(request.turnIndex) - 1) + : null; + if (requestedOrdinal === null) { + return false; + } + const targetByOrdinal = turnRailItems.find(turn => turn.ordinal === requestedOrdinal); + return targetByOrdinal ? navigateToTurn(targetByOrdinal) : false; + }, [ + activeSession?.sessionId, + navigateToTurn, + restoreTailPresentation, + turnRailItems, + updateViewportIntent, + ]); + + useFlowChatNavigation({ + activeSessionId: activeSession?.sessionId, + virtualItems, + virtualListRef, + onExpandExploreGroup: handleExpandGroup, + onBeforeTurnPinRequest: handleBeforeTurnPinRequest, + onNavigateToFocusTurn: handleNavigateToFocusTurn, + }); const handleRetryHistoryLoad = useCallback(() => { const sessionId = activeSession?.sessionId; @@ -1107,12 +1825,150 @@ export const ModernFlowChatContainer: React.FC = ( void FlowChatManager.getInstance().switchChatSession(sessionId); }, [activeSession?.sessionId]); - const activeSessionIdRef = useRef(null); - useEffect(() => { activeSessionIdRef.current = activeSession?.sessionId ?? null; }, [activeSession?.sessionId]); + const handleHistoryWindowBoundaryIntent = useCallback(( + direction: SessionHistoryWindowDirection, + options?: HistoryWindowBoundaryIntentOptions, + ): Promise => { + const existingRequest = historyBoundaryRequestsRef.current[direction]; + if (existingRequest) { + return existingRequest; + } + + const request = (async () => { + const currentViewportIntent = viewportIntentRef.current; + const presentation = currentViewportIntent?.kind === 'turn' + && currentViewportIntent.source === 'history-range' + ? historyPresentationRef.current + : null; + const sessionId = activeSessionIdRef.current; + const presentationOwnerGeneration = historyPresentationOwnerGenerationRef.current; + if (!sessionId || (presentation && presentation.sessionId !== sessionId)) { + return 'cancelled'; + } + + const session = flowChatStore.getState().sessions.get(sessionId); + const historyView = flowChatStore.getSessionHistoryViewState(sessionId); + const totalTurnCount = Math.max( + historyView?.catalog?.totalTurnCount ?? 0, + session?.totalTurnCount ?? 0, + ); + let targetOrdinal: number; + if (presentation) { + targetOrdinal = direction === 'before' + ? presentation.range.startOrdinal - 1 + : presentation.range.endOrdinalExclusive; + } else { + if ( + direction !== 'before' + || session?.isPartial !== true + || session.turnCatalog?.sessionId !== sessionId + ) { + return 'cancelled'; + } + const canonicalTailRange = flowChatStore.getSessionCanonicalTailRange(sessionId); + if (!canonicalTailRange) { + return 'not-ready'; + } + targetOrdinal = canonicalTailRange.startOrdinal - 1; + } + if (targetOrdinal < 0 || targetOrdinal >= totalTurnCount) { + return 'exhausted'; + } + + setHistoryBoundaryState(previous => ({ ...previous, [direction]: 'loading' })); + let viewportPreparationStarted = false; + try { + const result = await flowChatStore.loadSessionTurnWindow(sessionId, targetOrdinal, { + source: 'prefetch', + before: direction === 'before' ? 12 : 4, + after: direction === 'after' ? 12 : 1, + }); + if (!result.isCurrent || activeSessionIdRef.current !== sessionId) { + return 'cancelled'; + } + if (result.status !== 'ready') { + if (result.status === 'unsupported') { + const historyReady = await flowChatStore.ensureSessionFullHistory( + sessionId, + 'sequential-history-navigation', + ); + if (historyReady && activeSessionIdRef.current === sessionId) { + setHistoryBoundaryState(previous => ({ ...previous, [direction]: 'idle' })); + return 'applied'; + } + } + setHistoryBoundaryState(previous => ({ ...previous, [direction]: 'error' })); + return 'not-ready'; + } + + viewportPreparationStarted = Boolean(options?.prepareViewportForPresentationCommit); + const preparationResult = await options?.prepareViewportForPresentationCommit?.(); + const activeSessionIsCurrent = activeSessionIdRef.current === sessionId; + const presentationOwnerIsCurrent = ( + historyPresentationOwnerGenerationRef.current === presentationOwnerGeneration + ); + if ( + preparationResult === false + || !activeSessionIsCurrent + || !presentationOwnerIsCurrent + ) { + if (viewportPreparationStarted) { + options?.cancelViewportPresentationCommit?.(); + } + if (activeSessionIsCurrent) { + setHistoryBoundaryState(previous => ({ ...previous, [direction]: 'idle' })); + } + return 'cancelled'; + } + + const nextPresentation = presentation + ? flowChatStore.extendSessionHistoryWindow(sessionId, direction) + : flowChatStore.activateSessionHistoryWindowFromTail(sessionId, targetOrdinal); + if (!nextPresentation) { + if (viewportPreparationStarted) { + options?.cancelViewportPresentationCommit?.(); + } + setHistoryBoundaryState(previous => ({ ...previous, [direction]: 'error' })); + return 'not-ready'; + } + applyHistoryPresentation(sessionId, nextPresentation, { + completedBoundary: direction, + ...(!presentation ? { + viewportTarget: { + ordinal: targetOrdinal, + turnId: nextPresentation.turns[ + targetOrdinal - nextPresentation.range.startOrdinal + ]?.id ?? null, + }, + } : {}), + }); + return 'applied'; + } catch (error) { + if (viewportPreparationStarted) { + options?.cancelViewportPresentationCommit?.(); + } + if (activeSessionIdRef.current === sessionId) { + setHistoryBoundaryState(previous => ({ ...previous, [direction]: 'error' })); + } + log.warn('Failed to prefetch an adjacent session Turn window', { + sessionId, + direction, + targetOrdinal, + error, + }); + return 'not-ready'; + } + })().finally(() => { + historyBoundaryRequestsRef.current[direction] = null; + }); + historyBoundaryRequestsRef.current[direction] = request; + return request; + }, [applyHistoryPresentation]); + useEffect(() => { if (!activeSession?.sessionId) { return; @@ -1508,10 +2364,10 @@ export const ModernFlowChatContainer: React.FC = ( sessionId={activeSession?.sessionId} onJumpToCurrentTurn={() => { const turnId = effectiveVisibleTurnInfo?.turnId; - if (turnId) handleJumpToTurn(turnId); + if (turnId) navigateToTurn(turnId); }} searchQuery={searchQuery} - onSearchChange={onSearchChange} + onSearchChange={handleSearchChange} searchMatchCount={searchMatches.length} searchCurrentMatch={searchMatches.length > 0 ? searchCurrentMatchIndex + 1 : 0} onSearchNext={handleSearchNext} @@ -1562,6 +2418,8 @@ export const ModernFlowChatContainer: React.FC = ( data-show-history-open-intent-overlay={showHistoryOpenIntentOverlay ? 'true' : 'false'} data-has-pending-history-completion={hasPendingHistoryCompletion ? 'true' : 'false'} data-has-deferred-history-projection={hasDeferredHistoryProjection ? 'true' : 'false'} + data-presentation-mode={isRenderingHistoryProjection ? 'history-window' : 'tail'} + data-viewport-intent={activeViewportIntent?.kind ?? 'live-tail'} data-latest-turn-id={latestTurnId ?? ''} data-history-initial-content-ready={ historyInitialContentKey === null || historyInitialContentReadyKey === historyInitialContentKey @@ -1599,6 +2457,15 @@ export const ModernFlowChatContainer: React.FC = ( <> @@ -1608,7 +2475,7 @@ export const ModernFlowChatContainer: React.FC = ( turns={turnRailItems} currentTurnId={effectiveVisibleTurnInfo?.turnId ?? null} visibleTurnIds={effectiveVisibleTurnInfo?.visibleTurnIds ?? []} - onNavigate={handleJumpToTurn} + onNavigate={navigateToTurn} /> ) : null} {showHistoryLoadingLayer && ( 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 cc29e52314..8570937a01 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 @@ -6,12 +6,74 @@ import { JSDOM } from 'jsdom'; import { FlowChatContext } from './FlowChatContext'; import { UserMessageItem } from './UserMessageItem'; import { globalEventBus } from '@/infrastructure/event-bus'; +import { useMessageEditStore } from '../../store/messageEditStore'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; const activeSessionRef: { current: any } = { current: null, }; +const snapshotApiMock = vi.hoisted(() => ({ + rollbackToTurn: vi.fn(async () => [] as string[]), +})); +const componentLibraryMock = vi.hoisted(() => ({ + confirmDanger: vi.fn(async () => true), +})); +const editServiceMock = vi.hoisted(() => ({ + describeUserMessageEditImpact: vi.fn(() => ({ + willStopRunningTask: false, + willRestoreFiles: true, + willDeleteTurns: true, + willRerun: true, + })), + editAndRerunUserMessage: vi.fn(async () => undefined), +})); + +function createPartialHistorySession(includeCatalog: boolean) { + const session: any = { + sessionId: 'partial-session', + sessionKind: 'normal', + isPartial: true, + loadedTurnCount: 1, + totalTurnCount: 20, + dialogTurns: [{ id: 'turn-20', status: 'completed', backendTurnIndex: 19 }], + }; + if (includeCatalog) { + session.turnCatalog = { + schemaVersion: 1, + sessionId: 'partial-session', + revision: 'catalog-1', + totalTurnCount: 20, + complete: true, + entries: Array.from({ length: 20 }, (_, ordinal) => ({ + ordinal, + storageTurnIndex: ordinal, + turnId: `turn-${ordinal + 1}`, + preview: `Prompt ${ordinal + 1}`, + previewTruncated: false, + })), + }; + } + return session; +} + +function createHydratedHistoryState(partialSession: any) { + return { + sessions: new Map([[ + 'partial-session', + { + ...partialSession, + isPartial: false, + loadedTurnCount: 20, + dialogTurns: Array.from({ length: 20 }, (_, index) => ({ + id: `turn-${index + 1}`, + status: 'completed', + })), + }, + ]]), + activeSessionId: 'partial-session', + }; +} vi.mock('react-i18next', () => ({ initReactI18next: { @@ -44,6 +106,7 @@ const flowChatStoreMock = vi.hoisted(() => ({ sessions: new Map(), activeSessionId: null, })), + ensureSessionFullHistory: vi.fn(async () => true), truncateDialogTurnsFrom: vi.fn(), })); @@ -55,9 +118,7 @@ vi.mock('../../store/FlowChatStore', () => ({ })); vi.mock('@/infrastructure/api', () => ({ - snapshotAPI: { - rollbackToTurn: vi.fn(), - }, + snapshotAPI: snapshotApiMock, })); vi.mock('@/shared/notification-system', () => ({ @@ -76,7 +137,24 @@ vi.mock('@/infrastructure/event-bus', () => ({ vi.mock('@/component-library', () => ({ ReproductionStepsBlock: ({ steps }: { steps: string }) =>
{steps}
, Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, - confirmDanger: vi.fn(), + confirmDanger: componentLibraryMock.confirmDanger, +})); + +vi.mock('../../services/UserMessageEditService', () => ({ + describeUserMessageEditImpact: editServiceMock.describeUserMessageEditImpact, + editAndRerunUserMessage: editServiceMock.editAndRerunUserMessage, +})); + +vi.mock('./UserMessageEditComposer', () => ({ + UserMessageEditComposer: ({ onSubmit }: { onSubmit: () => void }) => ( + + ), })); describe('UserMessageItem steering tag', () => { @@ -86,6 +164,15 @@ describe('UserMessageItem steering tag', () => { beforeEach(() => { vi.clearAllMocks(); + flowChatStoreMock.getState.mockReturnValue({ + sessions: new Map(), + activeSessionId: null, + }); + flowChatStoreMock.ensureSessionFullHistory.mockResolvedValue(true); + componentLibraryMock.confirmDanger.mockResolvedValue(true); + snapshotApiMock.rollbackToTurn.mockResolvedValue([]); + editServiceMock.editAndRerunUserMessage.mockResolvedValue(undefined); + useMessageEditStore.getState().cancelEdit(); dom = new JSDOM('
', { pretendToBeVisual: true, }); @@ -107,6 +194,7 @@ describe('UserMessageItem steering tag', () => { act(() => { root.unmount(); }); + useMessageEditStore.getState().cancelEdit(); vi.unstubAllGlobals(); }); @@ -404,7 +492,48 @@ 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', () => { + it('keeps edit and rollback available for on-demand hydration in a partial history tail', () => { + 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(false); + expect(container.querySelector('.user-message-item__rollback-btn')?.disabled).toBe(false); + }); + + it('hydrates partial history before rollback and uses the global Turn index', async () => { activeSessionRef.current = { sessionId: 'partial-session', sessionKind: 'normal', @@ -419,6 +548,21 @@ describe('UserMessageItem steering tag', () => { }, ], }; + flowChatStoreMock.getState.mockReturnValue({ + sessions: new Map([[ + 'partial-session', + { + ...activeSessionRef.current, + isPartial: false, + loadedTurnCount: 20, + dialogTurns: Array.from({ length: 20 }, (_, index) => ({ + id: `turn-${index + 1}`, + status: 'completed', + })), + }, + ]]), + activeSessionId: 'partial-session', + }); act(() => { root.render( @@ -441,7 +585,133 @@ describe('UserMessageItem steering tag', () => { ); }); - expect(container.querySelector('.user-message-item__edit-btn')?.disabled).toBe(true); - expect(container.querySelector('.user-message-item__rollback-btn')?.disabled).toBe(true); + await act(async () => { + container.querySelector('.user-message-item__rollback-btn')?.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(flowChatStoreMock.ensureSessionFullHistory).toHaveBeenCalledWith( + 'partial-session', + 'user-message-rollback', + ); + expect(snapshotApiMock.rollbackToTurn).toHaveBeenCalledWith('partial-session', 19, true); + expect(flowChatStoreMock.truncateDialogTurnsFrom).toHaveBeenCalledWith('partial-session', 19); + }); + + it('keeps edit and rollback available for a rendered Turn outside the canonical tail', () => { + activeSessionRef.current = createPartialHistorySession(false); + + act(() => { + root.render( + + + , + ); + }); + + expect(container.querySelector('.user-message-item__edit-btn')?.disabled).toBe(false); + expect(container.querySelector('.user-message-item__rollback-btn')?.disabled).toBe(false); + }); + + it('hydrates a cataloged history-window Turn before rollback', async () => { + activeSessionRef.current = createPartialHistorySession(true); + flowChatStoreMock.getState.mockReturnValue( + createHydratedHistoryState(activeSessionRef.current), + ); + + act(() => { + root.render( + + + , + ); + }); + + await act(async () => { + container.querySelector('.user-message-item__rollback-btn')?.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(flowChatStoreMock.ensureSessionFullHistory).toHaveBeenCalledWith( + 'partial-session', + 'user-message-rollback', + ); + expect(snapshotApiMock.rollbackToTurn).toHaveBeenCalledWith('partial-session', 4, true); + expect(flowChatStoreMock.truncateDialogTurnsFrom).toHaveBeenCalledWith('partial-session', 4); + }); + + it('hydrates a cataloged history-window Turn before editing and rerunning', async () => { + activeSessionRef.current = createPartialHistorySession(true); + flowChatStoreMock.getState.mockReturnValue( + createHydratedHistoryState(activeSessionRef.current), + ); + + act(() => { + root.render( + + + , + ); + }); + + await act(async () => { + container.querySelector('.user-message-item__edit-btn')?.click(); + }); + await act(async () => { + useMessageEditStore.getState().setDraft('edited older window prompt'); + }); + await act(async () => { + container + .querySelector('.user-message-edit-composer__icon-button--confirm') + ?.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(flowChatStoreMock.ensureSessionFullHistory).toHaveBeenCalledWith( + 'partial-session', + 'user-message-edit', + ); + expect(editServiceMock.editAndRerunUserMessage).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: 'partial-session', + turnId: 'turn-5', + turnIndex: 4, + originalContent: 'older window prompt', + editedContent: 'edited older window prompt', + })); }); }); 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 992351db9f..08f37d9fd0 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx @@ -33,6 +33,7 @@ import type { SessionUsagePanelTab } from '../usage/sessionUsagePanelTypes'; import { coerceSessionUsageReport } from '../usage/usageReportUtils'; import { resolveSessionRelationship } from '../../utils/sessionMetadata'; import { isRemoteWorkspaceSession } from '../../utils/sessionWorkspace'; +import { absoluteSessionTurnIndexForId } from '../../utils/flowChatTurnOrdinal'; import { composerPresentationToAccessibleText, composerPresentationContexts, @@ -51,6 +52,8 @@ const log = createLogger('UserMessageItem'); interface UserMessageItemProps { message: DialogTurn['userMessage']; turnId: string; + absoluteTurnIndex?: number; + turnStatus?: DialogTurn['status']; steeringStatus?: FlowUserSteeringItem['status']; } @@ -83,7 +86,7 @@ function buildPresentationRerunPayload(presentation: ComposerPresentation): { } export const UserMessageItem = React.memo( - ({ message, turnId, steeringStatus }) => { + ({ message, turnId, absoluteTurnIndex, turnStatus, steeringStatus }) => { const { t, formatDate } = useI18n('flow-chat'); const { config, @@ -137,9 +140,15 @@ export const UserMessageItem = React.memo( ?? activeSessionFromStore; const turnIndex = currentSession?.dialogTurns.findIndex(t => t.id === turnId) ?? -1; const dialogTurn = turnIndex >= 0 ? currentSession?.dialogTurns[turnIndex] : null; - const isFailed = dialogTurn?.status === 'error'; + const resolvedTurnStatus = dialogTurn?.status ?? turnStatus; + const isFailed = resolvedTurnStatus === 'error'; const resolvedSessionId = sessionId ?? currentSession?.sessionId; - const historyActionsBlockedByPartialRestore = currentSession?.isPartial === true; + const resolvedAbsoluteTurnIndex = absoluteTurnIndex ?? ( + currentSession ? absoluteSessionTurnIndexForId(currentSession, turnId) : undefined + ); + const actionTurnIndex = resolvedAbsoluteTurnIndex !== undefined + ? resolvedAbsoluteTurnIndex - 1 + : -1; const isRemoteSession = isRemoteWorkspaceSession(currentSession ?? undefined, null); const isSystemTriggered = Boolean( message?.metadata?.triggerSource && message.metadata.triggerSource !== 'desktop_ui', @@ -148,16 +157,14 @@ export const UserMessageItem = React.memo( !steeringStatus && canShowRollbackAction && !!resolvedSessionId && - turnIndex >= 0 && - !historyActionsBlockedByPartialRestore && + actionTurnIndex >= 0 && !isRemoteSession && !isRollingBack && !isEditSubmitting; const canEditBase = allowUserMessageEdit && !!resolvedSessionId && - turnIndex >= 0 && - !historyActionsBlockedByPartialRestore && + actionTurnIndex >= 0 && !isRemoteSession && !isThreadGoalSystemMessage && !isSystemTriggered && @@ -170,13 +177,11 @@ export const UserMessageItem = React.memo( ? t('message.cannotEdit') : steeringStatus ? t('message.cannotEdit') - : historyActionsBlockedByPartialRestore - ? t('message.editDisabledHistoryNotReady') - : !resolvedSessionId || turnIndex < 0 + : !resolvedSessionId || actionTurnIndex < 0 ? t('message.editDisabledHistoryNotReady') : t('message.cannotEdit'); const rollbackTooltip = canRollback - ? t('message.rollbackTo', { index: turnIndex + 1 }) + ? t('message.rollbackTo', { index: actionTurnIndex + 1 }) : isRemoteSession ? t('message.rollbackDisabledRemote') : t('message.cannotRollback'); @@ -253,7 +258,7 @@ export const UserMessageItem = React.memo( e.stopPropagation(); if (!canRollback || !resolvedSessionId) return; - const index = turnIndex + 1; + const index = actionTurnIndex + 1; const confirmed = await confirmDanger( t('message.rollbackDialogTitle', { index }), ( @@ -270,10 +275,27 @@ export const UserMessageItem = React.memo( setIsRollingBack(true); try { - const restoredFiles = await snapshotAPI.rollbackToTurn(resolvedSessionId, turnIndex, true); + const historyReady = await flowChatStore.ensureSessionFullHistory( + resolvedSessionId, + 'user-message-rollback', + ); + const hydratedTurnIndex = flowChatStore + .getState() + .sessions + .get(resolvedSessionId) + ?.dialogTurns.findIndex(turn => turn.id === turnId) ?? -1; + if (!historyReady || hydratedTurnIndex < 0) { + throw new Error(t('message.cannotRollback')); + } + + const restoredFiles = await snapshotAPI.rollbackToTurn( + resolvedSessionId, + hydratedTurnIndex, + true, + ); // 1) Truncate local dialog turns from this index. - flowChatStore.truncateDialogTurnsFrom(resolvedSessionId, turnIndex); + flowChatStore.truncateDialogTurnsFrom(resolvedSessionId, hydratedTurnIndex); // 2) Refresh file tree and open editors. const { globalEventBus } = await import('@/infrastructure/event-bus'); @@ -299,7 +321,7 @@ export const UserMessageItem = React.memo( } finally { setIsRollingBack(false); } - }, [canRollback, composerPresentation, resolvedSessionId, t, turnIndex, messageContent]); + }, [actionTurnIndex, canRollback, composerPresentation, resolvedSessionId, t, turnId, messageContent]); const handleBeginEdit = useCallback((e: React.MouseEvent) => { e.stopPropagation(); @@ -308,7 +330,7 @@ export const UserMessageItem = React.memo( }, [beginEdit, canEdit, messageContent, turnId]); const handleSubmitEdit = useCallback(async (submittedPresentation?: ComposerPresentation) => { - if (!resolvedSessionId || turnIndex < 0 || isEditSubmitting) return; + if (!resolvedSessionId || actionTurnIndex < 0 || isEditSubmitting) return; const editedPresentation = submittedPresentation ?? composerPresentation; const editedContent = editedPresentation @@ -321,7 +343,7 @@ export const UserMessageItem = React.memo( const impact = describeUserMessageEditImpact(resolvedSessionId); const confirmed = await confirmDanger( - t('message.editDialogTitle', { index: turnIndex + 1 }), + t('message.editDialogTitle', { index: actionTurnIndex + 1 }), ( <>

{t('message.editDialogIntro')}

@@ -338,10 +360,23 @@ export const UserMessageItem = React.memo( setEditSubmitting(true); try { + const historyReady = await flowChatStore.ensureSessionFullHistory( + resolvedSessionId, + 'user-message-edit', + ); + const hydratedTurnIndex = flowChatStore + .getState() + .sessions + .get(resolvedSessionId) + ?.dialogTurns.findIndex(turn => turn.id === turnId) ?? -1; + if (!historyReady || hydratedTurnIndex < 0) { + throw new Error(t('message.editDisabledHistoryNotReady')); + } + await editAndRerunUserMessage({ sessionId: resolvedSessionId, turnId, - turnIndex, + turnIndex: hydratedTurnIndex, originalContent: messageContent, editedContent, agentType: currentSession?.mode, @@ -376,6 +411,7 @@ export const UserMessageItem = React.memo( } }, [ cancelEdit, + actionTurnIndex, composerPresentation, currentSession?.mode, editDraft, @@ -385,7 +421,6 @@ export const UserMessageItem = React.memo( setEditSubmitting, t, turnId, - turnIndex, ]); // Toggle expanded state. @@ -476,7 +511,7 @@ export const UserMessageItem = React.memo( className={`user-message-item ${expanded ? 'user-message-item--expanded' : ''}${isFailed ? ' user-message-item--failed' : ''}`} data-testid="chat-user-message" data-turn-id={turnId} - data-status={dialogTurn?.status || ''} + data-status={resolvedTurnStatus || ''} data-failed={isFailed ? 'true' : 'false'} > {config?.showTimestamps && ( diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx index d3bb38cfd3..30fbfa97d2 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx @@ -33,7 +33,14 @@ export const VirtualItemRenderer = React.memo( const content = (() => { switch (item.type) { case 'user-message': - return ; + return ( + + ); case 'user-steering-message': return ( diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.layout.test.ts b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.layout.test.ts index c3f98f989d..aca78d3c4f 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.layout.test.ts +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.layout.test.ts @@ -3,7 +3,6 @@ import { estimateTextHeightFromLength, estimateVirtualMessageItemHeight, getVirtualMessageDefaultItemHeight, - mapInitialHistoryExpansionScrollTop, selectInitialHistoryRenderWindow, } from './virtualMessageListLayout'; import type { VirtualItem } from '../../store/modernFlowChatStore'; @@ -178,86 +177,3 @@ describe('selectInitialHistoryRenderWindow', () => { expect(window.omittedEstimatedHeightPx).toBe(0); }); }); - -describe('mapInitialHistoryExpansionScrollTop', () => { - const base = { - previousScrollHeight: 5000, - nextScrollHeight: 5600, - omittedEstimatedHeightPx: 3000, - clientHeight: 1000, - }; - - it('keeps a direct jump to the omitted history top at the real top', () => { - expect(mapInitialHistoryExpansionScrollTop({ - ...base, - previousScrollTop: 0, - wasAtBottom: false, - })).toBe(0); - }); - - it('maps positions inside the omitted history spacer by ratio', () => { - expect(mapInitialHistoryExpansionScrollTop({ - ...base, - previousScrollTop: 1500, - wasAtBottom: false, - })).toBe(1800); - }); - - it('keeps visible tail content stable after the omitted spacer boundary', () => { - expect(mapInitialHistoryExpansionScrollTop({ - ...base, - previousScrollTop: 3400, - wasAtBottom: false, - })).toBe(4000); - }); - - it('keeps bottom-pinned sessions at the new physical bottom', () => { - expect(mapInitialHistoryExpansionScrollTop({ - ...base, - previousScrollTop: 4000, - wasAtBottom: true, - })).toBe(4600); - }); - - it('falls back to physical height delta when the omitted estimate is zero', () => { - expect(mapInitialHistoryExpansionScrollTop({ - ...base, - previousScrollTop: 700, - omittedEstimatedHeightPx: 0, - wasAtBottom: false, - })).toBe(1300); - }); - - it('keeps omitted-history ratio stable when the expanded content is shorter than estimated', () => { - expect(mapInitialHistoryExpansionScrollTop({ - previousScrollTop: 1500, - previousScrollHeight: 5000, - nextScrollHeight: 2600, - omittedEstimatedHeightPx: 3000, - clientHeight: 1000, - wasAtBottom: false, - })).toBe(300); - }); - - it('clamps stale visible-tail scroll positions to the expanded scroll range', () => { - expect(mapInitialHistoryExpansionScrollTop({ - previousScrollTop: 7000, - previousScrollHeight: 5000, - nextScrollHeight: 5200, - omittedEstimatedHeightPx: 3000, - clientHeight: 1000, - wasAtBottom: false, - })).toBe(4200); - }); - - it('keeps bottom-pinned sessions at zero when content is shorter than the viewport', () => { - expect(mapInitialHistoryExpansionScrollTop({ - previousScrollTop: 4000, - previousScrollHeight: 5000, - nextScrollHeight: 800, - omittedEstimatedHeightPx: 3000, - clientHeight: 1000, - wasAtBottom: true, - })).toBe(0); - }); -}); diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss index 186595abbc..caa253c262 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss @@ -101,7 +101,8 @@ overflow-anchor: none; visibility: hidden; - &[data-history-paging-sentinel='loading'] { + &[data-history-paging-sentinel='loading'], + &[data-history-paging-sentinel='error'] { visibility: visible; } } @@ -117,33 +118,16 @@ overflow-anchor: none; } - &__static-scroller { - width: 100%; - height: 100%; - position: relative; - z-index: 1; - overflow-y: auto; - overflow-x: hidden; - overflow-anchor: none; - scrollbar-gutter: stable; - } - - &__static-items { + &__projection-handoff-items { width: 100%; } - &__initial-history-spacer { - width: 100%; - flex: 0 0 auto; - pointer-events: none; - overflow-anchor: none; - } - &__projection-handoff-overlay { position: absolute; inset: 0; z-index: 2; overflow: hidden; + scrollbar-gutter: stable; pointer-events: none; background: var(--color-bg-scene); contain: paint; diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx index fa24d5867a..5e90c2e4aa 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx @@ -8,6 +8,7 @@ import { VirtualMessageList, type VirtualMessageListRef, } from './VirtualMessageList'; +import { getLeadingVirtualItemIndexDelta } from './virtualMessageListLayout'; import { FlowChatViewportCoordinator } from './FlowChatViewportCoordinator'; import { clampPinReservationPxToViewport, @@ -49,12 +50,13 @@ const virtuosoMocks = vi.hoisted(() => ({ scrollerScrollTo: vi.fn(), scrollToIndex: vi.fn(), initialTopMostItemIndex: null as unknown, + initialTopMostItemIndexHistory: [] as unknown[], + increaseViewportBy: null as unknown, rangeChanged: null as (() => void) | null, })); const flowStoreMocks = vi.hoisted(() => ({ hasPendingSessionHistoryCompletion: vi.fn(() => false), hasDeferredSessionHistoryProjection: vi.fn(() => false), - requestSessionFullHistoryProjection: vi.fn(), revealPreviousSessionHistoryWindow: vi.fn(() => false), releaseSessionHistoryCompletionAfterInitialPaint: vi.fn(() => false), })); @@ -63,10 +65,16 @@ const inputStateMocks = vi.hoisted(() => ({ isExpanded: false, inputHeight: 0, })); +const activeSessionStateMocks = vi.hoisted(() => ({ + isProcessing: false, +})); const flowDiagnosticsMocks = vi.hoisted(() => ({ enabled: false, trace: vi.fn(), })); +const resizeObserverMocks = vi.hoisted(() => ({ + callbacks: [] as Array<() => void>, +})); vi.mock('@/infrastructure/diagnostics/flowChatDiagnostics', () => ({ flowChatDiagnostics: { @@ -96,6 +104,8 @@ vi.mock('react-virtuoso', () => ({ const scrollerRef = React.useRef(null); const [, rerender] = React.useReducer((value: number) => value + 1, 0); virtuosoMocks.initialTopMostItemIndex = props.initialTopMostItemIndex; + virtuosoMocks.initialTopMostItemIndexHistory.push(props.initialTopMostItemIndex); + virtuosoMocks.increaseViewportBy = props.increaseViewportBy; virtuosoMocks.rangeChanged = props.rangeChanged ?? null; React.useImperativeHandle(ref, () => ({ scrollTo: vi.fn(), @@ -190,7 +200,7 @@ vi.mock('../../store/modernFlowChatStore', () => { vi.mock('../../hooks/useActiveSessionState', () => ({ useActiveSessionState: () => ({ - isProcessing: false, + isProcessing: activeSessionStateMocks.isProcessing, processingPhase: null, }), })); @@ -206,7 +216,6 @@ vi.mock('../../store/FlowChatStore', () => ({ }), hasPendingSessionHistoryCompletion: flowStoreMocks.hasPendingSessionHistoryCompletion, hasDeferredSessionHistoryProjection: flowStoreMocks.hasDeferredSessionHistoryProjection, - requestSessionFullHistoryProjection: flowStoreMocks.requestSessionFullHistoryProjection, revealPreviousSessionHistoryWindow: flowStoreMocks.revealPreviousSessionHistoryWindow, releaseSessionHistoryCompletionAfterInitialPaint: flowStoreMocks.releaseSessionHistoryCompletionAfterInitialPaint, }, @@ -384,6 +393,11 @@ describe('VirtualMessageList session boundary', () => { })); vi.stubGlobal('cancelAnimationFrame', vi.fn()); vi.stubGlobal('ResizeObserver', class { + constructor(callback: ResizeObserverCallback) { + resizeObserverMocks.callbacks.push(() => { + callback([], this as unknown as ResizeObserver); + }); + } observe = vi.fn(); unobserve = vi.fn(); disconnect = vi.fn(); @@ -397,12 +411,13 @@ describe('VirtualMessageList session boundary', () => { virtuosoMocks.scrollerScrollTo.mockReset(); virtuosoMocks.scrollToIndex.mockReset(); virtuosoMocks.initialTopMostItemIndex = null; + virtuosoMocks.initialTopMostItemIndexHistory = []; + virtuosoMocks.increaseViewportBy = null; virtuosoMocks.rangeChanged = null; flowStoreMocks.hasPendingSessionHistoryCompletion.mockReset(); flowStoreMocks.hasPendingSessionHistoryCompletion.mockReturnValue(false); flowStoreMocks.hasDeferredSessionHistoryProjection.mockReset(); flowStoreMocks.hasDeferredSessionHistoryProjection.mockReturnValue(false); - flowStoreMocks.requestSessionFullHistoryProjection.mockReset(); flowStoreMocks.revealPreviousSessionHistoryWindow.mockReset(); flowStoreMocks.revealPreviousSessionHistoryWindow.mockReturnValue(false); flowStoreMocks.releaseSessionHistoryCompletionAfterInitialPaint.mockReset(); @@ -410,8 +425,20 @@ describe('VirtualMessageList session boundary', () => { inputStateMocks.isActive = false; inputStateMocks.isExpanded = false; inputStateMocks.inputHeight = 0; + activeSessionStateMocks.isProcessing = false; flowDiagnosticsMocks.enabled = false; flowDiagnosticsMocks.trace.mockReset(); + resizeObserverMocks.callbacks = []; + }); + + it('keeps Virtuoso absolute indexes stable across prepend and remote-side trimming', () => { + const previous = ['turn-3', 'turn-4', 'turn-5', 'turn-6'].map(createItem); + const prepended = ['turn-1', 'turn-2', 'turn-3', 'turn-4', 'turn-5'].map(createItem); + const trimmed = ['turn-4', 'turn-5', 'turn-6', 'turn-7'].map(createItem); + + const getStableKey = (item: VirtualItem) => `${item.type}:${item.turnId}`; + expect(getLeadingVirtualItemIndexDelta(previous, prepended, getStableKey)).toBe(-2); + expect(getLeadingVirtualItemIndexDelta(previous, trimmed, getStableKey)).toBe(1); }); afterEach(() => { @@ -444,6 +471,501 @@ describe('VirtualMessageList session boundary', () => { expect(firstFooter.style.minHeight).toBe('900px'); }); + it('rebases retained viewport geometry without converting inactive zero size into footer space', () => { + stateMocks.activeSession = createSessionWithTurns('session-a', ['turn-a', 'turn-b']); + stateMocks.virtualItems = ['turn-a', 'turn-b'].flatMap(turnId => [ + createItem(turnId), + createModelItem(turnId), + ]); + + act(() => { + root.render(); + }); + + const scroller = container.querySelector('[data-virtuoso-scroller="true"]'); + const footer = container.querySelector('.message-list-footer'); + expect(scroller).not.toBeNull(); + expect(footer).not.toBeNull(); + if (!scroller || !footer) { + return; + } + + let hidden = false; + let naturalContentHeight = 36_000; + let scrollTop = 34_500; + Object.defineProperties(scroller, { + clientHeight: { + configurable: true, + get: () => hidden ? 0 : 1_000, + }, + scrollHeight: { + configurable: true, + get: () => hidden + ? 0 + : naturalContentHeight + (Number.parseFloat(footer.style.height) || 0), + }, + scrollTop: { + configurable: true, + get: () => scrollTop, + set: (value: number) => { + scrollTop = value; + }, + }, + }); + vi.spyOn(scroller, 'getBoundingClientRect').mockImplementation(() => createRect({ + top: 0, + bottom: hidden ? 0 : 1_000, + height: hidden ? 0 : 1_000, + })); + + act(() => { + root.render(); + }); + const baselineFooterHeight = Number.parseFloat(footer.style.height); + + act(() => { + root.render(); + }); + hidden = true; + scrollTop = 0; + act(() => { + resizeObserverMocks.callbacks.at(-1)?.(); + }); + for (let frame = 0; frame < 4; frame += 1) { + flushAnimationFrame(); + } + + expect(Number.parseFloat(footer.style.height)).toBe(baselineFooterHeight); + + naturalContentHeight += 600; + hidden = false; + act(() => { + root.render(); + }); + for (let frame = 0; frame < 4; frame += 1) { + flushAnimationFrame(); + } + + expect(Number.parseFloat(footer.style.height)).toBeLessThanOrEqual( + baselineFooterHeight + 1, + ); + expect(scroller.scrollTop).toBeGreaterThan(0); + expect(scroller.scrollHeight).toBeLessThan(40_000); + }); + + it('reconciles a stream that ends while its viewport is inactive after reactivation', () => { + flowDiagnosticsMocks.enabled = true; + const listRef = React.createRef(); + const session = createSession('session-a', 'turn-a'); + session.dialogTurns[0].status = 'processing'; + session.dialogTurns[0].modelRounds = [{ + id: 'round-turn-a', + status: 'streaming', + isStreaming: true, + items: [], + startTime: 1, + } as typeof session.dialogTurns[number]['modelRounds'][number]]; + stateMocks.activeSession = session; + stateMocks.virtualItems = [createItem('turn-a'), createModelItem('turn-a')]; + + act(() => { + root.render(); + }); + + const scroller = container.querySelector('[data-virtuoso-scroller="true"]'); + const footer = container.querySelector('.message-list-footer'); + const target = container.querySelector( + '[data-turn-id="turn-a"][data-item-type="user-message"]', + ); + expect(scroller).not.toBeNull(); + expect(footer).not.toBeNull(); + expect(target).not.toBeNull(); + if (!scroller || !footer || !target) { + return; + } + + let hidden = false; + let scrollTop = 200; + Object.defineProperties(scroller, { + clientHeight: { + configurable: true, + get: () => hidden ? 0 : 1_000, + }, + scrollHeight: { + configurable: true, + get: () => hidden + ? 0 + : 1_200 + (Number.parseFloat(footer.style.height) || 0), + }, + scrollTop: { + configurable: true, + get: () => scrollTop, + set: (value: number) => { + scrollTop = value; + }, + }, + }); + vi.spyOn(scroller, 'getBoundingClientRect').mockImplementation(() => createRect({ + top: 0, + bottom: hidden ? 0 : 1_000, + height: hidden ? 0 : 1_000, + })); + vi.spyOn(target, 'getBoundingClientRect').mockImplementation(() => { + const top = hidden ? 0 : 700 - scrollTop; + return createRect({ top, bottom: top + 40, height: hidden ? 0 : 40 }); + }); + + act(() => { + listRef.current?.pinTurnToTopWithStatus('turn-a', { + behavior: 'auto', + pinMode: 'sticky-latest', + }); + }); + expect(Number.parseFloat(footer.style.height)).toBeGreaterThan(0); + + act(() => { + root.render(); + }); + hidden = true; + scrollTop = 0; + stateMocks.activeSession = { + ...session, + dialogTurns: session.dialogTurns.map(turn => ({ + ...turn, + status: 'completed', + modelRounds: turn.modelRounds.map(round => ({ + ...round, + status: 'completed', + isStreaming: false, + })), + })), + }; + flowDiagnosticsMocks.trace.mockClear(); + act(() => { + root.render(); + resizeObserverMocks.callbacks.at(-1)?.(); + }); + expect(flowDiagnosticsMocks.trace).not.toHaveBeenCalledWith(expect.objectContaining({ + location: 'VirtualMessageList.streamEndReconciliation', + })); + + hidden = false; + act(() => { + root.render(); + }); + for (let frame = 0; frame < 4; frame += 1) { + flushAnimationFrame(); + } + + expect(flowDiagnosticsMocks.trace).toHaveBeenCalledWith(expect.objectContaining({ + location: 'VirtualMessageList.streamEndReconciliation', + })); + expect(Number.parseFloat(footer.style.height)).toBeLessThanOrEqual(1_124); + expect(scroller.scrollHeight).toBeLessThan(3_000); + }); + + it('routes jump-to-latest through the presentation owner while reading a history window', () => { + const onRequestJumpToLatest = vi.fn(); + stateMocks.activeSession = createSession('session-a', 'turn-10'); + stateMocks.virtualItems = [createItem('turn-10')]; + + act(() => { + root.render( + , + ); + }); + + const jumpButton = container.querySelector('[data-testid="scroll-to-latest"]'); + expect(jumpButton?.dataset.visible).toBe('true'); + act(() => jumpButton?.click()); + expect(onRequestJumpToLatest).toHaveBeenCalledOnce(); + }); + + it('keeps canonical streaming output hidden from tail follow in history-window mode', () => { + activeSessionStateMocks.isProcessing = true; + stateMocks.activeSession = createSession('session-a', 'turn-10', { + dialogTurns: [{ + ...createSession('session-a', 'turn-10').dialogTurns[0], + status: 'processing', + }], + }); + const historyItems = ['turn-3', 'turn-4', 'turn-5'].map(createItem); + stateMocks.virtualItems = historyItems; + + act(() => { + root.render( + , + ); + }); + + expect(container.querySelector('[data-testid="flowchat-message-list"]')?.getAttribute( + 'data-streaming-output', + )).toBe('false'); + }); + + it('allows live-tail follow while retaining a history-window projection', () => { + activeSessionStateMocks.isProcessing = true; + stateMocks.activeSession = createSession('session-a', 'turn-10', { + dialogTurns: [{ + ...createSession('session-a', 'turn-10').dialogTurns[0], + status: 'processing', + }], + }); + const historyItems = ['turn-3', 'turn-4', 'turn-5'].map(createItem); + stateMocks.virtualItems = historyItems; + const onRequestJumpToLatest = vi.fn(); + + act(() => { + root.render( + , + ); + }); + + expect(container.querySelector('[data-testid="flowchat-message-list"]')?.getAttribute( + 'data-viewport-mode', + )).toBe('live-tail'); + expect(container.querySelector('[data-testid="flowchat-message-list"]')?.getAttribute( + 'data-streaming-output', + )).toBe('true'); + + const jumpButton = container.querySelector('[data-testid="scroll-to-latest"]'); + expect(jumpButton).not.toBeNull(); + act(() => jumpButton?.click()); + expect(onRequestJumpToLatest).not.toHaveBeenCalled(); + }); + + it('waits for quiet scroll input before capturing an adjacent history anchor', async () => { + let prepareViewportForPresentationCommit: + | (() => boolean | void | Promise) + | undefined; + let resolveBoundaryIntent: ((handled: boolean) => void) | undefined; + const boundaryIntent = new Promise(resolve => { + resolveBoundaryIntent = resolve; + }); + const onBoundaryIntent = vi.fn(( + _direction: 'before' | 'after', + options?: { + prepareViewportForPresentationCommit?: () => boolean | void | Promise; + }, + ) => { + prepareViewportForPresentationCommit = options?.prepareViewportForPresentationCommit; + return boundaryIntent; + }); + stateMocks.activeSession = createSession('session-a', 'turn-10'); + const initialItems = ['turn-3', 'turn-4', 'turn-5', 'turn-6', 'turn-7'].map(createItem); + stateMocks.virtualItems = initialItems; + + act(() => { + root.render( + , + ); + }); + + const scroller = container.querySelector('[data-testid="virtuoso"]'); + const anchor = container.querySelector('[data-turn-id="turn-3"]'); + expect(scroller).not.toBeNull(); + expect(anchor).not.toBeNull(); + if (!scroller || !anchor) return; + + for (let frame = 0; frame < 8 && rafCallbacks.length > 0; frame += 1) { + flushAnimationFrame(); + } + + setScrollerGeometry(scroller, { + clientHeight: 100, + scrollHeight: 500, + scrollTop: 40, + }); + scroller.getBoundingClientRect = () => createRect({ top: 0, bottom: 100, height: 100 }); + let anchorDocumentTop = 60; + anchor.getBoundingClientRect = () => { + const top = anchorDocumentTop - scroller.scrollTop; + return createRect({ top, bottom: top + 20, height: 20 }); + }; + + vi.useFakeTimers(); + try { + act(() => { + scroller.dispatchEvent(new WheelEvent('wheel', { deltaY: -20, bubbles: true })); + }); + expect(onBoundaryIntent).toHaveBeenCalledWith('before', expect.objectContaining({ + prepareViewportForPresentationCommit: expect.any(Function), + })); + + scroller.scrollTop = 80; + let preparationSettled = false; + const preparation = Promise.resolve(prepareViewportForPresentationCommit?.()).then(result => { + preparationSettled = true; + return result; + }); + + act(() => { + vi.advanceTimersByTime(319); + scroller.dispatchEvent(new WheelEvent('wheel', { deltaY: -20, bubbles: true })); + vi.advanceTimersByTime(319); + }); + await Promise.resolve(); + expect(preparationSettled).toBe(false); + + await act(async () => { + vi.advanceTimersByTime(1); + expect(await preparation).not.toBe(false); + }); + + const extendedItems = ['turn-1', 'turn-2', ...initialItems.map(item => item.turnId)].map(createItem); + act(() => { + anchorDocumentTop = 120; + root.render( + , + ); + resolveBoundaryIntent?.(true); + }); + await act(async () => { + await boundaryIntent; + }); + flushAnimationFrame(); + flushAnimationFrame(); + + expect(scroller.scrollTop).toBe(140); + expect(container.querySelector('[data-history-paging-sentinel="loading"]')).not.toBeNull(); + + act(() => { + root.render( + , + ); + }); + expect(container.querySelector('[data-history-paging-sentinel]')).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + it('releases the visible element anchor when an adjacent history request is not handled', async () => { + const onBoundaryIntent = vi.fn(async () => false); + stateMocks.activeSession = createSession('session-a', 'turn-10'); + const initialItems = ['turn-3', 'turn-4', 'turn-5', 'turn-6', 'turn-7'].map(createItem); + stateMocks.virtualItems = initialItems; + + act(() => { + root.render( + , + ); + }); + + const scroller = container.querySelector('[data-testid="virtuoso"]'); + const anchor = container.querySelector('[data-turn-id="turn-3"]'); + expect(scroller).not.toBeNull(); + expect(anchor).not.toBeNull(); + if (!scroller || !anchor) return; + + for (let frame = 0; frame < 8 && rafCallbacks.length > 0; frame += 1) { + flushAnimationFrame(); + } + + setScrollerGeometry(scroller, { + clientHeight: 100, + scrollHeight: 500, + scrollTop: 40, + }); + scroller.getBoundingClientRect = () => createRect({ top: 0, bottom: 100, height: 100 }); + let anchorDocumentTop = 60; + anchor.getBoundingClientRect = () => { + const top = anchorDocumentTop - scroller.scrollTop; + return createRect({ top, bottom: top + 20, height: 20 }); + }; + + act(() => { + scroller.dispatchEvent(new WheelEvent('wheel', { deltaY: -20, bubbles: true })); + }); + await act(async () => { + await Promise.resolve(); + }); + expect(onBoundaryIntent).toHaveBeenCalledWith('before', expect.objectContaining({ + prepareViewportForPresentationCommit: expect.any(Function), + })); + + scroller.scrollTop = 80; + anchorDocumentTop = 60; + flushAnimationFrame(); + + expect(scroller.scrollTop).toBe(80); + }); + it('reports every turn intersecting the readable viewport in DOM order', () => { stateMocks.setVisibleTurnInfo.mockImplementation((info: unknown) => { stateMocks.visibleTurnInfo = info; @@ -1434,7 +1956,7 @@ describe('VirtualMessageList session boundary', () => { } }); - it('keeps static initial history position when background updates arrive after an upward scroll', () => { + it('keeps the initial Virtuoso position when background updates arrive after an upward scroll', () => { let nowMs = 1_000; const nowSpy = vi.spyOn(performance, 'now').mockImplementation(() => nowMs); @@ -1516,6 +2038,17 @@ describe('VirtualMessageList session boundary', () => { root.render(); }); + const scroller = container.querySelector('[data-virtuoso-scroller="true"]'); + expect(scroller).not.toBeNull(); + if (!scroller) { + return; + } + setScrollerGeometry(scroller, { + scrollHeight: 6_000, + clientHeight: 1_000, + scrollTop: 4_000, + }); + expect( container.querySelector( `[data-turn-id="${targetTurnId}"][data-item-type="user-message"]`, @@ -1537,22 +2070,19 @@ describe('VirtualMessageList session boundary', () => { align: 'start', behavior: 'auto', })); + expect(virtuosoMocks.increaseViewportBy).toEqual({ + top: 2_000, + bottom: 2_000, + }); const target = container.querySelector( `[data-turn-id="${targetTurnId}"][data-item-type="user-message"]`, ); - const scroller = container.querySelector('[data-virtuoso-scroller="true"]'); expect(target).not.toBeNull(); - expect(scroller).not.toBeNull(); - if (!target || !scroller) { + if (!target) { return; } - setScrollerGeometry(scroller, { - scrollHeight: 6_000, - clientHeight: 1_000, - scrollTop: 4_000, - }); vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue(createRect({ top: 0, bottom: 1_000, @@ -1571,30 +2101,235 @@ describe('VirtualMessageList session boundary', () => { flushAnimationFrame(); flushAnimationFrame(); flushAnimationFrame(); + flushAnimationFrame(); expect(scroller.scrollTop).toBe(4_443); expect(target.getBoundingClientRect().top).toBe(57); expect(virtuosoMocks.scrollToIndex).toHaveBeenCalledTimes(1); + expect(virtuosoMocks.increaseViewportBy).toEqual({ + top: 600, + bottom: 600, + }); }); - it('keeps the existing sticky range while a distant turn is materialized', () => { + it('materializes the first turn before pinning when it is outside the rendered range', () => { const listRef = React.createRef(); - const turnIds = Array.from({ length: 24 }, (_, index) => `turn-${String(index + 1).padStart(2, '0')}`); - const latestTurnId = turnIds[turnIds.length - 1]; - const targetTurnId = turnIds[1]; - const session = createSessionWithTurns('session-a', turnIds); - const latestTurn = session.dialogTurns[session.dialogTurns.length - 1]; - latestTurn.status = 'processing'; - latestTurn.modelRounds = [{ - id: `round-${latestTurnId}`, - status: 'streaming', - isStreaming: true, - items: [], - startTime: 1, - } as typeof latestTurn.modelRounds[number]]; - stateMocks.activeSession = session; - stateMocks.virtualItems = turnIds.flatMap(turnId => [ - createItem(turnId), - createModelItem(turnId), + const turnIds = Array.from( + { length: 9 }, + (_, index) => `turn-${String(index + 1).padStart(2, '0')}`, + ); + + stateMocks.activeSession = createSessionWithTurns('session-a', turnIds); + stateMocks.virtualItems = turnIds.flatMap(turnId => [ + createItem(turnId), + createModelItem(turnId), + ]); + virtuosoMocks.renderedRange = { start: 12, end: 18 }; + + act(() => { + root.render(); + }); + + expect(virtuosoMocks.initialTopMostItemIndexHistory).toContainEqual({ + index: 17, + align: 'end', + }); + expect(virtuosoMocks.initialTopMostItemIndex).toBeUndefined(); + expect(container.querySelector( + '[data-turn-id="turn-01"][data-item-type="user-message"]', + )).toBeNull(); + virtuosoMocks.scrollToIndex.mockClear(); + const navigationRenderStart = virtuosoMocks.initialTopMostItemIndexHistory.length; + + let status: ReturnType = 'rejected'; + act(() => { + status = listRef.current?.pinTurnToTopWithStatus('turn-01', { + behavior: 'auto', + pinMode: 'transient', + }) ?? 'rejected'; + }); + + expect(status).toBe('pending'); + expect(virtuosoMocks.scrollToIndex).toHaveBeenCalledWith(expect.objectContaining({ + index: 0, + align: 'start', + behavior: 'auto', + })); + expect(virtuosoMocks.initialTopMostItemIndex).toBeUndefined(); + const navigationInitialPositionProps = + virtuosoMocks.initialTopMostItemIndexHistory.slice(navigationRenderStart); + expect(navigationInitialPositionProps.length).toBeGreaterThan(0); + expect(navigationInitialPositionProps.every(value => value === undefined)).toBe(true); + expect(container.querySelector( + '[data-turn-id="turn-01"][data-item-type="user-message"]', + )).not.toBeNull(); + }); + + it('settles best-effort navigation at the natural maximum without pin footer space', () => { + const listRef = React.createRef(); + stateMocks.activeSession = createSessionWithTurns('session-a', ['turn-a', 'turn-b']); + stateMocks.virtualItems = [createItem('turn-a'), createItem('turn-b')]; + + act(() => { + root.render(); + }); + + const scroller = container.querySelector('[data-virtuoso-scroller="true"]'); + const footer = container.querySelector('.message-list-footer'); + const target = container.querySelector( + '[data-turn-id="turn-b"][data-item-type="user-message"]', + ); + expect(scroller).not.toBeNull(); + expect(footer).not.toBeNull(); + expect(target).not.toBeNull(); + if (!scroller || !footer || !target) { + return; + } + + setScrollerGeometry(scroller, { + scrollHeight: 1_200, + clientHeight: 1_000, + scrollTop: 0, + }); + vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue(createRect({ + top: 0, + bottom: 1_000, + height: 1_000, + })); + vi.spyOn(target, 'getBoundingClientRect').mockImplementation(() => { + const top = 900 - scroller.scrollTop; + return createRect({ top, bottom: top + 40, height: 40 }); + }); + const footerHeightBefore = footer.style.height; + + let status: ReturnType = 'rejected'; + act(() => { + status = listRef.current?.pinTurnToTopWithStatus('turn-b', { + behavior: 'auto', + pinMode: 'transient', + alignmentPolicy: 'best-effort', + }) ?? 'rejected'; + }); + + expect(status).toBe('settled'); + expect(scroller.scrollTop).toBe(200); + expect(footer.style.height).toBe(footerHeightBefore); + expect(target.getBoundingClientRect().top).toBeGreaterThan(57); + }); + + it('keeps sticky-latest exact even when a best-effort policy is supplied', () => { + const listRef = React.createRef(); + stateMocks.activeSession = createSessionWithTurns('session-a', ['turn-a', 'turn-b']); + const latestTurn = stateMocks.activeSession.dialogTurns[1]; + latestTurn.status = 'processing'; + latestTurn.modelRounds = [{ + id: 'round-turn-b', + status: 'streaming', + isStreaming: true, + items: [], + startTime: 1, + } as typeof latestTurn.modelRounds[number]]; + stateMocks.virtualItems = [createItem('turn-a'), createItem('turn-b')]; + + act(() => { + root.render(); + }); + + const scroller = container.querySelector('[data-virtuoso-scroller="true"]'); + const footer = container.querySelector('.message-list-footer'); + const target = container.querySelector( + '[data-turn-id="turn-b"][data-item-type="user-message"]', + ); + expect(scroller).not.toBeNull(); + expect(footer).not.toBeNull(); + expect(target).not.toBeNull(); + if (!scroller || !footer || !target) { + return; + } + + Object.defineProperties(scroller, { + clientHeight: { configurable: true, value: 1_000 }, + scrollHeight: { + configurable: true, + get: () => 1_200 + (Number.parseFloat(footer.style.height) || 0), + }, + scrollTop: { configurable: true, writable: true, value: 200 }, + }); + vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue(createRect({ + top: 0, + bottom: 1_000, + height: 1_000, + })); + vi.spyOn(target, 'getBoundingClientRect').mockImplementation(() => { + const top = 700 - scroller.scrollTop; + return createRect({ top, bottom: top + 40, height: 40 }); + }); + + let status: ReturnType = 'rejected'; + act(() => { + status = listRef.current?.pinTurnToTopWithStatus('turn-b', { + behavior: 'auto', + pinMode: 'sticky-latest', + alignmentPolicy: 'best-effort', + }) ?? 'rejected'; + }); + + expect(status).toBe('settled'); + expect(Number.parseFloat(footer.style.height)).toBeGreaterThan(0); + expect(target.getBoundingClientRect().top).toBe(57); + }); + + it('uses a prepared turn only for the initial Virtuoso mount', () => { + const listRef = React.createRef(); + const turnIds = ['turn-01', 'turn-02', 'turn-03']; + + stateMocks.activeSession = createSessionWithTurns('session-a', turnIds); + stateMocks.virtualItems = []; + + act(() => { + root.render(); + }); + act(() => { + listRef.current?.prepareTurnPinToTop('turn-02', { + behavior: 'auto', + pinMode: 'transient', + }); + }); + + virtuosoMocks.initialTopMostItemIndexHistory = []; + stateMocks.virtualItems = turnIds.flatMap(turnId => [ + createItem(turnId), + createModelItem(turnId), + ]); + act(() => { + root.render(); + }); + + expect(virtuosoMocks.initialTopMostItemIndexHistory).toContainEqual({ + index: 2, + align: 'start', + }); + expect(virtuosoMocks.initialTopMostItemIndex).toBeUndefined(); + }); + + it('keeps the existing sticky range while a distant turn is materialized', () => { + const listRef = React.createRef(); + const turnIds = Array.from({ length: 24 }, (_, index) => `turn-${String(index + 1).padStart(2, '0')}`); + const latestTurnId = turnIds[turnIds.length - 1]; + const targetTurnId = turnIds[1]; + const session = createSessionWithTurns('session-a', turnIds); + const latestTurn = session.dialogTurns[session.dialogTurns.length - 1]; + latestTurn.status = 'processing'; + latestTurn.modelRounds = [{ + id: `round-${latestTurnId}`, + status: 'streaming', + isStreaming: true, + items: [], + startTime: 1, + } as typeof latestTurn.modelRounds[number]]; + stateMocks.activeSession = session; + stateMocks.virtualItems = turnIds.flatMap(turnId => [ + createItem(turnId), + createModelItem(turnId), ]); virtuosoMocks.renderedRange = { start: 40, end: 48 }; @@ -1681,7 +2416,7 @@ describe('VirtualMessageList session boundary', () => { expect(Number.parseFloat(footer.style.height)).toBeGreaterThan(0); }); - it('uses a pending static target as Virtuoso initial position during renderer handoff', () => { + it('routes turn navigation through Virtuoso while the initial snapshot is visible', () => { const listRef = React.createRef(); const turnIds = Array.from({ length: 8 }, (_, index) => `turn-${index}`); const targetTurnId = 'turn-1'; @@ -1694,11 +2429,14 @@ describe('VirtualMessageList session boundary', () => { createItem(turnId), createModelItem(turnId), ]); + virtuosoMocks.renderedRange = { start: 12, end: 16 }; act(() => { root.render(); }); - expect(container.querySelector('[data-initial-history-render-windowed]')).not.toBeNull(); + expect(container.querySelector('[data-testid="virtuoso"]')).not.toBeNull(); + expect(container.querySelector('[data-initial-history-snapshot="true"]')).not.toBeNull(); + virtuosoMocks.scrollToIndex.mockClear(); let status: ReturnType = 'rejected'; act(() => { @@ -1706,19 +2444,105 @@ describe('VirtualMessageList session boundary', () => { behavior: 'smooth', pinMode: 'transient', }) ?? 'rejected'; - stateMocks.activeSession = createSessionWithTurns('session-a', turnIds, { - contextRestoreState: 'ready', - isPartial: false, - historyState: 'ready', - }); - root.render(); }); expect(status).toBe('pending'); - expect(virtuosoMocks.initialTopMostItemIndex).toEqual({ + expect(virtuosoMocks.scrollToIndex).toHaveBeenCalledWith(expect.objectContaining({ index: 2, align: 'start', + behavior: 'auto', + })); + expect(container.querySelector('[data-history-projection-handoff="true"]')?.getAttribute( + 'data-target-turn-id', + )).toBe(targetTurnId); + }); + + it('releases the initial snapshot after Virtuoso renders readable target content', () => { + stateMocks.activeSession = createSessionWithTurns('session-a', ['turn-a', 'turn-b'], { + contextRestoreState: 'pending', + isPartial: true, + historyState: 'ready', + }); + stateMocks.virtualItems = [createItem('turn-a'), createItem('turn-b')]; + + act(() => { + root.render(); + }); + + expect(container.querySelector('[data-testid="virtuoso"]')).not.toBeNull(); + expect(container.querySelector('[data-initial-history-snapshot="true"]')).not.toBeNull(); + + const scroller = container.querySelector('[data-testid="virtuoso"]'); + const target = scroller?.querySelector( + '[data-turn-id="turn-b"][data-item-type="user-message"]', + ); + expect(scroller).not.toBeNull(); + expect(target).not.toBeNull(); + if (!scroller || !target) { + return; + } + vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue(createRect({ + top: 0, + bottom: 1_000, + height: 1_000, + })); + vi.spyOn(target, 'getBoundingClientRect').mockReturnValue(createRect({ + top: 200, + bottom: 240, + width: 400, + height: 40, + })); + Object.defineProperty(target, 'innerText', { + configurable: true, + value: 'turn-b', + }); + + for (let frame = 0; frame < 5; frame += 1) { + flushAnimationFrame(); + } + + expect(container.querySelector('[data-initial-history-snapshot="true"]')).toBeNull(); + expect(container.querySelector('[data-testid="virtuoso"]')).not.toBeNull(); + }); + + it('does not render an initial projection handoff over a history-window presentation', () => { + stateMocks.activeSession = createSessionWithTurns('session-a', ['turn-a', 'turn-b'], { + contextRestoreState: 'pending', + isPartial: true, + historyState: 'ready', }); + const tailItems = [createItem('turn-a'), createItem('turn-b')]; + stateMocks.virtualItems = tailItems; + + act(() => { + root.render(); + }); + expect(container.querySelector('[data-history-projection-handoff="true"]')).not.toBeNull(); + + const historyItems = [ + createItem('turn-a'), + createModelItem('turn-a'), + createItem('turn-b'), + createModelItem('turn-b'), + ]; + act(() => { + root.render( + , + ); + }); + + expect(container.querySelector('[data-history-projection-handoff="true"]')).toBeNull(); + expect(container.querySelector('[data-testid="virtuoso"]')).not.toBeNull(); }); it('does not let a canceled pending sticky pin RAF restore provisional footer space', () => { @@ -1960,7 +2784,7 @@ describe('VirtualMessageList session boundary', () => { }).getBoundingClientRect; }); - it('renders only a bounded static-history window around an omitted search result', () => { + it('keeps the initial snapshot bounded while search navigation uses Virtuoso', () => { const listRef = React.createRef(); const turnIds = Array.from({ length: 8 }, (_, index) => `turn-${index}`); const targetTurnId = 'turn-1'; @@ -1976,17 +2800,27 @@ describe('VirtualMessageList session boundary', () => { createItem(turnId), createModelItem(turnId), ]); + virtuosoMocks.renderedRange = { start: 12, end: 16 }; act(() => { root.render(); }); - expect(container.querySelector( + const initialSnapshot = container.querySelector('[data-initial-history-snapshot="true"]'); + expect(initialSnapshot).not.toBeNull(); + expect(container.querySelector('[data-testid="virtuoso"]')).not.toBeNull(); + expect(initialSnapshot?.querySelectorAll('.virtual-item-wrapper').length) + .toBeLessThan(stateMocks.virtualItems.length); + expect(initialSnapshot?.querySelector( `[data-turn-id="${targetTurnId}"][data-item-type="user-message"]`, )).toBeNull(); - expect(container.querySelector( + expect(initialSnapshot?.querySelector( `[data-turn-id="${latestTurnId}"][data-item-type="user-message"]`, )).not.toBeNull(); + expect(container.querySelector( + `[data-turn-id="${targetTurnId}"][data-item-type="user-message"]`, + )).toBeNull(); + virtuosoMocks.scrollToIndex.mockClear(); act(() => { listRef.current?.scrollToSearchMatch({ @@ -1994,22 +2828,18 @@ describe('VirtualMessageList session boundary', () => { query: targetTurnId, }); }); - flushAnimationFrame(); + expect(virtuosoMocks.scrollToIndex).toHaveBeenCalledWith(expect.objectContaining({ + index: 2, + align: 'center', + behavior: 'auto', + })); expect(container.querySelector( `[data-turn-id="${targetTurnId}"][data-item-type="user-message"]`, )).not.toBeNull(); - expect(container.querySelector( - `[data-turn-id="${latestTurnId}"][data-item-type="user-message"]`, - )).toBeNull(); - expect(container.querySelector( - '[data-history-initial-render-tail-spacer="true"]', - )).not.toBeNull(); - expect(container.querySelectorAll('.virtual-item-wrapper').length) - .toBeLessThan(stateMocks.virtualItems.length); }); - it('keeps a static initial history turn pin from being pulled back to bottom by the initial guard', () => { + it('keeps Virtuoso navigation stable while an initial snapshot handoff is active', () => { let nowMs = 1_000; const nowSpy = vi.spyOn(performance, 'now').mockImplementation(() => nowMs); const listRef = React.createRef(); @@ -2053,11 +2883,15 @@ describe('VirtualMessageList session boundary', () => { bottom: 1_040, height: 1_000, })); - vi.spyOn(target, 'getBoundingClientRect').mockReturnValue(createRect({ - top: -1_200, - bottom: -1_160, - height: 40, - })); + const targetDocumentTop = 2_800; + vi.spyOn(target, 'getBoundingClientRect').mockImplementation(() => { + const top = targetDocumentTop - scroller.scrollTop; + return createRect({ + top, + bottom: top + 40, + height: 40, + }); + }); act(() => { scroller.dispatchEvent(new Event('scroll', { bubbles: true })); @@ -2079,7 +2913,7 @@ describe('VirtualMessageList session boundary', () => { root.render(); }); - expect(scroller.scrollTop).toBe(4_200); + expect(scroller.scrollTop).toBe(4_000); let didPin = false; act(() => { @@ -2088,7 +2922,8 @@ describe('VirtualMessageList session boundary', () => { expect(didPin).toBe(true); const pinnedScrollTop = scroller.scrollTop; - expect(pinnedScrollTop).toBeLessThan(4_200); + expect(pinnedScrollTop).toBe(2_703); + expect(target.getBoundingClientRect().top).toBe(97); expect(rafCallbacks.length).toBeGreaterThan(0); for (let frame = 0; frame < 4; frame += 1) { @@ -2101,7 +2936,7 @@ describe('VirtualMessageList session boundary', () => { } }); - it('keeps latest reachable after pinning an older turn outside the static initial tail', () => { + it('keeps latest reachable after Virtuoso materializes an older turn during snapshot handoff', () => { const listRef = React.createRef(); const onUserScrollIntent = vi.fn(); const turnIds = Array.from({ length: 8 }, (_, index) => `turn-${index}`); @@ -2118,6 +2953,7 @@ describe('VirtualMessageList session boundary', () => { createItem(turnId), createModelItem(turnId), ]); + virtuosoMocks.renderedRange = { start: 12, end: 16 }; act(() => { root.render(); @@ -2162,11 +2998,11 @@ describe('VirtualMessageList session boundary', () => { }); expect(pinStatus).toBe('pending'); - expect(scroller.scrollTop).toBeLessThan(11_000); + expect(scroller.scrollTop).toBe(11_000); expect(container.querySelector(`[data-turn-id="${targetTurnId}"][data-item-type="user-message"]`)).not.toBeNull(); - expect(container.querySelector(`[data-turn-id="${latestTurnId}"][data-item-type="user-message"]`)).toBeNull(); - expect(container.querySelector('[data-history-initial-render-tail-spacer="true"]')).not.toBeNull(); - expect(container.querySelector('[data-testid="scroll-to-latest"]')?.getAttribute('data-visible')).toBe('true'); + expect(container.querySelector( + `[data-testid="virtuoso"] [data-turn-id="${latestTurnId}"][data-item-type="user-message"]`, + )).toBeNull(); act(() => { container.querySelector('[data-testid="scroll-to-latest"]')?.dispatchEvent( @@ -2175,14 +3011,13 @@ describe('VirtualMessageList session boundary', () => { }); expect(onUserScrollIntent).toHaveBeenCalledTimes(1); - expect(container.querySelector(`[data-turn-id="${latestTurnId}"][data-item-type="user-message"]`)).not.toBeNull(); expect(scroller.scrollTop).toBe(11_000); }); - it('does not clear a static history pin when smooth navigation reports the old bottom first', () => { + it('keeps a Virtuoso history pin when materialization reports the old bottom first', () => { const listRef = React.createRef(); const turnIds = Array.from({ length: 8 }, (_, index) => `turn-${index}`); - const targetTurnId = 'turn-0'; + const targetTurnId = 'turn-1'; const latestTurnId = 'turn-7'; stateMocks.activeSession = createSessionWithTurns('session-a', turnIds, { @@ -2195,6 +3030,7 @@ describe('VirtualMessageList session boundary', () => { createItem(turnId), createModelItem(turnId), ]); + virtuosoMocks.renderedRange = { start: 12, end: 16 }; act(() => { root.render(); @@ -2236,7 +3072,11 @@ describe('VirtualMessageList session boundary', () => { }); expect(pinStatus).toBe('pending'); - expect(scrollTo).toHaveBeenCalledWith(expect.objectContaining({ behavior: 'smooth' })); + expect(virtuosoMocks.scrollToIndex).toHaveBeenCalledWith(expect.objectContaining({ + index: 2, + align: 'start', + behavior: 'auto', + })); expect(container.querySelector( `[data-turn-id="${targetTurnId}"][data-item-type="user-message"]`, )).not.toBeNull(); @@ -2255,7 +3095,6 @@ describe('VirtualMessageList session boundary', () => { expect(container.querySelector( `[data-turn-id="${targetTurnId}"][data-item-type="user-message"]`, )).not.toBeNull(); - expect(container.querySelector('[data-history-initial-render-tail-spacer="true"]')).not.toBeNull(); // A real downward user gesture is still allowed to return to the latest // window once the pane reaches its physical bottom. @@ -2265,11 +3104,11 @@ describe('VirtualMessageList session boundary', () => { }); expect(container.querySelector( - `[data-turn-id="${latestTurnId}"][data-item-type="user-message"]`, + `[data-turn-id="${targetTurnId}"][data-item-type="user-message"]`, )).not.toBeNull(); }); - it('keeps static initial history position when footer height changes after an upward scroll', () => { + it('keeps the initial Virtuoso position when footer height changes after an upward scroll', () => { let nowMs = 1_000; const nowSpy = vi.spyOn(performance, 'now').mockImplementation(() => nowMs); @@ -2331,7 +3170,7 @@ describe('VirtualMessageList session boundary', () => { } }); - it('does not treat a collapse-compensated bottom as user-left-bottom', () => { + it('does not let the initial snapshot become a collapse scroll writer', () => { stateMocks.activeSession = createSessionWithTurns('session-a', ['turn-a', 'turn-b'], { isHistorical: false, historyState: 'ready', @@ -2392,8 +3231,7 @@ describe('VirtualMessageList session boundary', () => { root.render(); }); - expect(scroller.scrollTop).toBeGreaterThan(4_000); - expect(scroller.scrollTop).toBeLessThanOrEqual(4_600); + expect(scroller.scrollTop).toBe(4_000); }); it('does not expose stale history projection handoff snapshots across sessions', () => { @@ -2415,7 +3253,7 @@ describe('VirtualMessageList session boundary', () => { it('does not request full history projection for ordinary upward reading scroll', () => { flowStoreMocks.hasDeferredSessionHistoryProjection.mockReturnValue(true); - stateMocks.activeSession = createSession('session-a', 'turn-a', { + stateMocks.activeSession = createSessionWithTurns('session-a', ['turn-a'], { isHistorical: false, historyState: 'ready', contextRestoreState: 'ready', @@ -2457,11 +3295,10 @@ describe('VirtualMessageList session boundary', () => { flushAnimationFrame(); flushAnimationFrame(); - expect(flowStoreMocks.requestSessionFullHistoryProjection).not.toHaveBeenCalled(); expect(flowStoreMocks.revealPreviousSessionHistoryWindow).toHaveBeenCalledWith('session-a', 'wheel-up'); }); - it('expands partial static history before prepending older turns', () => { + it('paginates partial history through Virtuoso after upward intent', () => { flowStoreMocks.hasDeferredSessionHistoryProjection.mockReturnValue(true); flowStoreMocks.revealPreviousSessionHistoryWindow.mockReturnValue(true); stateMocks.activeSession = createSessionWithTurns('session-a', ['turn-3', 'turn-4', 'turn-5'], { @@ -2481,22 +3318,20 @@ describe('VirtualMessageList session boundary', () => { root.render(); }); - const staticScroller = container.querySelector('[data-virtuoso-scroller="true"]'); - expect(staticScroller).not.toBeNull(); - expect(container.querySelector('[data-initial-history-render-windowed]')).not.toBeNull(); + const scroller = container.querySelector('[data-virtuoso-scroller="true"]'); + expect(scroller).not.toBeNull(); + expect(container.querySelector('[data-testid="virtuoso"]')).not.toBeNull(); + expect(container.querySelector('[data-initial-history-snapshot="true"]')).not.toBeNull(); act(() => { - staticScroller?.dispatchEvent(new WheelEvent('wheel', { + scroller?.dispatchEvent(new WheelEvent('wheel', { deltaY: -120, bubbles: true, })); }); - expect(container.querySelector('[data-initial-history-render-windowed]')).not.toBeNull(); - expect(container.querySelector('[data-testid="virtuoso"]')).toBeNull(); - expect(container.querySelector('[data-history-initial-render-spacer="true"]')).toBeNull(); + expect(container.querySelector('[data-initial-history-snapshot="true"]')).toBeNull(); expect(container.querySelector('[data-turn-id="turn-3"]')).not.toBeNull(); - expect(container.querySelector('[data-history-paging-sentinel="loading"]')).not.toBeNull(); expect(flowStoreMocks.revealPreviousSessionHistoryWindow).not.toHaveBeenCalled(); flushAnimationFrame(); @@ -2525,12 +3360,11 @@ describe('VirtualMessageList session boundary', () => { root.render(); }); - expect(container.querySelector('[data-history-initial-render-spacer="true"]')).toBeNull(); expect(container.querySelector('[data-turn-id="turn-3"]')).not.toBeNull(); expect(container.querySelector('[data-history-paging-sentinel]')).toBeNull(); }); - it('waits until the static history boundary is near before starting pagination', () => { + it('waits until the Virtuoso history boundary is near before starting pagination', () => { flowStoreMocks.hasDeferredSessionHistoryProjection.mockReturnValue(true); stateMocks.activeSession = createSessionWithTurns('session-a', ['turn-3', 'turn-4', 'turn-5'], { isHistorical: true, @@ -2550,18 +3384,15 @@ describe('VirtualMessageList session boundary', () => { }); const scroller = container.querySelector('[data-virtuoso-scroller="true"]'); - const spacer = container.querySelector('[data-history-initial-render-spacer="true"]'); expect(scroller).not.toBeNull(); - expect(spacer).not.toBeNull(); - if (!scroller || !spacer) { + if (!scroller) { return; } - const spacerHeight = Number.parseFloat(spacer.style.height); setScrollerGeometry(scroller, { - scrollHeight: spacerHeight + 3_000, + scrollHeight: 5_000, clientHeight: 1_000, - scrollTop: spacerHeight + 500, + scrollTop: 2_000, }); act(() => { @@ -2571,17 +3402,16 @@ describe('VirtualMessageList session boundary', () => { flushAnimationFrame(); flushAnimationFrame(); - expect(container.querySelector('[data-initial-history-render-windowed]')).not.toBeNull(); expect(flowStoreMocks.revealPreviousSessionHistoryWindow).not.toHaveBeenCalled(); - scroller.scrollTop = spacerHeight + 100; + scroller.scrollTop = 1_000; act(() => { scroller.dispatchEvent(new Event('scroll', { bubbles: true })); }); + flushAnimationFrame(); + flushAnimationFrame(); - expect(container.querySelector('[data-initial-history-render-windowed]')).not.toBeNull(); - expect(container.querySelector('[data-history-initial-render-spacer="true"]')).toBeNull(); - expect(container.querySelector('[data-history-paging-sentinel="loading"]')).not.toBeNull(); + expect(flowStoreMocks.revealPreviousSessionHistoryWindow).toHaveBeenCalledWith('session-a', 'scroll-near-partial-history-boundary'); }); it('does not reveal previous history for upward scroll away from the history boundary', () => { @@ -2631,11 +3461,212 @@ describe('VirtualMessageList session boundary', () => { flushAnimationFrame(); flushAnimationFrame(); - expect(flowStoreMocks.requestSessionFullHistoryProjection).not.toHaveBeenCalled(); expect(flowStoreMocks.revealPreviousSessionHistoryWindow).not.toHaveBeenCalled(); expect(container.querySelector('[data-history-boundary-status]')).toBeNull(); }); + it('requests an adjacent Turn window for catalog-backed tail history', async () => { + const onHistoryWindowBoundaryIntent = vi.fn(async ( + _direction: 'before' | 'after', + _options?: { + prepareViewportForPresentationCommit?: () => boolean | void | Promise; + }, + ) => { + return true; + }); + stateMocks.activeSession = createSession('session-a', 'turn-a', { + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'ready', + isPartial: true, + loadedTurnCount: 1, + totalTurnCount: 20, + turnCatalog: { + schemaVersion: 1, + sessionId: 'session-a', + revision: 'catalog-1', + totalTurnCount: 20, + complete: true, + entries: Array.from({ length: 20 }, (_, ordinal) => ({ + ordinal, + storageTurnIndex: ordinal, + turnId: `turn-${ordinal + 1}`, + preview: `Prompt ${ordinal + 1}`, + previewTruncated: false, + })), + }, + dialogTurns: [ + { + id: 'turn-a', + sessionId: 'session-a', + userMessage: { id: 'user-turn-a', content: 'latest loaded prompt', timestamp: 1 }, + modelRounds: [], + status: 'completed', + startTime: 1, + }, + ], + }); + stateMocks.virtualItems = [createItem('turn-a')]; + + act(() => { + root.render( + , + ); + }); + + const scroller = container.querySelector('[data-virtuoso-scroller="true"]'); + expect(scroller).not.toBeNull(); + act(() => { + scroller?.dispatchEvent(new WheelEvent('wheel', { + deltaY: -120, + bubbles: true, + })); + }); + flushAnimationFrame(); + flushAnimationFrame(); + await act(async () => { + await Promise.resolve(); + }); + + expect(onHistoryWindowBoundaryIntent).toHaveBeenCalledWith('before', expect.objectContaining({ + prepareViewportForPresentationCommit: expect.any(Function), + })); + expect(flowStoreMocks.revealPreviousSessionHistoryWindow).not.toHaveBeenCalled(); + expect(flowStoreMocks.releaseSessionHistoryCompletionAfterInitialPaint).not.toHaveBeenCalled(); + }); + + it('treats an exhausted catalog boundary as complete without showing a not-ready error', async () => { + const onHistoryWindowBoundaryIntent = vi.fn(async () => 'exhausted' as const); + stateMocks.activeSession = createSession('session-a', 'turn-a', { + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'ready', + isPartial: true, + loadedTurnCount: 1, + totalTurnCount: 1, + turnCatalog: { + schemaVersion: 1, + sessionId: 'session-a', + revision: 'catalog-1', + totalTurnCount: 1, + complete: true, + entries: [{ + ordinal: 0, + storageTurnIndex: 0, + turnId: 'turn-a', + preview: 'Prompt 1', + previewTruncated: false, + }], + }, + }); + stateMocks.virtualItems = [createItem('turn-a')]; + + act(() => { + root.render( + , + ); + }); + + const scroller = container.querySelector('[data-virtuoso-scroller="true"]'); + expect(scroller).not.toBeNull(); + act(() => { + scroller?.dispatchEvent(new WheelEvent('wheel', { + deltaY: -120, + bubbles: true, + })); + }); + flushAnimationFrame(); + flushAnimationFrame(); + await act(async () => { + await Promise.resolve(); + }); + + expect(onHistoryWindowBoundaryIntent).toHaveBeenCalledOnce(); + expect(container.querySelector('[data-history-boundary-status]')).toBeNull(); + }); + + it('releases a pending catalog boundary transaction when returning from history to tail', () => { + const pendingBoundary = new Promise<'applied'>(() => {}); + const onHistoryWindowBoundaryIntent = vi.fn(() => pendingBoundary); + stateMocks.activeSession = createSessionWithTurns('session-a', ['turn-a'], { + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'ready', + isPartial: true, + loadedTurnCount: 1, + totalTurnCount: 2, + turnCatalog: { + schemaVersion: 1, + sessionId: 'session-a', + revision: 'catalog-1', + totalTurnCount: 2, + complete: true, + entries: Array.from({ length: 2 }, (_, ordinal) => ({ + ordinal, + storageTurnIndex: ordinal, + turnId: `turn-${ordinal + 1}`, + preview: `Prompt ${ordinal + 1}`, + previewTruncated: false, + })), + }, + }); + stateMocks.virtualItems = [createItem('turn-a')]; + + act(() => { + root.render( + , + ); + }); + + const scroller = container.querySelector('[data-virtuoso-scroller="true"]'); + expect(scroller).not.toBeNull(); + act(() => { + scroller?.dispatchEvent(new WheelEvent('wheel', { + deltaY: -120, + bubbles: true, + })); + }); + flushAnimationFrame(); + flushAnimationFrame(); + expect(onHistoryWindowBoundaryIntent).toHaveBeenCalledOnce(); + + act(() => { + root.render( + , + ); + }); + act(() => { + root.render( + , + ); + }); + + act(() => { + scroller?.dispatchEvent(new WheelEvent('wheel', { + deltaY: -120, + bubbles: true, + })); + }); + flushAnimationFrame(); + flushAnimationFrame(); + expect(onHistoryWindowBoundaryIntent).toHaveBeenCalledTimes(2); + }); + it('surfaces a not-ready boundary state when a deferred history window cannot be revealed', () => { flowStoreMocks.hasDeferredSessionHistoryProjection.mockReturnValue(true); flowStoreMocks.revealPreviousSessionHistoryWindow.mockReturnValue(false); @@ -2673,7 +3704,6 @@ describe('VirtualMessageList session boundary', () => { flushAnimationFrame(); flushAnimationFrame(); - expect(flowStoreMocks.requestSessionFullHistoryProjection).not.toHaveBeenCalled(); expect(flowStoreMocks.revealPreviousSessionHistoryWindow).toHaveBeenCalledWith('session-a', 'wheel-up'); expect(container.querySelector('[data-history-boundary-status="not-ready"]')?.textContent).toBe('Older history is not ready yet.'); }); @@ -2714,7 +3744,6 @@ describe('VirtualMessageList session boundary', () => { flushAnimationFrame(); flushAnimationFrame(); - expect(flowStoreMocks.requestSessionFullHistoryProjection).not.toHaveBeenCalled(); expect(flowStoreMocks.revealPreviousSessionHistoryWindow).not.toHaveBeenCalled(); expect(flowStoreMocks.releaseSessionHistoryCompletionAfterInitialPaint).toHaveBeenCalledWith('session-a', { immediate: true, @@ -2758,7 +3787,6 @@ describe('VirtualMessageList session boundary', () => { flushAnimationFrame(); flushAnimationFrame(); - expect(flowStoreMocks.requestSessionFullHistoryProjection).not.toHaveBeenCalled(); expect(flowStoreMocks.revealPreviousSessionHistoryWindow).not.toHaveBeenCalled(); expect(flowStoreMocks.releaseSessionHistoryCompletionAfterInitialPaint).not.toHaveBeenCalled(); expect(container.querySelector('[data-history-boundary-status="not-ready"]')?.textContent).toBe('Older history is not ready yet.'); 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 37a75ebbfe..9bfc317ab1 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -30,6 +30,8 @@ import { StickyTaskIndicator } from '../StickyTaskIndicator'; import { RuntimeStatusSlot } from './RuntimeStatusSlot'; import { useFlowChatFollowOutput } from './useFlowChatFollowOutput'; import type { FlowChatPinTurnToTopMode } from '../../events/flowchatNavigation'; +import type { ActiveTurnRenderRange } from '../../types/flow-chat'; +import type { SessionHistoryWindowDirection } from '../../store/FlowChatStore'; import { useVirtualItems, useActiveSession, useModernFlowChatStore, type VisibleTurnInfo, type VirtualItem } from '../../store/modernFlowChatStore'; import { useChatInputState } from '../../store/chatInputStateStore'; import { @@ -42,13 +44,12 @@ import { } from '../../utils/flowChatTurnScrollPolicy'; import { flowChatStore } from '../../store/FlowChatStore'; import { startupTrace } from '@/shared/utils/startupTrace'; +import { createLogger } from '@/shared/utils/logger'; import { flowChatDiagnostics } from '@/infrastructure/diagnostics/flowChatDiagnostics'; import { estimateVirtualMessageItemHeight, + getLeadingVirtualItemIndexDelta, getVirtualMessageDefaultItemHeight, - INITIAL_HISTORY_RENDER_MIN_ESTIMATED_HEIGHT_PX, - INITIAL_HISTORY_RENDER_MIN_TURN_COUNT, - mapInitialHistoryExpansionScrollTop, selectInitialHistoryRenderWindow, } from './virtualMessageListLayout'; import { @@ -102,22 +103,23 @@ import { } from './flowChatCollapseMotion'; import './VirtualMessageList.scss'; +const log = createLogger('VirtualMessageList'); + 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 STATIC_HISTORY_BOTTOM_RETURN_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 LATEST_END_ANCHOR_STATIC_FAST_PATH_TOLERANCE_PX = 96; const VIRTUOSO_FIRST_ITEM_INDEX_BASE = 1_000_000; const PARTIAL_HISTORY_INITIAL_TAIL_TURN_BUDGET = 16; const PARTIAL_HISTORY_FULL_PROJECTION_TOP_THRESHOLD_PX = 1200; -const PARTIAL_HISTORY_STATIC_BOUNDARY_THRESHOLD_PX = 120; const HISTORY_PROJECTION_HANDOFF_MAX_DURATION_MS = 5000; const TURN_PIN_REQUEST_TTL_MS = 5000; +const TRANSIENT_TURN_PIN_STABLE_FRAME_COUNT = 2; +const TRANSIENT_TURN_PIN_VIEWPORT_MULTIPLIER = 2; const SESSION_OPEN_HANDOFF_ITEM_BUDGET = 24; const PREVIOUS_HISTORY_BOUNDARY_STATUS_DURATION_MS = 2500; const SEARCH_NAVIGATION_MAX_ATTEMPTS = 24; @@ -127,19 +129,16 @@ const STICKY_PIN_GROWTH_SETTLE_MS = 300; const RETAINED_COLLAPSE_QUIET_SETTLE_MS = 120; const RETAINED_COLLAPSE_QUIET_SETTLE_FRAMES = 2; const RETAINED_COLLAPSE_RELEASE_QUIET_MS = COLLAPSE_INTENT_TTL_MS; - -function getPinnedTurnScrollTop(scroller: HTMLElement, targetElement: HTMLElement): number { - const targetRect = targetElement.getBoundingClientRect(); - const scrollerRect = scroller.getBoundingClientRect(); - return Math.max( - 0, - scroller.scrollTop + targetRect.top - scrollerRect.top - PINNED_TURN_VIEWPORT_OFFSET_PX, - ); -} +const HISTORY_PRESENTATION_COMMIT_SCROLL_QUIET_MS = 320; +const IDLE_HISTORY_WINDOW_BOUNDARY_STATE: Record< + SessionHistoryWindowDirection, + 'idle' | 'loading' | 'error' +> = { before: 'idle', after: 'idle' }; type FlowChatVirtuosoContext = { footerRef: React.RefCallback; previousHistoryBoundaryStatusNode: React.ReactNode; + nextHistoryBoundaryStatusNode: React.ReactNode; runtimeStatusSessionId: string | null; }; @@ -150,16 +149,24 @@ const FlowChatVirtuosoHeader = ({ context }: ContextProp ); -const FlowChatHistoryPagingSentinel = ({ visible, label }: { visible: boolean; label: string }) => ( +const FlowChatHistoryPagingSentinel = ({ + state, + label, +}: { + state: 'idle' | 'loading' | 'error'; + label: string; +}) => (
- + {state === 'loading' ? ( + + ) : null} {label}
); @@ -169,6 +176,7 @@ const FlowChatVirtuosoFooter = ({ context }: ContextProp + {context.nextHistoryBoundaryStatusNode} ); @@ -201,6 +209,25 @@ type InitialHistoryTransitionState = { */ export type FlowChatTurnPinRequestStatus = 'rejected' | 'pending' | 'settled'; +export type TurnPinAlignmentPolicy = 'exact' | 'best-effort'; + +export interface TurnPinOptions { + behavior?: ScrollBehavior; + pinMode?: FlowChatPinTurnToTopMode; + alignmentPolicy?: TurnPinAlignmentPolicy; +} + +export type HistoryWindowBoundaryIntentResult = + | 'applied' + | 'exhausted' + | 'not-ready' + | 'cancelled'; + +type HistoryWindowBoundaryIntentResponse = + | HistoryWindowBoundaryIntentResult + | boolean + | void; + export interface VirtualMessageListRef { scrollToTurn: (turnIndex: number) => void; scrollToIndex: (index: number) => void; @@ -224,15 +251,35 @@ export interface VirtualMessageListRef { // 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. - pinTurnToTop: (turnId: string, options?: { behavior?: ScrollBehavior; pinMode?: FlowChatPinTurnToTopMode }) => boolean; + pinTurnToTop: (turnId: string, options?: TurnPinOptions) => boolean; // Detailed status for callers that must distinguish immediate feedback from deferred virtual-list settling. - pinTurnToTopWithStatus: (turnId: string, options?: { behavior?: ScrollBehavior; pinMode?: FlowChatPinTurnToTopMode }) => FlowChatTurnPinRequestStatus; + pinTurnToTopWithStatus: (turnId: string, options?: TurnPinOptions) => FlowChatTurnPinRequestStatus; + // Establishes the existing pin transaction before a different presentation window is committed. + prepareTurnPinToTop: (turnId: string, options?: TurnPinOptions) => FlowChatTurnPinRequestStatus; } export interface VirtualMessageListProps { + items?: VirtualItem[]; + /** Whether the host scene currently owns a visible, measurable viewport. */ + isViewportActive?: boolean; + presentationMode?: 'tail' | 'history-window'; + viewportMode?: 'live-tail' | 'history-reading'; + historyWindow?: ActiveTurnRenderRange | null; + presentationRevision?: number; + historyBoundaryState?: Record; + onHistoryWindowBoundaryIntent?: ( + direction: SessionHistoryWindowDirection, + options?: HistoryWindowBoundaryIntentOptions, + ) => HistoryWindowBoundaryIntentResponse | Promise; + onRequestJumpToLatest?: () => void; onUserScrollIntent?: () => void; } +export interface HistoryWindowBoundaryIntentOptions { + prepareViewportForPresentationCommit?: () => boolean | void | Promise; + cancelViewportPresentationCommit?: () => void; +} + interface PendingCollapseIntentState { active: boolean; anchorScrollTop: number; @@ -250,6 +297,19 @@ interface RetainedCollapseAnchorState { toolName: string | null; } +interface HistoryPresentationCommitQuietWaiter { + cancel: () => void; + restart: () => void; +} + +interface PendingHistoryPrependAnchorState { + turnId: string; + offsetFromScrollerTop: number; + beforeItemCount: number; + handoffRestored: boolean; + prependRestored: boolean; +} + function createInactiveCollapseIntentState(): PendingCollapseIntentState { return { active: false, @@ -263,6 +323,18 @@ function createInactiveCollapseIntentState(): PendingCollapseIntentState { }; } +function normalizeHistoryWindowBoundaryIntentResult( + result: HistoryWindowBoundaryIntentResponse, +): HistoryWindowBoundaryIntentResult { + if (result === true) { + return 'applied'; + } + if (result === false || result === undefined) { + return 'not-ready'; + } + return result; +} + interface LatestEndAnchorRequestState { turnId: string; targetIndex: number; @@ -275,6 +347,10 @@ interface LatestEndAnchorRequestState { lastTargetBottom: number | null; } +type InitialTopMostItemIndex = + | number + | { index: number; align: 'start' | 'end' }; + interface ScrollerGeometrySnapshot { scrollTop: number; scrollHeight: number; @@ -287,16 +363,29 @@ interface PendingTurnPinState { turnId: string; behavior: ScrollBehavior; pinMode: FlowChatPinTurnToTopMode; + alignmentPolicy: TurnPinAlignmentPolicy; expiresAtMs: number; attempts: number; } -interface PendingStaticTurnPinState { +interface TransientTurnPinStabilizationState extends PendingTurnPinState { + stableAlignedFrames: number; + lastScrollTop: number | null; + lastTargetTop: number | null; +} + +interface PreparedHistoryTurnPinState { turnId: string; behavior: ScrollBehavior; pinMode: FlowChatPinTurnToTopMode; + alignmentPolicy: TurnPinAlignmentPolicy; } +type TurnPinResolution = + | { kind: 'pending' } + | { kind: 'exact'; targetScrollTop: number } + | { kind: 'best-effort'; targetScrollTop: number }; + function sanitizeReservationPx(value: number): number { return Number.isFinite(value) ? Math.max(0, value) : 0; } @@ -387,28 +476,31 @@ function getVirtualItemStableKey(item: VirtualItem): string { } } -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; -} - const VirtualMessageListSession = forwardRef(({ + items, + isViewportActive = true, + presentationMode = 'tail', + viewportMode = presentationMode === 'history-window' ? 'history-reading' : 'live-tail', + historyWindow = null, + presentationRevision = 0, + historyBoundaryState = IDLE_HISTORY_WINDOW_BOUNDARY_STATE, + onHistoryWindowBoundaryIntent, + onRequestJumpToLatest, onUserScrollIntent, }, ref) => { const { t } = useTranslation('flow-chat'); const virtuosoRef = useRef(null); - const virtualItems = useVirtualItems(); + const canonicalVirtualItems = useVirtualItems(); + const virtualItems = items ?? canonicalVirtualItems; const activeSession = useActiveSession(); + 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 virtuosoIndexStateRef = useRef<{ sessionId: string | null; firstItemIndex: number; @@ -425,9 +517,16 @@ const VirtualMessageListSession = forwardRef 0) { - virtuosoIndexState.firstItemIndex = Math.max(0, virtuosoIndexState.firstItemIndex - prependedCount); + const leadingIndexDelta = getLeadingVirtualItemIndexDelta( + virtuosoIndexState.virtualItems, + virtualItems, + getVirtualItemStableKey, + ); + if (leadingIndexDelta !== 0) { + virtuosoIndexState.firstItemIndex = Math.max( + 0, + virtuosoIndexState.firstItemIndex + leadingIndexDelta, + ); } virtuosoIndexState.virtualItems = virtualItems; } @@ -435,16 +534,16 @@ const VirtualMessageListSession = forwardRef(null); + const [viewportActivityRevision, setViewportActivityRevision] = useState(0); const [bottomReservationState, setBottomReservationState] = useState( () => createInitialBottomReservationState() ); const [pendingTurnPin, setPendingTurnPin] = useState(null); const [historyProjectionHandoff, setHistoryProjectionHandoff] = useState(null); - const [expandedInitialHistoryRenderKey, setExpandedInitialHistoryRenderKey] = useState(null); + const [initialHistorySnapshotActive, setInitialHistorySnapshotActive] = useState(useInitialHistoryRenderBudget); const [historyPagingActive, setHistoryPagingActive] = useState(false); const [historyPagingLoading, setHistoryPagingLoading] = useState(false); const [historyPagingAnchorTurnId, setHistoryPagingAnchorTurnId] = useState(null); - const [staticAnchorWindowTurnId, setStaticAnchorWindowTurnId] = useState(null); const [previousHistoryBoundaryStatus, setPreviousHistoryBoundaryStatus] = useState<{ sessionId: string; reason: string; @@ -454,8 +553,14 @@ const VirtualMessageListSession = forwardRef(null); const footerElementRef = useRef(null); const activeSessionIdRef = useRef(null); + const isViewportActiveRef = useRef(isViewportActive); + const viewportGeometrySuspendedRef = useRef(!isViewportActive); + const viewportActivityGenerationRef = useRef(0); + const viewportReactivationFrameRef = useRef(null); + const suspendedViewportScrollTopRef = useRef(0); const historyProjectionHandoffRef = useRef(null); const historyProjectionHandoffReleaseFrameRef = useRef(null); + const clearHistoryProjectionHandoffRef = useRef<(reason: string) => void>(() => {}); const latestVisibleHistoryTailSnapshotRef = useRef(null); const previousInitialHistoryTransitionStateRef = useRef(null); const fullHistoryProjectionIntentFrameRef = useRef(null); @@ -470,44 +575,35 @@ const VirtualMessageListSession = forwardRef(null); const markedInitialHistoryRenderWindowKeyRef = useRef(null); - const autoScrolledInitialHistoryRenderKeyRef = useRef(null); - const useStaticInitialHistoryListRef = useRef(false); - const staticInitialHistoryUserLeftBottomRef = useRef(false); - const pendingInitialHistoryExpansionRef = useRef<{ - scrollTop: number; - scrollHeight: number; - omittedEstimatedHeightPx: number; - wasAtBottom: boolean; - } | null>(null); - const pendingHistoryPrependAnchorRef = useRef<{ - turnId: string; - offsetFromScrollerTop: number; - beforeItemCount: number; - handoffRestored: boolean; - prependRestored: boolean; - } | null>(null); + const initialHistorySnapshotCreatedAtMsRef = useRef(performance.now()); + const pendingHistoryPrependAnchorRef = useRef(null); const historyPagingActiveRef = useRef(false); - const historyPagingRevealScheduledRef = useRef(false); - const pendingHistoryPagingRevealRef = useRef<{ - sessionId: string; - reason: string; - } | null>(null); const historyPagingRetryTimerRef = useRef(null); - const pendingStaticTurnPinRef = useRef(null); - const pendingStaticLatestScrollBehaviorRef = useRef<('auto' | 'smooth') | null>(null); - const staticHistoryBottomReturnIntentUntilMsRef = useRef(0); - const initialHistoryRenderWindowCheckFrameRef = useRef(null); + const catalogHistoryWindowRequestRef = useRef | null>(null); + const previousViewportModeRef = useRef<'live-tail' | 'history-reading'>(viewportMode); + const lastUserScrollIntentAtMsRef = useRef(Number.NEGATIVE_INFINITY); + const historyPresentationCommitQuietGenerationRef = useRef(0); + const historyPresentationCommitQuietWaitersRef = useRef( + new Set(), + ); + const preparedHistoryTurnPinRef = useRef(null); const measureFrameRef = useRef(null); const visibleTurnMeasureFrameRef = useRef(null); const pinReservationReconcileFrameRef = useRef(null); const turnPinStabilizationFrameRef = useRef(null); const turnPinRequestGenerationRef = useRef(0); + const pendingTurnPinGenerationRef = useRef(null); const activeTurnPinRequestRef = useRef(null); const latestEndAnchorStabilizationFrameRef = useRef(null); const searchNavigationRequestIdRef = useRef(0); - const staticInitialHistoryBottomGuardFrameRef = useRef(null); - const staticInitialHistoryBottomGuardUntilMsRef = useRef(0); const latestEndAnchorRequestRef = useRef(null); + const virtuosoMountStateRef = useRef<{ + sessionId: string | null; + mounted: boolean; + }>({ + sessionId: null, + mounted: false, + }); const resolveLatestEndAnchorStabilizationRef = useRef<((reason: LatestEndAnchorResolveReason) => boolean) | null>(null); const resizeObserverRef = useRef(null); const mutationObserverRef = useRef(null); @@ -567,8 +663,63 @@ const VirtualMessageListSession = forwardRef(null); + const transientTurnPinStabilizationRef = useRef(null); + const scheduleTransientTurnPinStabilizationRef = useRef<(frames?: number) => void>(() => {}); activeSessionIdRef.current = activeSessionId; + isViewportActiveRef.current = isViewportActive; + if (!isViewportActive) { + viewportGeometrySuspendedRef.current = true; + } + + const isScrollerViewportMeasurable = useCallback((scroller: HTMLElement | null): scroller is HTMLElement => ( + Boolean( + isViewportActiveRef.current && + scroller?.isConnected && + ( + scroller.clientHeight > COMPENSATION_EPSILON_PX || + scroller.getBoundingClientRect().height > COMPENSATION_EPSILON_PX + ) + ) + ), []); + + const canProcessViewportGeometry = useCallback((scroller: HTMLElement | null): scroller is HTMLElement => ( + !viewportGeometrySuspendedRef.current && isScrollerViewportMeasurable(scroller) + ), [isScrollerViewportMeasurable]); + + const canCoordinateViewport = useCallback(() => ( + isViewportActiveRef.current && !viewportGeometrySuspendedRef.current + ), []); + + useEffect(() => { + const previousMode = previousViewportModeRef.current; + if (previousMode === 'history-reading' && viewportMode === 'live-tail') { + historyPresentationCommitQuietGenerationRef.current += 1; + Array.from(historyPresentationCommitQuietWaitersRef.current).forEach(waiter => waiter.cancel()); + if (historyPagingRetryTimerRef.current !== null) { + window.clearTimeout(historyPagingRetryTimerRef.current); + historyPagingRetryTimerRef.current = null; + } + if (fullHistoryProjectionIntentFrameRef.current !== null) { + cancelAnimationFrame(fullHistoryProjectionIntentFrameRef.current); + fullHistoryProjectionIntentFrameRef.current = null; + } + if (previousHistoryBoundaryStatusTimerRef.current !== null) { + window.clearTimeout(previousHistoryBoundaryStatusTimerRef.current); + previousHistoryBoundaryStatusTimerRef.current = null; + } + pendingFullHistoryProjectionReasonRef.current = null; + catalogHistoryWindowRequestRef.current = null; + pendingHistoryPrependAnchorRef.current = null; + historyPagingActiveRef.current = false; + setHistoryPagingActive(false); + setHistoryPagingLoading(false); + setHistoryPagingAnchorTurnId(null); + setPreviousHistoryBoundaryStatus(null); + } + if (previousMode !== viewportMode) { + previousViewportModeRef.current = viewportMode; + } + }, [viewportMode]); const isInputActive = useChatInputState(state => state.isActive); const isInputExpanded = useChatInputState(state => state.isExpanded); @@ -585,7 +736,7 @@ const VirtualMessageListSession = forwardRef { + if (!canCoordinateViewport()) { + return; + } previousScrollerGeometryRef.current = { scrollTop: scroller.scrollTop, scrollHeight: scroller.scrollHeight, clientHeight: scroller.clientHeight, }; - }, []); - - const getEffectiveBottomScrollTop = useCallback((scroller: HTMLElement) => { - return Math.max( - 0, - scroller.scrollHeight - scroller.clientHeight - getTotalBottomCompensationPx(), - ); - }, [getTotalBottomCompensationPx]); - - const isGeometryAtEffectiveBottom = useCallback((geometry: ScrollerGeometrySnapshot | null) => { - if (!geometry) { - return false; - } - - const effectiveBottomScrollTop = Math.max( - 0, - geometry.scrollHeight - geometry.clientHeight - getTotalBottomCompensationPx(), - ); - return Math.abs(effectiveBottomScrollTop - geometry.scrollTop) <= LATEST_END_ANCHOR_STABLE_EPSILON_PX; - }, [getTotalBottomCompensationPx]); - - const recordStaticInitialHistoryBottomState = useCallback((scroller: HTMLElement) => { - if (!useStaticInitialHistoryListRef.current) { - staticInitialHistoryUserLeftBottomRef.current = false; - return; - } - - const distanceFromBottom = Math.max( - 0, - getEffectiveBottomScrollTop(scroller) - scroller.scrollTop, - ); - staticInitialHistoryUserLeftBottomRef.current = - distanceFromBottom > LATEST_END_ANCHOR_STABLE_EPSILON_PX; - }, [getEffectiveBottomScrollTop]); - - const clearStaticHistoryBottomReturnIntent = useCallback(() => { - staticHistoryBottomReturnIntentUntilMsRef.current = 0; - }, []); - - const markStaticHistoryBottomReturnIntent = useCallback(() => { - staticHistoryBottomReturnIntentUntilMsRef.current = - performance.now() + STATIC_HISTORY_BOTTOM_RETURN_INTENT_WINDOW_MS; - }, []); - - const hasRecentStaticHistoryBottomReturnIntent = useCallback(() => { - const intentUntilMs = staticHistoryBottomReturnIntentUntilMsRef.current; - return intentUntilMs > 0 && performance.now() <= intentUntilMs; - }, []); - - const releaseStaticHistoryAnchorWindow = useCallback(() => { - pendingStaticTurnPinRef.current = null; - clearStaticHistoryBottomReturnIntent(); - setStaticAnchorWindowTurnId(null); - }, [clearStaticHistoryBottomReturnIntent]); + }, [canCoordinateViewport]); const recordScrollerGeometryIfLayoutStable = useCallback((scroller: HTMLElement) => { const previousGeometry = previousScrollerGeometryRef.current; @@ -721,7 +822,91 @@ const VirtualMessageListSession = forwardRef { + historyPresentationCommitQuietWaitersRef.current.forEach(waiter => waiter.restart()); + }, []); + + const cancelHistoryPresentationCommitQuietWaiters = useCallback(() => { + historyPresentationCommitQuietGenerationRef.current += 1; + const waiters = Array.from(historyPresentationCommitQuietWaitersRef.current); + waiters.forEach(waiter => waiter.cancel()); + }, []); + + const waitForHistoryPresentationCommitQuiet = useCallback((sessionId: string): Promise => { + const generation = historyPresentationCommitQuietGenerationRef.current; + + return new Promise(resolve => { + let timerId: number | null = null; + let settled = false; + let waiter: HistoryPresentationCommitQuietWaiter | null = null; + + const finish = (ready: boolean) => { + if (settled) { + return; + } + settled = true; + if (timerId !== null) { + window.clearTimeout(timerId); + timerId = null; + } + if (waiter) { + historyPresentationCommitQuietWaitersRef.current.delete(waiter); + } + resolve(ready); + }; + + const check = () => { + timerId = null; + const scroller = scrollerElementRef.current; + if ( + generation !== historyPresentationCommitQuietGenerationRef.current + || activeSessionIdRef.current !== sessionId + || !scroller?.isConnected + ) { + finish(false); + return; + } + + const hasActivePointerGesture = ( + touchScrollIntentStartYRef.current !== null + || scrollbarPointerInteractionActiveRef.current + ); + const quietForMs = performance.now() - lastUserScrollIntentAtMsRef.current; + if (!hasActivePointerGesture && quietForMs >= HISTORY_PRESENTATION_COMMIT_SCROLL_QUIET_MS) { + finish(true); + return; + } + + const remainingQuietMs = hasActivePointerGesture + ? HISTORY_PRESENTATION_COMMIT_SCROLL_QUIET_MS + : HISTORY_PRESENTATION_COMMIT_SCROLL_QUIET_MS - quietForMs; + timerId = window.setTimeout(check, Math.max(1, remainingQuietMs)); + }; + + waiter = { + cancel: () => finish(false), + restart: () => { + if (settled) { + return; + } + if (timerId !== null) { + window.clearTimeout(timerId); + timerId = null; + } + timerId = window.setTimeout(check, HISTORY_PRESENTATION_COMMIT_SCROLL_QUIET_MS); + }, + }; + historyPresentationCommitQuietWaitersRef.current.add(waiter); + check(); + }); + }, []); + const notifyUserScrollIntent = useCallback((reason = 'user-scroll-intent') => { + lastUserScrollIntentAtMsRef.current = performance.now(); + restartHistoryPresentationCommitQuietWaiters(); + if (historyProjectionHandoffRef.current) { + clearHistoryProjectionHandoffRef.current(reason); + } if (flowChatDiagnostics.isEnabled()) { flowChatDiagnostics.trace({ hypothesis: 'A', @@ -748,9 +933,16 @@ const VirtualMessageListSession = forwardRef { + if (!canProcessViewportGeometry(scroller)) { + return false; + } const previousGeometry = previousScrollerGeometryRef.current; if (!previousGeometry) { return false; @@ -797,96 +989,12 @@ const VirtualMessageListSession = forwardRef { - staticInitialHistoryBottomGuardUntilMsRef.current = 0; - if (staticInitialHistoryBottomGuardFrameRef.current !== null) { - cancelAnimationFrame(staticInitialHistoryBottomGuardFrameRef.current); - staticInitialHistoryBottomGuardFrameRef.current = null; - } - }, []); - - const startStaticInitialHistoryBottomGuard = useCallback((durationMs = 2500) => { - const scroller = scrollerElementRef.current; - if (!scroller) { - return; - } - - if ( - staticInitialHistoryUserLeftBottomRef.current || - pendingStaticTurnPinRef.current || - staticAnchorWindowTurnId - ) { - return; - } - - const effectiveBottomScrollTop = getEffectiveBottomScrollTop(scroller); - if (Math.abs(effectiveBottomScrollTop - scroller.scrollTop) > LATEST_END_ANCHOR_STABLE_EPSILON_PX) { - scroller.scrollTop = effectiveBottomScrollTop; - staticInitialHistoryUserLeftBottomRef.current = false; - previousScrollTopRef.current = effectiveBottomScrollTop; - previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(scroller); - recordScrollerGeometry(scroller); - } - - staticInitialHistoryBottomGuardUntilMsRef.current = Math.max( - staticInitialHistoryBottomGuardUntilMsRef.current, - performance.now() + durationMs, - ); - if (staticInitialHistoryBottomGuardFrameRef.current !== null) { - return; - } - - const tick = () => { - staticInitialHistoryBottomGuardFrameRef.current = null; - const now = performance.now(); - if (now > staticInitialHistoryBottomGuardUntilMsRef.current) { - return; - } - - const currentScroller = scrollerElementRef.current; - if ( - !currentScroller || - !useStaticInitialHistoryListRef.current || - staticInitialHistoryUserLeftBottomRef.current || - pendingStaticTurnPinRef.current || - staticAnchorWindowTurnId || - now <= userInitiatedUpwardScrollUntilMsRef.current - ) { - staticInitialHistoryBottomGuardUntilMsRef.current = 0; - return; - } - - const currentEffectiveBottomScrollTop = getEffectiveBottomScrollTop(currentScroller); - const distanceFromBottom = Math.max( - 0, - currentEffectiveBottomScrollTop - currentScroller.scrollTop, - ); - if (distanceFromBottom > LATEST_END_ANCHOR_STABLE_EPSILON_PX) { - currentScroller.scrollTop = currentEffectiveBottomScrollTop; - staticInitialHistoryUserLeftBottomRef.current = false; - previousScrollTopRef.current = currentEffectiveBottomScrollTop; - previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(currentScroller); - } - recordScrollerGeometry(currentScroller); - - staticInitialHistoryBottomGuardFrameRef.current = requestAnimationFrame(tick); - }; - - staticInitialHistoryBottomGuardFrameRef.current = requestAnimationFrame(tick); - }, [ - getEffectiveBottomScrollTop, - recordScrollerGeometry, - snapshotMeasuredContentHeight, - staticAnchorWindowTurnId, - ]); + }, [canProcessViewportGeometry, recordScrollerGeometry, snapshotMeasuredContentHeight]); const updateBottomReservationState = useCallback(( updater: BottomReservationState | ((prev: BottomReservationState) => BottomReservationState), @@ -919,7 +1027,9 @@ const VirtualMessageListSession = forwardRef { turnPinRequestGenerationRef.current += 1; + pendingTurnPinGenerationRef.current = null; activeTurnPinRequestRef.current = null; + preparedHistoryTurnPinRef.current = null; transientTurnPinStabilizationRef.current = null; if (turnPinStabilizationFrameRef.current !== null) { @@ -930,6 +1040,19 @@ const VirtualMessageListSession = forwardRef { + if (request.alignmentPolicy !== 'best-effort') { + return false; + } + + transientTurnPinStabilizationRef.current = null; + if (viewportCoordinatorRef.current.getMode() === 'pinned-item') { + viewportCoordinatorRef.current.release('best-effort-turn-pin-settled'); + } + clearTurnPinRequest(); + return true; + }, [clearTurnPinRequest]); + const cancelLatestEndAnchorStabilization = useCallback(() => { if (latestEndAnchorStabilizationFrameRef.current !== null) { cancelAnimationFrame(latestEndAnchorStabilizationFrameRef.current); @@ -943,10 +1066,17 @@ const VirtualMessageListSession = forwardRef { const scrollerNow = scrollerElementRef.current; - if (!scrollerNow) return; + if (!canProcessViewportGeometry(scrollerNow)) { + return; + } // Do not drain if a collapse intent is still protecting an ongoing // CSS transition or delayed virtualizer measurement. const intent = pendingCollapseIntentRef.current; @@ -1531,11 +1672,19 @@ const VirtualMessageListSession = forwardRef { const scroller = scrollerElementRef.current; if (!scroller) return; + if (!canProcessViewportGeometry(scroller)) { + return; + } const currentScrollTop = scroller.scrollTop; const previousScrollTop = previousScrollTopRef.current; @@ -1850,6 +1999,7 @@ const VirtualMessageListSession = forwardRef(latestTurnId); const previousSessionIdForFollowRef = useRef(activeSession?.sessionId); @@ -1947,6 +2087,9 @@ const VirtualMessageListSession = forwardRef { if (visibleTurnMeasureFrameRef.current !== null) { @@ -2180,6 +2323,7 @@ const VirtualMessageListSession = forwardRef { if (historyProjectionHandoffReleaseFrameRef.current !== null) { @@ -2223,6 +2367,22 @@ const VirtualMessageListSession = forwardRef { + const snapshot = historyProjectionHandoffRef.current; + if (!snapshot || snapshot.targetTurnId === turnId) { + return; + } + + const nextSnapshot: HistoryProjectionHandoffSnapshot = { + ...snapshot, + targetTurnId: turnId, + createdAtMs: performance.now(), + }; + historyProjectionHandoffRef.current = nextSnapshot; + setHistoryProjectionHandoff(nextSnapshot); + scheduleHistoryProjectionHandoffRelease(2); + }, [scheduleHistoryProjectionHandoffRelease]); + const buildPinReservation = useCallback(( turnId: string, pinMode: FlowChatPinTurnToTopMode, @@ -2267,7 +2427,9 @@ const VirtualMessageListSession = forwardRef { const scroller = scrollerElementRef.current; - if (!scroller) return null; + if (!canProcessViewportGeometry(scroller)) { + return null; + } const targetElement = getRenderedUserMessageElement(turnId); if (!targetElement) return null; @@ -2314,13 +2476,17 @@ const VirtualMessageListSession = forwardRef { const scroller = scrollerElementRef.current; const currentState = bottomReservationStateRef.current; const pinReservation = currentState.pin; - if (!scroller || pinReservation.mode !== 'sticky-latest' || !pinReservation.targetTurnId) { + if ( + !canProcessViewportGeometry(scroller) || + pinReservation.mode !== 'sticky-latest' || + !pinReservation.targetTurnId + ) { return false; } @@ -2329,7 +2495,7 @@ const VirtualMessageListSession = forwardRef { - const scroller = scrollerElementRef.current; - const targetElement = getRenderedUserMessageElement(turnId); - if (!scroller || !targetElement) { - return false; + const tryResolvePendingTurnPin = useCallback((request: PendingTurnPinState): TurnPinResolution => { + if (!isTurnPinRequestCurrent(request)) { + return { kind: 'pending' }; } - const targetScrollTop = getPinnedTurnScrollTop(scroller, targetElement); - cancelStaticInitialHistoryBottomGuard(); - scroller.scrollTo({ - top: targetScrollTop, - behavior, - }); - previousScrollTopRef.current = targetScrollTop; - previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(scroller); - recordScrollerGeometry(scroller); - recordStaticInitialHistoryBottomState(scroller); - setIsAtBottom(false); - scheduleVisibleTurnMeasure(2); - return true; - }, [ - cancelStaticInitialHistoryBottomGuard, - getRenderedUserMessageElement, - recordScrollerGeometry, - recordStaticInitialHistoryBottomState, - scheduleVisibleTurnMeasure, - snapshotMeasuredContentHeight, - ]); - - useLayoutEffect(() => { - const pending = pendingStaticTurnPinRef.current; - if (!pending || pending.turnId !== staticAnchorWindowTurnId) { - return; - } + const scroller = scrollerElementRef.current; + const virtuoso = virtuosoRef.current; - if (scrollStaticTurnToTop(pending.turnId, pending.behavior)) { - pendingStaticTurnPinRef.current = null; - } - }, [ - scrollStaticTurnToTop, - staticAnchorWindowTurnId, - virtualItems, - ]); - - const tryResolvePendingTurnPin = useCallback((request: PendingTurnPinState) => { - if (!isTurnPinRequestCurrent(request)) { - return false; - } - - const scroller = scrollerElementRef.current; - const virtuoso = virtuosoRef.current; - - if (!scroller || !virtuoso) { + if (!canCoordinateViewport() || !scroller || !virtuoso) { startupTrace.markPhase('flowchat_turn_pin_resolve', { - result: 'missing_scroller_or_virtuoso', + result: 'inactive_or_missing_scroller_or_virtuoso', turnId: request.turnId, pinMode: request.pinMode, attempt: request.attempts, hasScroller: Boolean(scroller), + isViewportActive: isViewportActiveRef.current, + viewportGeometrySuspended: viewportGeometrySuspendedRef.current, hasVirtuoso: Boolean(virtuoso), }); - return false; + return { kind: 'pending' }; } const targetItem = userMessageItems.find(({ item }) => item.turnId === request.turnId); @@ -2552,7 +2677,7 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX + ); + const isBestEffortTransientPin = ( + request.alignmentPolicy === 'best-effort' && + request.pinMode === 'transient' + ); + const nextPinReservation: PinBottomReservation = isBestEffortTransientPin + ? { + kind: 'pin', + px: 0, + floorPx: 0, + mode: 'transient', + targetTurnId: null, + } + : buildPinReservation( request.turnId, request.pinMode, resolvedMetrics.missingTailSpace, scroller.clientHeight, - ), + ); + const nextReservationState: BottomReservationState = { + ...bottomReservationStateRef.current, + pin: nextPinReservation, }; updateBottomReservationState(nextReservationState); applyFooterCompensationNow(nextReservationState); - const resolvedMaxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); + const resolvedMaxScrollTop = hasInsufficientNaturalRange + ? resolvedMetrics.maxScrollTop + : Math.max(0, scroller.scrollHeight - scroller.clientHeight); const targetScrollTop = Math.min(resolvedMetrics.desiredScrollTop, resolvedMaxScrollTop); if (Math.abs(scroller.scrollTop - targetScrollTop) > COMPENSATION_EPSILON_PX) { scroller.scrollTop = targetScrollTop; @@ -2652,6 +2796,7 @@ const VirtualMessageListSession = forwardRef 1.5 && @@ -2700,11 +2845,15 @@ const VirtualMessageListSession = forwardRef request.expiresAtMs) { + transientTurnPinStabilizationRef.current = null; if (activeTurnPinRequestRef.current?.generation === request.generation) { - activeTurnPinRequestRef.current = null; + cancelUnresolvedTurnPinRef.current(); + } else { + pendingTurnPinGenerationRef.current = null; + setPendingTurnPin(current => ( + current?.generation === request.generation ? null : current + )); } - transientTurnPinStabilizationRef.current = null; return; } - const nextRequest: PendingTurnPinState = { + if (viewportCoordinatorRef.current.getMode() === 'idle') { + viewportCoordinatorRef.current.pinItem('transient-turn-pin-rematerializing'); + } + + const nextRequest: TransientTurnPinStabilizationState = { ...request, behavior: 'auto', attempts: request.attempts + 1, }; - transientTurnPinStabilizationRef.current = nextRequest; + const resolved = tryResolvePendingTurnPin(nextRequest); + if (resolved.kind === 'best-effort' && settleBestEffortTurnPin(nextRequest)) { + scheduleVisibleTurnMeasure(2); + return; + } + const scroller = scrollerElementRef.current; + const targetElement = getRenderedUserMessageElement(nextRequest.turnId); + const targetRect = targetElement?.getBoundingClientRect(); + const viewportTop = scroller + ? scroller.getBoundingClientRect().top + PINNED_TURN_VIEWPORT_OFFSET_PX + : null; + const aligned = Boolean( + resolved.kind === 'exact' && + scroller && + targetRect && + viewportTop !== null && + Math.abs(targetRect.top - viewportTop) <= 1.5 + ); + const geometryStable = Boolean( + aligned && + request.lastScrollTop !== null && + request.lastTargetTop !== null && + scroller && + targetRect && + Math.abs(scroller.scrollTop - request.lastScrollTop) <= 1 && + Math.abs(targetRect.top - request.lastTargetTop) <= 1 + ); + nextRequest.stableAlignedFrames = aligned + ? (geometryStable ? request.stableAlignedFrames + 1 : 1) + : 0; + nextRequest.lastScrollTop = aligned && scroller ? scroller.scrollTop : null; + nextRequest.lastTargetTop = aligned && targetRect ? targetRect.top : null; + + if (nextRequest.stableAlignedFrames >= TRANSIENT_TURN_PIN_STABLE_FRAME_COUNT) { + if (activeTurnPinRequestRef.current?.generation === nextRequest.generation) { + activeTurnPinRequestRef.current = null; + } + transientTurnPinStabilizationRef.current = null; + pendingTurnPinGenerationRef.current = null; + setPendingTurnPin(current => ( + current?.generation === nextRequest.generation ? null : current + )); + scheduleVisibleTurnMeasure(2); + return; + } - if (tryResolvePendingTurnPin(nextRequest)) { + transientTurnPinStabilizationRef.current = nextRequest; + if (resolved.kind !== 'pending') { scheduleVisibleTurnMeasure(2); } - }, [scheduleVisibleTurnMeasure, tryResolvePendingTurnPin]); + scheduleTransientTurnPinStabilizationRef.current(1); + }, [ + canProcessViewportGeometry, + getRenderedUserMessageElement, + scheduleVisibleTurnMeasure, + settleBestEffortTurnPin, + tryResolvePendingTurnPin, + ]); const scheduleTransientTurnPinStabilization = useCallback((frames: number = 1) => { if (!transientTurnPinStabilizationRef.current) { return; } + if (!canProcessViewportGeometry(scrollerElementRef.current)) { + return; + } if (turnPinStabilizationFrameRef.current !== null) { cancelAnimationFrame(turnPinStabilizationFrameRef.current); @@ -2779,7 +3003,8 @@ const VirtualMessageListSession = forwardRef { const currentState = bottomReservationStateRef.current; @@ -3140,6 +3365,10 @@ const VirtualMessageListSession = forwardRef { + if ( + presentationMode !== 'history-window' + || !historyWindow + || !onHistoryWindowBoundaryIntent + || historyBoundaryState[direction] === 'loading' + ) { + return false; + } + + const scroller = scrollerElementRef.current; + const sessionId = activeSessionIdRef.current; + if (!scroller || !sessionId) { + return false; + } + const thresholdPx = Math.max( + PARTIAL_HISTORY_FULL_PROJECTION_TOP_THRESHOLD_PX, + scroller.clientHeight * 2, + ); + const distanceFromBoundary = direction === 'before' + ? scroller.scrollTop + : Math.max(0, scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop); + if (!options?.force && distanceFromBoundary > thresholdPx) { + return false; + } + + let preparedAnchorState: PendingHistoryPrependAnchorState | null = null; + const cancelViewportPresentationCommit = () => { + if ( + preparedAnchorState + && pendingHistoryPrependAnchorRef.current === preparedAnchorState + ) { + pendingHistoryPrependAnchorRef.current = null; + setHistoryPagingAnchorTurnId(null); + } + setHistoryPagingLoading(false); + clearPreviousHistoryBoundaryStatus(); + }; + const prepareViewportForPresentationCommit = async () => { + const quietReady = await waitForHistoryPresentationCommitQuiet(sessionId); + if (!quietReady || activeSessionIdRef.current !== sessionId) { + return false; + } + const anchor = captureHistoryPrependAnchor(); + if (anchor) { + historyPagingActiveRef.current = true; + setHistoryPagingActive(true); + setHistoryPagingAnchorTurnId(anchor.turnId); + preparedAnchorState = { + ...anchor, + beforeItemCount: virtualItems.length, + handoffRestored: true, + prependRestored: false, + }; + pendingHistoryPrependAnchorRef.current = preparedAnchorState; + } + return true; + }; + startupTrace.markPhase('flowchat_history_window_boundary_requested', { + sessionId: activeSessionIdRef.current, + direction, + reason, + startOrdinal: historyWindow.startOrdinal, + endOrdinalExclusive: historyWindow.endOrdinalExclusive, + presentationRevision, + }); + try { + void Promise.resolve(onHistoryWindowBoundaryIntent(direction, { + prepareViewportForPresentationCommit, + cancelViewportPresentationCommit, + })).catch(() => {}); + } catch { + return false; + } + return true; + }, [ + captureHistoryPrependAnchor, + clearPreviousHistoryBoundaryStatus, + historyBoundaryState, + historyWindow, + onHistoryWindowBoundaryIntent, + presentationMode, + presentationRevision, + virtualItems.length, + waitForHistoryPresentationCommitQuiet, + ]); + const revealPreviousHistoryWindowForUserIntent = useCallback((reason: string) => { const sessionId = activeSession?.sessionId; if ( + presentationMode === 'history-window' || !sessionId || activeSession.historyState !== 'ready' || activeSession.isPartial !== true @@ -3244,8 +3566,13 @@ const VirtualMessageListSession = forwardRef { + if ( + preparedAnchorState + && pendingHistoryPrependAnchorRef.current === preparedAnchorState + ) { + pendingHistoryPrependAnchorRef.current = null; + } + historyPagingActiveRef.current = false; + setHistoryPagingActive(false); + setHistoryPagingLoading(false); + setHistoryPagingAnchorTurnId(null); + clearPreviousHistoryBoundaryStatus(); + }; + const cancelViewportPresentationCommit = resetCatalogHistoryPagingState; + const prepareViewportForPresentationCommit = async () => { + const quietReady = await waitForHistoryPresentationCommitQuiet(sessionId); + if (!quietReady || activeSessionIdRef.current !== sessionId) { + return false; + } + const anchor = captureHistoryPrependAnchor(); + if (anchor) { + setHistoryPagingAnchorTurnId(anchor.turnId); + preparedAnchorState = { + ...anchor, + beforeItemCount: virtualItems.length, + handoffRestored: true, + prependRestored: false, + }; + pendingHistoryPrependAnchorRef.current = preparedAnchorState; + } + return true; + }; + const request = Promise.resolve(onHistoryWindowBoundaryIntent('before', { + prepareViewportForPresentationCommit, + cancelViewportPresentationCommit, + })) + .then(handled => { + const result = normalizeHistoryWindowBoundaryIntentResult(handled); + if (activeSessionIdRef.current !== sessionId) { + return result; + } + if (result === 'applied') { + if (!preparedAnchorState) { + resetCatalogHistoryPagingState(); + } + return result; + } + if (result === 'exhausted' || result === 'cancelled') { + resetCatalogHistoryPagingState(); + return result; + } + resetCatalogHistoryPagingState(); + showPreviousHistoryBoundaryStatus(sessionId, reason, 'not-ready'); + return result; + }) + .catch(error => { + if (activeSessionIdRef.current === sessionId) { + resetCatalogHistoryPagingState(); + showPreviousHistoryBoundaryStatus(sessionId, reason, 'not-ready'); + } + log.warn('Failed to request an older catalog Turn window', { + sessionId, + error, + }); + return 'not-ready' as const; + }) + .finally(() => { + if (catalogHistoryWindowRequestRef.current === request) { + catalogHistoryWindowRequestRef.current = null; + } + }); + catalogHistoryWindowRequestRef.current = request; return; } @@ -3335,48 +3735,14 @@ const VirtualMessageListSession = forwardRef { - const pendingReveal = pendingHistoryPagingRevealRef.current; - const pendingAnchor = pendingHistoryPrependAnchorRef.current; - if ( - !pendingReveal || - !historyPagingActive || - !scrollerElement?.isConnected || - (pendingAnchor && !pendingAnchor.handoffRestored) || - historyPagingRevealScheduledRef.current - ) { - return; - } - - historyPagingRevealScheduledRef.current = true; - const frameId = requestAnimationFrame(() => { - historyPagingRevealScheduledRef.current = false; - const currentPendingReveal = pendingHistoryPagingRevealRef.current; - if ( - !currentPendingReveal || - currentPendingReveal.sessionId !== pendingReveal.sessionId || - activeSessionIdRef.current !== pendingReveal.sessionId - ) { - return; - } - pendingHistoryPagingRevealRef.current = null; - revealPreviousHistoryWindowForUserIntent(currentPendingReveal.reason); - }); - - return () => { - cancelAnimationFrame(frameId); - historyPagingRevealScheduledRef.current = false; - }; - }, [ - historyPagingActive, - revealPreviousHistoryWindowForUserIntent, - scrollerElement, + waitForHistoryPresentationCommitQuiet, ]); const shouldRevealPreviousHistoryWindowForUserIntent = useCallback((options?: { force?: boolean }) => { @@ -3428,7 +3794,11 @@ const VirtualMessageListSession = forwardRef { const collapseIntent = pendingCollapseIntentRef.current; - return collapseIntent.active; + return ( + !isViewportActiveRef.current || + viewportGeometrySuspendedRef.current || + collapseIntent.active + ); }, []); const scheduleFollowToLatestWithViewportState = useCallback((reason: string) => { @@ -3444,25 +3814,23 @@ const VirtualMessageListSession = forwardRef { previousMeasuredHeightRef.current = null; previousScrollTopRef.current = 0; + cancelHistoryPresentationCommitQuietWaiters(); viewportCoordinatorRef.current.release('session-reset'); clearTurnPinRequest(); cancelLatestEndAnchorStabilization(); - cancelStaticInitialHistoryBottomGuard(); clearCollapseIntentScheduling(); clearRetainedCollapseSettlement(); clearPendingStickyPinGrowth('session-reset'); pendingCollapseIntentRef.current = createInactiveCollapseIntentState(); retainedCollapseAnchorRef.current = null; previousScrollerGeometryRef.current = null; - releaseStaticHistoryAnchorWindow(); - pendingStaticLatestScrollBehaviorRef.current = null; + preparedHistoryTurnPinRef.current = null; historyPagingActiveRef.current = false; - historyPagingRevealScheduledRef.current = false; - pendingHistoryPagingRevealRef.current = null; if (historyPagingRetryTimerRef.current !== null) { window.clearTimeout(historyPagingRetryTimerRef.current); historyPagingRetryTimerRef.current = null; } + catalogHistoryWindowRequestRef.current = null; pendingHistoryPrependAnchorRef.current = null; setHistoryPagingActive(false); setHistoryPagingLoading(false); @@ -3475,14 +3843,13 @@ const VirtualMessageListSession = forwardRef { return () => { + cancelHistoryPresentationCommitQuietWaiters(); clearRetainedCollapseSettlement(); cancelLatestEndAnchorStabilization(); - cancelStaticInitialHistoryBottomGuard(); if (historyProjectionHandoffReleaseFrameRef.current !== null) { cancelAnimationFrame(historyProjectionHandoffReleaseFrameRef.current); historyProjectionHandoffReleaseFrameRef.current = null; } }; }, [ + cancelHistoryPresentationCommitQuietWaiters, cancelLatestEndAnchorStabilization, - cancelStaticInitialHistoryBottomGuard, clearRetainedCollapseSettlement, ]); @@ -3526,7 +3893,6 @@ const VirtualMessageListSession = forwardRef { + if (!canProcessViewportGeometry(scrollerElement)) { + return; + } syncPhysicalBottomAfterViewportResize(scrollerElement); resolveLatestEndAnchorStabilizationRef.current?.('resize-observer'); scheduleObserverBatch(); @@ -3567,6 +3940,9 @@ const VirtualMessageListSession = forwardRef { + if (!canProcessViewportGeometry(scrollerElement)) { + return; + } syncPhysicalBottomAfterViewportResize(scrollerElement); resolveLatestEndAnchorStabilizationRef.current?.('resize-observer'); scheduleObserverBatch(); @@ -3578,6 +3954,9 @@ const VirtualMessageListSession = forwardRef { if (mutationPending) return; + if (!canProcessViewportGeometry(scrollerElement)) { + return; + } if (!isProcessing) { return; } @@ -3615,6 +3994,9 @@ const VirtualMessageListSession = forwardRef { + if (!canProcessViewportGeometry(scrollerElement)) { + return; + } const now = performance.now(); const intent = pendingCollapseIntentRef.current; const retainedCollapseAnchor = retainedCollapseAnchorRef.current; @@ -3635,11 +4017,13 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX) { - markStaticHistoryBottomReturnIntent(); - } else if (intentCheckScrollDelta < -COMPENSATION_EPSILON_PX) { - clearStaticHistoryBottomReturnIntent(); + const pendingHistoryAnchor = pendingHistoryPrependAnchorRef.current; + if (pendingHistoryAnchor && !pendingHistoryAnchor.prependRestored) { + const anchorElement = getRenderedUserMessageElement(pendingHistoryAnchor.turnId); + if (anchorElement) { + const scrollerRect = scrollerElement.getBoundingClientRect(); + pendingHistoryAnchor.offsetFromScrollerTop = + anchorElement.getBoundingClientRect().top - scrollerRect.top; } } if ( @@ -3821,7 +4205,6 @@ const VirtualMessageListSession = forwardRef 0) { - markStaticHistoryBottomReturnIntent(); + requestHistoryWindowBoundaryForUserIntent('after', 'wheel-down'); } else if (event.deltaY < 0) { - clearStaticHistoryBottomReturnIntent(); + requestHistoryWindowBoundaryForUserIntent('before', 'wheel-up'); } if (event.deltaY < 0) { - staticInitialHistoryUserLeftBottomRef.current = true; userInitiatedUpwardScrollUntilMsRef.current = performance.now() + USER_UPWARD_SCROLL_INTENT_WINDOW_MS; schedulePreviousHistoryWindowForUserIntent('wheel-up'); @@ -3894,15 +4276,14 @@ const VirtualMessageListSession = forwardRef TOUCH_SCROLL_INTENT_EXIT_THRESHOLD_PX) { touchScrollIntentStartYRef.current = currentY; - staticInitialHistoryUserLeftBottomRef.current = true; userInitiatedUpwardScrollUntilMsRef.current = performance.now() + USER_UPWARD_SCROLL_INTENT_WINDOW_MS; schedulePreviousHistoryWindowForUserIntent('touch-scroll-up'); @@ -3915,6 +4296,7 @@ const VirtualMessageListSession = forwardRef { touchScrollIntentStartYRef.current = null; + restartHistoryPresentationCommitQuietWaiters(); }; const handleKeyDown = (event: KeyboardEvent) => { @@ -3935,15 +4317,16 @@ const VirtualMessageListSession = forwardRef { scrollbarPointerInteractionActiveRef.current = false; + restartHistoryPresentationCommitQuietWaiters(); }; scrollerElement.addEventListener('wheel', handleWheel, { passive: true }); @@ -4026,6 +4412,9 @@ const VirtualMessageListSession = forwardRef { + if (!canProcessViewportGeometry(scrollerElement)) { + return; + } const detail = (event as CustomEvent<{ toolId?: string | null; toolName?: string | null; @@ -4226,33 +4615,32 @@ const VirtualMessageListSession = forwardRef { if (latestEndAnchorStabilizationFrameRef.current === null) { @@ -4486,6 +4877,7 @@ const VirtualMessageListSession = forwardRef ( current?.generation === nextRequest.generation ? nextRequest : current )); return; } + if (resolution.kind === 'best-effort' && settleBestEffortTurnPin(nextRequest)) { + scheduleVisibleTurnMeasure(2); + return; + } + if (nextRequest.pinMode === 'transient') { activateTransientTurnPinStabilization(nextRequest); scheduleTransientTurnPinStabilization(2); setPendingTurnPin(current => ( - current?.generation === nextRequest.generation ? null : current + current?.generation === nextRequest.generation ? nextRequest : current )); } else { clearTurnPinRequest(); @@ -4533,12 +4931,16 @@ const VirtualMessageListSession = forwardRef { + if (!canProcessViewportGeometry(scrollerElementRef.current)) { + return; + } resolvePendingTurnPinFromRangeChange(); resolveLatestEndAnchorStabilization('range-changed'); scheduleVisibleTurnMeasure(2); @@ -4552,6 +4954,7 @@ const VirtualMessageListSession = forwardRef { if (!pendingTurnPin) return; + if (!isViewportActive || viewportGeometrySuspendedRef.current) { + return; + } if (!isTurnPinRequestCurrent(pendingTurnPin)) { + pendingTurnPinGenerationRef.current = null; setPendingTurnPin(prev => ( prev?.generation === pendingTurnPin.generation ? null : prev )); @@ -4603,8 +5010,15 @@ const VirtualMessageListSession = forwardRef { + if (pendingTurnPinGenerationRef.current !== pendingTurnPin.generation) { + return; + } const resolved = tryResolvePendingTurnPin(pendingTurnPin); - if (resolved) { + if (resolved.kind !== 'pending') { + if (resolved.kind === 'best-effort' && settleBestEffortTurnPin(pendingTurnPin)) { + scheduleVisibleTurnMeasure(2); + return; + } if ( pendingTurnPin.pinMode === 'transient' && performance.now() <= pendingTurnPin.expiresAtMs @@ -4612,7 +5026,6 @@ const VirtualMessageListSession = forwardRef { + if (viewportMode === 'history-reading') { + return false; + } if (isProcessing) { return true; } @@ -4808,19 +5226,26 @@ const VirtualMessageListSession = forwardRef round.isStreaming); - }, [activeSession, isProcessing]); - const initialTopMostItemIndex = React.useMemo(() => { - const pendingTurnId = pendingTurnPin?.turnId ?? - pendingStaticTurnPinRef.current?.turnId ?? - staticAnchorWindowTurnId; - if (pendingTurnId) { - const pendingTargetIndex = virtualItems.findIndex(item => ( - item.turnId === pendingTurnId && item.type === 'user-message' + }, [activeSession, isProcessing, viewportMode]); + if (virtuosoMountStateRef.current.sessionId !== activeSessionId) { + virtuosoMountStateRef.current = { + sessionId: activeSessionId, + mounted: false, + }; + } + if (virtualItems.length === 0) { + virtuosoMountStateRef.current.mounted = false; + } + const initialTopMostItemIndexCandidate = React.useMemo(() => { + const preparedTurnId = preparedHistoryTurnPinRef.current?.turnId; + if (preparedTurnId) { + const preparedTargetIndex = virtualItems.findIndex(item => ( + item.turnId === preparedTurnId && item.type === 'user-message' )); - if (pendingTargetIndex >= 0) { + if (preparedTargetIndex >= 0) { return { - index: pendingTargetIndex, - align: 'start' as const, + index: preparedTargetIndex, + align: 'start', }; } } @@ -4832,7 +5257,7 @@ const VirtualMessageListSession = forwardRef= 0) { return { index: anchorIndex, - align: 'start' as const, + align: 'start', }; } } @@ -4843,17 +5268,31 @@ const VirtualMessageListSession = forwardRef { + if (!pendingTurnPin) { + return FLOW_CHAT_VIRTUOSO_VIEWPORT_INCREASE; + } + const navigationRangePx = Math.max( + FLOW_CHAT_VIRTUOSO_VIEWPORT_INCREASE.top, + (scrollerElement?.clientHeight ?? 0) * TRANSIENT_TURN_PIN_VIEWPORT_MULTIPLIER, + ); + return { + top: navigationRangePx, + bottom: navigationRangePx, + }; + }, [pendingTurnPin, scrollerElement]); useLayoutEffect(() => { if (!historyPagingActive || !historyPagingAnchorTurnId) { @@ -4881,9 +5320,14 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX) { scroller.scrollTop = Math.max(0, scroller.scrollTop + correction); } + if (presentationMode === 'history-window') { + viewportCoordinatorRef.current.preserveElement(anchorElement); + } if (isPrependCommit) { pendingAnchor.prependRestored = true; + pendingHistoryPrependAnchorRef.current = null; setHistoryPagingLoading(false); + setHistoryPagingAnchorTurnId(null); clearPreviousHistoryBoundaryStatus(); } else { pendingAnchor.handoffRestored = true; @@ -4896,13 +5340,63 @@ const VirtualMessageListSession = forwardRef { + if (presentationMode !== 'history-window' || presentationRevision <= 0) { + return; + } + const scroller = scrollerElementRef.current; + if (!scroller || !viewportCoordinatorRef.current.ownsElementAnchor()) { + return; + } + + viewportCoordinatorRef.current.scheduleElementAnchorRestore( + scroller, + 'history-window-presentation-commit', + ); + let secondFrameId: number | null = null; + const firstFrameId = requestAnimationFrame(() => { + viewportCoordinatorRef.current.scheduleElementAnchorRestore( + scroller, + 'history-window-presentation-measured', + ); + secondFrameId = requestAnimationFrame(() => { + viewportCoordinatorRef.current.restoreElementAnchor( + scroller, + 'history-window-presentation-settled', + ); + viewportCoordinatorRef.current.settleElementPreservation( + 'history-window-presentation-settled', + ); + previousScrollTopRef.current = scroller.scrollTop; + previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(scroller); + recordScrollerGeometry(scroller); + }); + }); + + return () => { + cancelAnimationFrame(firstFrameId); + if (secondFrameId !== null) { + cancelAnimationFrame(secondFrameId); + } + }; + }, [ + presentationMode, + presentationRevision, + recordScrollerGeometry, + snapshotMeasuredContentHeight, + ]); + useEffect(() => { + if (!isViewportActive || viewportGeometrySuspendedRef.current) { + return; + } const wasStreaming = previousIsStreamingOutputRef.current; previousIsStreamingOutputRef.current = isStreamingOutput; if (!wasStreaming || isStreamingOutput) { @@ -5059,21 +5553,19 @@ const VirtualMessageListSession = forwardRef { const scroller = scrollerElementRef.current; - if (!scroller) return; - - if (useStaticInitialHistoryListRef.current && staticAnchorWindowTurnId) { - pendingStaticLatestScrollBehaviorRef.current = behavior; - releaseStaticHistoryAnchorWindow(); + if (!canCoordinateViewport() || !scroller) { return; } @@ -5104,7 +5596,6 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX) { scroller.scrollTo({ top: effectiveBottomTop, behavior: 'auto' }); - recordStaticInitialHistoryBottomState(scroller); } setIsAtBottom(true); return; @@ -5117,33 +5608,20 @@ const VirtualMessageListSession = forwardRef { - const behavior = pendingStaticLatestScrollBehaviorRef.current; - if (!behavior || staticAnchorWindowTurnId || !useStaticInitialHistoryList) { - return; - } - - pendingStaticLatestScrollBehaviorRef.current = null; - scrollToLatestEndPositionInternal(behavior); - }, [ - scrollToLatestEndPositionInternal, - staticAnchorWindowTurnId, - useStaticInitialHistoryList, ]); - const requestTurnPinToTop = useCallback((turnId: string, options?: { behavior?: ScrollBehavior; pinMode?: FlowChatPinTurnToTopMode }): FlowChatTurnPinRequestStatus => { + const requestTurnPinToTop = useCallback((turnId: string, options?: TurnPinOptions): FlowChatTurnPinRequestStatus => { const requestedPinMode = options?.pinMode ?? 'transient'; const requestedBehavior = options?.behavior ?? 'auto'; + const requestedAlignmentPolicy = requestedPinMode === 'sticky-latest' + ? 'exact' + : options?.alignmentPolicy ?? 'exact'; const targetTurn = findDialogTurn(activeSession?.dialogTurns, turnId); if (requestedPinMode === 'sticky-latest' && !shouldUseStickyLatestPin(targetTurn)) { return 'rejected'; @@ -5157,38 +5635,15 @@ const VirtualMessageListSession = forwardRef { - if (useStaticInitialHistoryList || !virtuosoRef.current || pendingTurnPin) { + if ( + !canCoordinateViewport() || + !virtuosoRef.current || + pendingTurnPin + ) { return; } - const pendingStaticTurnPin = pendingStaticTurnPinRef.current; - if (!pendingStaticTurnPin) { + const preparedTurnPin = preparedHistoryTurnPinRef.current; + if ( + !preparedTurnPin + || !userMessageItems.some(({ item }) => item.turnId === preparedTurnPin.turnId) + ) { return; } - // The static renderer can be replaced by Virtuoso while a target window - // is still being materialized. Keep the target as Virtuoso's initial - // position (computed above), then continue through the normal pin - // transaction once the new scroller exists. - pendingStaticTurnPinRef.current = null; - releaseStaticHistoryAnchorWindow(); - requestTurnPinToTop(pendingStaticTurnPin.turnId, { - behavior: pendingStaticTurnPin.behavior, - pinMode: pendingStaticTurnPin.pinMode, + preparedHistoryTurnPinRef.current = null; + requestTurnPinToTop(preparedTurnPin.turnId, { + behavior: preparedTurnPin.behavior, + pinMode: preparedTurnPin.pinMode, + alignmentPolicy: preparedTurnPin.alignmentPolicy, }); }, [ pendingTurnPin, - releaseStaticHistoryAnchorWindow, + canCoordinateViewport, + isViewportActive, requestTurnPinToTop, scrollerElement, - useStaticInitialHistoryList, + userMessageItems, virtualItems.length, ]); @@ -5333,6 +5794,7 @@ const VirtualMessageListSession = forwardRef ( + canProcessViewportGeometry(scrollerElementRef.current) && viewportCoordinatorRef.current.getMode() === 'following-tail' && !viewportCoordinatorRef.current.ownsElementAnchor() ), @@ -5357,6 +5819,14 @@ const VirtualMessageListSession = forwardRef { + if (viewportMode !== 'history-reading') { + return; + } + cancelPendingAutoFollowArm(); + exitFollowOutput('pin-turn-to-top'); + }, [cancelPendingAutoFollowArm, exitFollowOutput, viewportMode]); + useEffect(() => { if (hasPrimedMountedStreamingTurnFollowRef.current) { return; @@ -5441,7 +5911,11 @@ const VirtualMessageListSession = forwardRef { - if (!latestTurnId || isFollowingOutput) { + if ( + !latestTurnId || + isFollowingOutput || + !canProcessViewportGeometry(scrollerElementRef.current) + ) { return false; } @@ -5518,6 +5992,7 @@ const VirtualMessageListSession = forwardRef { + const generation = viewportActivityGenerationRef.current + 1; + viewportActivityGenerationRef.current = generation; + + if (!isViewportActive) { + const scroller = scrollerElementRef.current; + if ( + scroller?.isConnected && + scroller.clientHeight > COMPENSATION_EPSILON_PX + ) { + suspendedViewportScrollTopRef.current = scroller.scrollTop; + } else { + suspendedViewportScrollTopRef.current = previousScrollTopRef.current; + } + + viewportGeometrySuspendedRef.current = true; + + const outstandingPinRequest = activeTurnPinRequestRef.current ?? pendingTurnPin; + if (outstandingPinRequest) { + preparedHistoryTurnPinRef.current = { + turnId: outstandingPinRequest.turnId, + behavior: 'auto', + pinMode: outstandingPinRequest.pinMode, + alignmentPolicy: outstandingPinRequest.alignmentPolicy, + }; + clearTurnPinRequest(); + } + + cancelLatestEndAnchorStabilization(); + clearCollapseIntentScheduling(); + clearRetainedCollapseSettlement(); + clearPendingStickyPinGrowth('viewport-suspended'); + pendingCollapseIntentRef.current = createInactiveCollapseIntentState(); + retainedCollapseAnchorRef.current = null; + + [ + measureFrameRef, + visibleTurnMeasureFrameRef, + pinReservationReconcileFrameRef, + turnPinStabilizationFrameRef, + fullHistoryProjectionIntentFrameRef, + ].forEach(frameRef => { + if (frameRef.current !== null) { + cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + }); + pendingFullHistoryProjectionReasonRef.current = null; + return; + } + + if (!viewportGeometrySuspendedRef.current) { + return; + } + + const restoreVisibleViewport = () => { + viewportReactivationFrameRef.current = null; + if ( + generation !== viewportActivityGenerationRef.current || + !isViewportActiveRef.current + ) { + return; + } + + const scroller = scrollerElementRef.current; + if (!isScrollerViewportMeasurable(scroller)) { + viewportReactivationFrameRef.current = requestAnimationFrame(restoreVisibleViewport); + return; + } + + viewportGeometrySuspendedRef.current = false; + + clearCollapseIntentScheduling(); + clearRetainedCollapseSettlement(); + clearPendingStickyPinGrowth('viewport-reactivated'); + pendingCollapseIntentRef.current = createInactiveCollapseIntentState(); + retainedCollapseAnchorRef.current = null; + + const currentState = bottomReservationStateRef.current; + let nextState: BottomReservationState = { + ...currentState, + collapse: { + kind: 'collapse', + px: 0, + floorPx: 0, + }, + }; + let stickyPinMetrics: ReturnType = null; + const stickyPinTarget = ( + currentState.pin.mode === 'sticky-latest' && + currentState.pin.targetTurnId + ) + ? currentState.pin.targetTurnId + : null; + if (stickyPinTarget) { + stickyPinMetrics = resolveTurnPinMetrics( + stickyPinTarget, + getTotalBottomCompensationPx(currentState), + ); + if (stickyPinMetrics) { + const requiredPinPx = clampPinReservationPxToViewport( + stickyPinMetrics.missingTailSpace, + scroller.clientHeight, + ); + nextState = { + ...nextState, + pin: { + kind: 'pin', + px: requiredPinPx, + floorPx: requiredPinPx, + mode: 'sticky-latest', + targetTurnId: stickyPinTarget, + }, + }; + } + } + + updateBottomReservationState(nextState); + applyFooterCompensationNow(nextState); + + if (stickyPinTarget && stickyPinMetrics) { + viewportCoordinatorRef.current.pinItem('viewport-reactivated-sticky-pin'); + const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); + scroller.scrollTop = Math.min(stickyPinMetrics.desiredScrollTop, maxScrollTop); + } else if (isFollowingOutputRef.current && isStreamingOutputRef.current) { + viewportCoordinatorRef.current.followTail({ force: true }); + scrollToLatestEndPositionInternal('auto'); + } else if (viewportCoordinatorRef.current.ownsElementAnchor()) { + viewportCoordinatorRef.current.restoreElementAnchor(scroller, 'viewport-reactivated'); + } else { + const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); + scroller.scrollTop = Math.min(suspendedViewportScrollTopRef.current, maxScrollTop); + } + + previousMeasuredHeightRef.current = snapshotMeasuredContentHeight( + scroller, + bottomReservationStateRef.current, + ); + previousScrollTopRef.current = scroller.scrollTop; + recordScrollerGeometry(scroller); + setViewportActivityRevision(revision => revision + 1); + + const preparedTurnPin = preparedHistoryTurnPinRef.current; + if ( + preparedTurnPin && + userMessageItems.some(({ item }) => item.turnId === preparedTurnPin.turnId) + ) { + preparedHistoryTurnPinRef.current = null; + requestTurnPinToTop(preparedTurnPin.turnId, { + behavior: 'auto', + pinMode: preparedTurnPin.pinMode, + alignmentPolicy: preparedTurnPin.alignmentPolicy, + }); + } + + scheduleHeightMeasure(2); + scheduleVisibleTurnMeasure(2); + schedulePinReservationReconcile(2); + scheduleTransientTurnPinStabilization(2); + maybeHandoffPinnedTurnToTailRef.current('viewport-reactivated'); + if (isFollowingOutputRef.current && isStreamingOutputRef.current) { + scheduleFollowToLatestWithViewportState('viewport-reactivated'); + } + }; + + viewportReactivationFrameRef.current = requestAnimationFrame(restoreVisibleViewport); + return () => { + if (viewportReactivationFrameRef.current !== null) { + cancelAnimationFrame(viewportReactivationFrameRef.current); + viewportReactivationFrameRef.current = null; + } + }; + }, [ + applyFooterCompensationNow, + cancelLatestEndAnchorStabilization, + clearCollapseIntentScheduling, + clearPendingStickyPinGrowth, + clearRetainedCollapseSettlement, + clearTurnPinRequest, + getTotalBottomCompensationPx, + isScrollerViewportMeasurable, + isViewportActive, + pendingTurnPin, + recordScrollerGeometry, + requestTurnPinToTop, + resolveTurnPinMetrics, + scheduleFollowToLatestWithViewportState, + scheduleHeightMeasure, + schedulePinReservationReconcile, + scheduleTransientTurnPinStabilization, + scheduleVisibleTurnMeasure, + scrollToLatestEndPositionInternal, + snapshotMeasuredContentHeight, + updateBottomReservationState, + userMessageItems, + ]); + useEffect(() => { maybeHandoffPinnedTurnToTail('reservation-state-change'); }, [ @@ -5630,6 +6302,7 @@ const VirtualMessageListSession = forwardRef { if (!virtuosoRef.current) return; @@ -5649,13 +6327,22 @@ const VirtualMessageListSession = forwardRef { searchNavigationRequestIdRef.current += 1; @@ -5685,7 +6372,10 @@ const VirtualMessageListSession = forwardRef ( - currentTurnId === targetTurnId ? currentTurnId : targetTurnId - )); - } if (attempts < SEARCH_NAVIGATION_MAX_ATTEMPTS) { requestAnimationFrame(resolveExactTextPosition); } @@ -5864,11 +6542,11 @@ const VirtualMessageListSession = forwardRef { + const pinTurnToTopWithStatus = useCallback((turnId: string, options?: TurnPinOptions): FlowChatTurnPinRequestStatus => { const shouldExitFollowOutput = !( options?.pinMode === 'sticky-latest' && turnId === latestTurnId @@ -5894,7 +6572,33 @@ const VirtualMessageListSession = forwardRef { + const prepareTurnPinToTop = useCallback((turnId: string, options?: TurnPinOptions): FlowChatTurnPinRequestStatus => { + if (!turnId || !activeSessionIdRef.current) { + return 'rejected'; + } + + exitFollowOutput('pin-turn-to-top'); + clearTurnPinRequest(); + cancelLatestEndAnchorStabilization(); + retainedCollapseAnchorRef.current = null; + viewportCoordinatorRef.current.pinItem('prepare-history-window-turn-pin'); + preparedHistoryTurnPinRef.current = { + turnId, + behavior: options?.behavior ?? 'auto', + pinMode: options?.pinMode ?? 'transient', + alignmentPolicy: options?.alignmentPolicy ?? 'exact', + }; + retargetHistoryProjectionHandoff(turnId); + setIsAtBottom(false); + return 'pending'; + }, [ + cancelLatestEndAnchorStabilization, + clearTurnPinRequest, + exitFollowOutput, + retargetHistoryProjectionHandoff, + ]); + + const pinTurnToTop = useCallback((turnId: string, options?: TurnPinOptions) => { return pinTurnToTopWithStatus(turnId, options) !== 'rejected'; }, [pinTurnToTopWithStatus]); @@ -5921,20 +6625,26 @@ const VirtualMessageListSession = forwardRef { const scroller = scrollerElementRef.current; - if (scroller) { + if (canProcessViewportGeometry(scroller)) { + clearTurnPinRequest(); clearAllBottomReservationsForUserNavigation(); const nextScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); scroller.scrollTo({ top: nextScrollTop, behavior: 'auto', }); - staticInitialHistoryUserLeftBottomRef.current = false; previousScrollTopRef.current = nextScrollTop; previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(scroller); recordScrollerGeometry(scroller); setIsAtBottom(true); } - }, [clearAllBottomReservationsForUserNavigation, recordScrollerGeometry, snapshotMeasuredContentHeight]); + }, [ + clearAllBottomReservationsForUserNavigation, + clearTurnPinRequest, + canProcessViewportGeometry, + recordScrollerGeometry, + snapshotMeasuredContentHeight, + ]); const scrollToTurnEndAndClearPin = useCallback((turnId: string) => { const scroller = scrollerElementRef.current; @@ -5943,15 +6653,20 @@ const VirtualMessageListSession = forwardRef= virtualItems.length - 1 || - (Boolean(latestTurnId) && turnId === latestTurnId) - ); - const targetElement = getRenderedVirtualItemElement(targetIndex); - if (!targetElement) { - return reject('missing_static_target_element', { - targetIndex, - }); - } - - const readStaticTargetEndState = () => { - const rect = targetElement.getBoundingClientRect(); - 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, - ); - return { - endDeltaPx: rect.bottom - visibleBottom, - maxScrollTop: Math.max(0, scroller.scrollHeight - scroller.clientHeight), - rect, - visible: rect.bottom > visibleTop && rect.top < visibleBottom, - visibleHeight: visibleBottom - visibleTop, - }; - }; - const canUseStaticFastPath = (state: ReturnType) => { - const distanceFromBottom = Math.max(0, state.maxScrollTop - scroller.scrollTop); - const bottomTolerancePx = shouldSnapLatestToPhysicalBottom - ? LATEST_END_ANCHOR_STABLE_EPSILON_PX - : LATEST_END_ANCHOR_STATIC_FAST_PATH_TOLERANCE_PX; - return ( - state.visible && - state.rect.height <= state.visibleHeight + LATEST_END_ANCHOR_STATIC_FAST_PATH_TOLERANCE_PX && - Math.abs(state.endDeltaPx) <= LATEST_END_ANCHOR_STATIC_FAST_PATH_TOLERANCE_PX && - distanceFromBottom <= bottomTolerancePx - ); - }; - let staticState = readStaticTargetEndState(); - if (!canUseStaticFastPath(staticState)) { - const nextScrollTop = shouldSnapLatestToPhysicalBottom - ? staticState.maxScrollTop - : Math.max( - 0, - Math.min(staticState.maxScrollTop, scroller.scrollTop + staticState.endDeltaPx), - ); - if (Math.abs(nextScrollTop - scroller.scrollTop) > COMPENSATION_EPSILON_PX) { - scroller.scrollTop = nextScrollTop; - previousScrollTopRef.current = nextScrollTop; - previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(scroller); - recordScrollerGeometry(scroller); - recordStaticInitialHistoryBottomState(scroller); - staticState = readStaticTargetEndState(); - } - } - if (canUseStaticFastPath(staticState)) { - startupTrace.markPhase('flowchat_latest_end_anchor_request', { - targetIndex, - turnId, - virtualItemCount: virtualItems.length, - mode: 'static-initial-history-fast-path', - }); - scheduleVisibleTurnMeasure(1); - return true; - } - - 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, - mode: 'static-initial-history', - }); - resolveLatestEndAnchorStabilization('raf'); - return true; + return reject('missing_virtuoso', { targetIndex }); } latestEndAnchorRequestRef.current = { @@ -6095,23 +6720,37 @@ const VirtualMessageListSession = forwardRef { retainedCollapseAnchorRef.current = null; + clearTurnPinRequest(); + if (latestTurnId) { + retargetHistoryProjectionHandoff(latestTurnId); + } onUserScrollIntent?.(); enterFollowOutput('jump-to-latest'); - }, [enterFollowOutput, onUserScrollIntent]); + }, [ + clearTurnPinRequest, + enterFollowOutput, + latestTurnId, + onUserScrollIntent, + retargetHistoryProjectionHandoff, + ]); + + const handleScrollToLatestRequest = useCallback(() => { + if (viewportMode === 'history-reading' && onRequestJumpToLatest) { + onRequestJumpToLatest(); + return; + } + scrollToLatestEndPosition(); + }, [onRequestJumpToLatest, scrollToLatestEndPosition, viewportMode]); useImperativeHandle(ref, () => ({ scrollToTurn, @@ -6125,11 +6764,13 @@ const VirtualMessageListSession = forwardRef { - if (!useStaticInitialHistoryList) { - return { - items: virtualItems, - startIndex: 0, - omittedEstimatedHeightPx: 0, - trailingOmittedEstimatedHeightPx: 0, - renderedEstimatedHeightPx: 0, - totalEstimatedHeightPx: 0, - isWindowed: false, - }; - } - - if (!staticAnchorWindowTurnId) { - return selectInitialHistoryRenderWindow(virtualItems); - } - - const targetIndex = virtualItems.findIndex(item => ( - item.turnId === staticAnchorWindowTurnId && item.type === 'user-message' - )); - if (targetIndex < 0) { - return selectInitialHistoryRenderWindow(virtualItems); - } - - let startIndex = targetIndex; - while ( - startIndex > 0 && - virtualItems[startIndex - 1]?.turnId === staticAnchorWindowTurnId - ) { - startIndex -= 1; - } - - let renderedEstimatedHeightPx = 0; - let endIndex = startIndex; - const includedTurnIds = new Set(); - for (; endIndex < virtualItems.length; endIndex += 1) { - const item = virtualItems[endIndex]; - renderedEstimatedHeightPx += estimateVirtualMessageItemHeight(item); - if (item.turnId) { - includedTurnIds.add(item.turnId); - } - - const nextItem = virtualItems[endIndex + 1]; - const stillInsideSameTurn = Boolean(item.turnId) && nextItem?.turnId === item.turnId; - if ( - !stillInsideSameTurn && - includedTurnIds.size >= INITIAL_HISTORY_RENDER_MIN_TURN_COUNT && - renderedEstimatedHeightPx >= INITIAL_HISTORY_RENDER_MIN_ESTIMATED_HEIGHT_PX - ) { - endIndex += 1; - break; - } - } - - const totalEstimatedHeightPx = virtualItems.reduce( - (total, item) => total + estimateVirtualMessageItemHeight(item), - 0, - ); - const omittedEstimatedHeightPx = virtualItems - .slice(0, startIndex) - .reduce((total, item) => total + estimateVirtualMessageItemHeight(item), 0); - const trailingOmittedEstimatedHeightPx = virtualItems - .slice(endIndex) - .reduce((total, item) => total + estimateVirtualMessageItemHeight(item), 0); - - return { - items: virtualItems.slice(startIndex, endIndex), - startIndex, - omittedEstimatedHeightPx, - trailingOmittedEstimatedHeightPx, - renderedEstimatedHeightPx, - totalEstimatedHeightPx, - isWindowed: startIndex > 0 || endIndex < virtualItems.length, - }; - }, [staticAnchorWindowTurnId, useStaticInitialHistoryList, virtualItems]); + return selectInitialHistoryRenderWindow(virtualItems); + }, [virtualItems]); const initialHistoryRenderKey = [ activeSessionId ?? 'no-active-session', latestTurnId ?? 'no-latest-turn', virtualItems.length, initialHistoryRenderWindow.startIndex, ].join(':'); - const isInitialHistoryRenderWindowExpanded = - historyPagingActive || - !initialHistoryRenderWindow.isWindowed || - expandedInitialHistoryRenderKey === initialHistoryRenderKey; - const renderedInitialHistoryItems = isInitialHistoryRenderWindowExpanded - ? virtualItems - : initialHistoryRenderWindow.items; - const renderedInitialHistoryStartIndex = isInitialHistoryRenderWindowExpanded - ? 0 - : initialHistoryRenderWindow.startIndex; - const omittedInitialHistoryEstimatedHeightPx = isInitialHistoryRenderWindowExpanded - ? 0 - : initialHistoryRenderWindow.omittedEstimatedHeightPx; - const trailingOmittedInitialHistoryEstimatedHeightPx = isInitialHistoryRenderWindowExpanded - ? 0 - : initialHistoryRenderWindow.trailingOmittedEstimatedHeightPx; - const expandInitialHistoryRenderWindow = useCallback((reason: string) => { + const initialHistorySnapshot = React.useMemo(() => { if ( - !useStaticInitialHistoryList || - !initialHistoryRenderWindow.isWindowed || - expandedInitialHistoryRenderKey === initialHistoryRenderKey + !initialHistorySnapshotActive + || !useInitialHistoryRenderBudget + || !activeSessionId + || !latestTurnId + || initialHistoryRenderWindow.items.length === 0 ) { - return; - } - - const scroller = scrollerElementRef.current; - if (scroller) { - const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); - pendingInitialHistoryExpansionRef.current = { - scrollTop: scroller.scrollTop, - scrollHeight: scroller.scrollHeight, - omittedEstimatedHeightPx: initialHistoryRenderWindow.omittedEstimatedHeightPx, - wasAtBottom: Math.abs(maxScrollTop - scroller.scrollTop) <= COMPENSATION_EPSILON_PX, - }; - } else { - pendingInitialHistoryExpansionRef.current = null; + return null; } - startupTrace.markPhase('flowchat_initial_history_render_window_expanded', { + return { sessionId: activeSessionId, - reason, - startIndex: initialHistoryRenderWindow.startIndex, - renderedItemCount: initialHistoryRenderWindow.items.length, - totalItemCount: virtualItems.length, - omittedEstimatedHeightPx: Math.round(initialHistoryRenderWindow.omittedEstimatedHeightPx), - }); - setExpandedInitialHistoryRenderKey(initialHistoryRenderKey); + reason: 'initial-history-snapshot', + createdAtMs: initialHistorySnapshotCreatedAtMsRef.current, + items: initialHistoryRenderWindow.items, + mode: 'bottom-tail', + targetTurnId: preparedHistoryTurnPinRef.current?.turnId ?? latestTurnId, + footerHeightPx, + }; }, [ activeSessionId, - expandedInitialHistoryRenderKey, - initialHistoryRenderKey, - initialHistoryRenderWindow, - useStaticInitialHistoryList, - virtualItems.length, + footerHeightPx, + initialHistoryRenderWindow.items, + initialHistorySnapshotActive, + latestTurnId, + useInitialHistoryRenderBudget, ]); - const expandInitialHistoryRenderWindowIfNeeded = useCallback((reason: string) => { - if ( - !useStaticInitialHistoryList || - isInitialHistoryRenderWindowExpanded && activeSession?.isPartial !== true - ) { - return; - } - - const scroller = scrollerElementRef.current; - if (!scroller) { - return; - } - - if (activeSession?.isPartial === true) { - const pagingThresholdPx = omittedInitialHistoryEstimatedHeightPx + PARTIAL_HISTORY_STATIC_BOUNDARY_THRESHOLD_PX; - if (scroller.scrollTop <= pagingThresholdPx) { - revealPreviousHistoryWindowForUserIntent(reason); + useLayoutEffect(() => { + if (!initialHistorySnapshot) { + if (initialHistorySnapshotActive && !useInitialHistoryRenderBudget) { + setInitialHistorySnapshotActive(false); } return; } - if (!initialHistoryRenderWindow.isWindowed || omittedInitialHistoryEstimatedHeightPx <= 0) { - return; - } - - if (scroller.scrollTop > omittedInitialHistoryEstimatedHeightPx) { - return; - } - - expandInitialHistoryRenderWindow(reason); - }, [ - expandInitialHistoryRenderWindow, - activeSession?.isPartial, - initialHistoryRenderWindow.isWindowed, - isInitialHistoryRenderWindowExpanded, - omittedInitialHistoryEstimatedHeightPx, - revealPreviousHistoryWindowForUserIntent, - useStaticInitialHistoryList, - ]); - const scheduleInitialHistoryRenderWindowCheck = useCallback((reason: string) => { - if ( - !useStaticInitialHistoryList || - !initialHistoryRenderWindow.isWindowed || - isInitialHistoryRenderWindowExpanded - ) { - return; - } - - if (initialHistoryRenderWindowCheckFrameRef.current !== null) { - cancelAnimationFrame(initialHistoryRenderWindowCheckFrameRef.current); - } - - const scheduledSessionId = activeSessionId; - initialHistoryRenderWindowCheckFrameRef.current = requestAnimationFrame(() => { - initialHistoryRenderWindowCheckFrameRef.current = null; - if (activeSessionIdRef.current !== scheduledSessionId) { - return; - } - expandInitialHistoryRenderWindowIfNeeded(reason); - }); - }, [ - activeSessionId, - expandInitialHistoryRenderWindowIfNeeded, - initialHistoryRenderWindow.isWindowed, - isInitialHistoryRenderWindowExpanded, - useStaticInitialHistoryList, - ]); - const handleInitialHistoryStaticScroll = useCallback((event: React.UIEvent) => { - const scroller = event.currentTarget; - const distanceFromBottom = Math.max( - 0, - getEffectiveBottomScrollTop(scroller) - scroller.scrollTop, - ); - const atBottom = distanceFromBottom <= 50; - const hasRecentBottomReturnIntent = hasRecentStaticHistoryBottomReturnIntent(); - const hasRecentPreviousHistoryIntent = - performance.now() <= userInitiatedUpwardScrollUntilMsRef.current || - scrollbarPointerInteractionActiveRef.current; - const shouldReleaseStaticAnchorWindow = atBottom && - staticAnchorWindowTurnId !== null && - hasRecentBottomReturnIntent; - // A programmatic static-history pin can temporarily report the old - // physical bottom while its smooth scroll is still in flight. Keep the - // semantic state away from latest until the user explicitly heads back to - // the bottom. - setIsAtBottom(shouldReleaseStaticAnchorWindow || (atBottom && !staticAnchorWindowTurnId)); - if (shouldReleaseStaticAnchorWindow) { - releaseStaticHistoryAnchorWindow(); - } - if (hasRecentPreviousHistoryIntent) { - expandInitialHistoryRenderWindowIfNeeded('scroll-near-omitted-history'); - } - }, [ - expandInitialHistoryRenderWindowIfNeeded, - getEffectiveBottomScrollTop, - hasRecentStaticHistoryBottomReturnIntent, - staticAnchorWindowTurnId, - releaseStaticHistoryAnchorWindow, - ]); - const handleInitialHistoryStaticWheelCapture = useCallback((event: React.WheelEvent) => { - if (event.deltaY >= 0) { - return; - } - expandInitialHistoryRenderWindowIfNeeded('wheel-up'); - scheduleInitialHistoryRenderWindowCheck('wheel-up'); - }, [expandInitialHistoryRenderWindowIfNeeded, scheduleInitialHistoryRenderWindowCheck]); - const handleInitialHistoryStaticKeyDownCapture = useCallback((event: React.KeyboardEvent) => { - if ( - event.key === 'Home' || - event.key === 'PageUp' || - event.key === 'ArrowUp' - ) { - scheduleInitialHistoryRenderWindowCheck(`keyboard-${event.key}`); - } - }, [scheduleInitialHistoryRenderWindowCheck]); - useEffect(() => { - if ( - !useStaticInitialHistoryList || - !initialHistoryRenderWindow.isWindowed || - markedInitialHistoryRenderWindowKeyRef.current === initialHistoryRenderKey - ) { - return; + historyProjectionHandoffRef.current = initialHistorySnapshot; + setHistoryProjectionHandoff(initialHistorySnapshot); + setInitialHistorySnapshotActive(false); + if (markedInitialHistoryRenderWindowKeyRef.current !== initialHistoryRenderKey) { + markedInitialHistoryRenderWindowKeyRef.current = initialHistoryRenderKey; + startupTrace.markPhase('flowchat_initial_history_snapshot_activated', { + sessionId: activeSessionId, + startIndex: initialHistoryRenderWindow.startIndex, + renderedItemCount: initialHistoryRenderWindow.items.length, + totalItemCount: virtualItems.length, + }); } - - markedInitialHistoryRenderWindowKeyRef.current = initialHistoryRenderKey; - startupTrace.markPhase('flowchat_initial_history_render_window', { - sessionId: activeSessionId, - startIndex: initialHistoryRenderWindow.startIndex, - renderedItemCount: initialHistoryRenderWindow.items.length, - totalItemCount: virtualItems.length, - omittedEstimatedHeightPx: Math.round(initialHistoryRenderWindow.omittedEstimatedHeightPx), - renderedEstimatedHeightPx: Math.round(initialHistoryRenderWindow.renderedEstimatedHeightPx), - }); + scheduleHistoryProjectionHandoffRelease(3); }, [ activeSessionId, initialHistoryRenderKey, - initialHistoryRenderWindow, - useStaticInitialHistoryList, + initialHistoryRenderWindow.items.length, + initialHistoryRenderWindow.startIndex, + initialHistorySnapshot, + initialHistorySnapshotActive, + scheduleHistoryProjectionHandoffRelease, + useInitialHistoryRenderBudget, virtualItems.length, ]); - useLayoutEffect(() => { - const pending = pendingInitialHistoryExpansionRef.current; - if (!pending) { - return; - } - - if (expandedInitialHistoryRenderKey !== initialHistoryRenderKey) { - pendingInitialHistoryExpansionRef.current = null; - return; - } - - pendingInitialHistoryExpansionRef.current = null; - const scroller = scrollerElementRef.current; - if (!scroller) { - return; - } - - const nextScrollTop = mapInitialHistoryExpansionScrollTop({ - previousScrollTop: pending.scrollTop, - previousScrollHeight: pending.scrollHeight, - nextScrollHeight: scroller.scrollHeight, - omittedEstimatedHeightPx: pending.omittedEstimatedHeightPx, - wasAtBottom: pending.wasAtBottom, - clientHeight: scroller.clientHeight, - }); - scroller.scrollTop = nextScrollTop; - previousScrollTopRef.current = nextScrollTop; - previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(scroller); - recordScrollerGeometry(scroller); - }, [ - expandedInitialHistoryRenderKey, - initialHistoryRenderKey, - recordScrollerGeometry, - snapshotMeasuredContentHeight, - ]); - useEffect(() => { - return () => { - if (initialHistoryRenderWindowCheckFrameRef.current !== null) { - cancelAnimationFrame(initialHistoryRenderWindowCheckFrameRef.current); - initialHistoryRenderWindowCheckFrameRef.current = null; - } - }; - }, []); const sessionOpenProjectionHandoff = React.useMemo(() => { const previousActiveSessionId = previousActiveSessionIdForOpenHandoffRef.current; const isSessionSwitch = ( @@ -6594,7 +6993,6 @@ const VirtualMessageListSession = forwardRef { @@ -6661,9 +7058,11 @@ const VirtualMessageListSession = forwardRef { previousActiveSessionIdForOpenHandoffRef.current = activeSessionId; }, [activeSessionId]); - const activeHistoryProjectionHandoff = - activeSessionHistoryProjectionHandoff(historyProjectionHandoff, activeSessionId) ?? - activeSessionHistoryProjectionHandoff(sessionOpenProjectionHandoff, activeSessionId); + const activeHistoryProjectionHandoff = presentationMode === 'history-window' + ? null + : activeSessionHistoryProjectionHandoff(historyProjectionHandoff, activeSessionId) ?? + activeSessionHistoryProjectionHandoff(sessionOpenProjectionHandoff, activeSessionId) ?? + activeSessionHistoryProjectionHandoff(initialHistorySnapshot, activeSessionId); const hasCompactHistoricalProjection = virtualItems.length >= 6 && virtualItems .slice(-16) .every(item => @@ -6682,116 +7081,11 @@ const VirtualMessageListSession = forwardRef useInitialHistoryRenderBudget && !useStaticInitialHistoryList + () => useInitialHistoryRenderBudget ? virtualItems.map(estimateVirtualMessageItemHeight) : undefined, - [useInitialHistoryRenderBudget, useStaticInitialHistoryList, virtualItems], + [useInitialHistoryRenderBudget, virtualItems], ); - useLayoutEffect(() => { - if (!useStaticInitialHistoryList) { - autoScrolledInitialHistoryRenderKeyRef.current = null; - staticInitialHistoryUserLeftBottomRef.current = false; - cancelStaticInitialHistoryBottomGuard(); - return; - } - - const previousAutoScrollKey = autoScrolledInitialHistoryRenderKeyRef.current; - if (previousAutoScrollKey === initialHistoryRenderKey) { - return; - } - - const scroller = scrollerElementRef.current; - if (!scroller || staticAnchorWindowTurnId) { - return; - } - - if (previousAutoScrollKey !== null) { - const distanceFromBottom = Math.max( - 0, - getEffectiveBottomScrollTop(scroller) - scroller.scrollTop, - ); - const wasAtEffectiveBottom = isGeometryAtEffectiveBottom(previousScrollerGeometryRef.current); - if ( - staticInitialHistoryUserLeftBottomRef.current || - (!wasAtEffectiveBottom && distanceFromBottom > LATEST_END_ANCHOR_STABLE_EPSILON_PX) - ) { - staticInitialHistoryUserLeftBottomRef.current = true; - recordScrollerGeometry(scroller); - return; - } - } - - autoScrolledInitialHistoryRenderKeyRef.current = initialHistoryRenderKey; - const nextScrollTop = getEffectiveBottomScrollTop(scroller); - scroller.scrollTop = nextScrollTop; - staticInitialHistoryUserLeftBottomRef.current = false; - previousScrollTopRef.current = nextScrollTop; - previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(scroller); - recordScrollerGeometry(scroller); - startStaticInitialHistoryBottomGuard(); - scheduleVisibleTurnMeasure(1); - }, [ - activeSessionId, - cancelStaticInitialHistoryBottomGuard, - getEffectiveBottomScrollTop, - initialHistoryRenderKey, - isGeometryAtEffectiveBottom, - latestTurnId, - recordScrollerGeometry, - scheduleVisibleTurnMeasure, - snapshotMeasuredContentHeight, - startStaticInitialHistoryBottomGuard, - staticAnchorWindowTurnId, - useStaticInitialHistoryList, - ]); - useLayoutEffect(() => { - if ( - !useStaticInitialHistoryList || - autoScrolledInitialHistoryRenderKeyRef.current !== initialHistoryRenderKey || - performance.now() <= userInitiatedUpwardScrollUntilMsRef.current - ) { - return; - } - - const scroller = scrollerElementRef.current; - if (!scroller) { - return; - } - - const effectiveBottomScrollTop = getEffectiveBottomScrollTop(scroller); - if (Math.abs(effectiveBottomScrollTop - scroller.scrollTop) <= LATEST_END_ANCHOR_STABLE_EPSILON_PX) { - staticInitialHistoryUserLeftBottomRef.current = false; - recordScrollerGeometry(scroller); - return; - } - - if (staticInitialHistoryUserLeftBottomRef.current) { - recordScrollerGeometry(scroller); - return; - } - - const previousWasAtBottom = isGeometryAtEffectiveBottom(previousScrollerGeometryRef.current); - if (!previousWasAtBottom) { - recordStaticInitialHistoryBottomState(scroller); - recordScrollerGeometry(scroller); - return; - } - - scroller.scrollTop = effectiveBottomScrollTop; - staticInitialHistoryUserLeftBottomRef.current = false; - previousScrollTopRef.current = effectiveBottomScrollTop; - previousMeasuredHeightRef.current = snapshotMeasuredContentHeight(scroller); - recordScrollerGeometry(scroller); - }, [ - footerHeightPx, - getEffectiveBottomScrollTop, - initialHistoryRenderKey, - isGeometryAtEffectiveBottom, - recordScrollerGeometry, - recordStaticInitialHistoryBottomState, - snapshotMeasuredContentHeight, - useStaticInitialHistoryList, - ]); const previousHistoryBoundaryStatusNode = React.useMemo( () => previousHistoryBoundaryStatus?.sessionId === activeSessionId && ( previousHistoryBoundaryStatus.state === 'not-ready' || !historyPagingActive @@ -6810,28 +7104,60 @@ const VirtualMessageListSession = forwardRef historyPagingActive && activeSession?.isPartial === true ? ( + () => ( + presentationMode === 'tail' + && historyPagingActive + && activeSession?.isPartial === true + ) ? ( ) : null, - [activeSession?.isPartial, historyPagingActive, historyPagingLoading, t], + [activeSession?.isPartial, historyPagingActive, historyPagingLoading, presentationMode, t], ); + const previousHistoryWindowSentinelNode = React.useMemo(() => { + if (presentationMode !== 'history-window' || historyBoundaryState.before === 'idle') { + return null; + } + return ( + + ); + }, [historyBoundaryState.before, presentationMode, t]); + const nextHistoryWindowSentinelNode = React.useMemo(() => { + if (presentationMode !== 'history-window' || historyBoundaryState.after === 'idle') { + return null; + } + return ( + + ); + }, [historyBoundaryState.after, presentationMode, t]); const virtuosoContext = React.useMemo(() => ({ // Reservation pixels are applied imperatively to the stable Footer node. // Keeping them out of context prevents a measurement-sensitive Virtuoso // render for every compensation update. footerRef: handleFooterElementRef, previousHistoryBoundaryStatusNode: <> + {previousHistoryWindowSentinelNode} {historyPagingSentinelNode} {previousHistoryBoundaryStatusNode} , + nextHistoryBoundaryStatusNode: nextHistoryWindowSentinelNode, runtimeStatusSessionId: activeSessionId, }), [ activeSessionId, handleFooterElementRef, historyPagingSentinelNode, + nextHistoryWindowSentinelNode, + previousHistoryWindowSentinelNode, previousHistoryBoundaryStatusNode, ]); const computeVirtuosoItemKey = useCallback((_: number, item: VirtualItem) => ( @@ -6861,107 +7187,59 @@ const VirtualMessageListSession = forwardRef - {useStaticInitialHistoryList ? ( -
-
- {historyPagingSentinelNode} - {previousHistoryBoundaryStatusNode} - {omittedInitialHistoryEstimatedHeightPx > 0 ? ( -