From 3425811914f5c32df08007cb1e3e4aa3afd747ab Mon Sep 17 00:00:00 2001 From: wsp Date: Sat, 1 Aug 2026 01:35:52 +0800 Subject: [PATCH 01/23] feat(flowchat): add persistent turn catalog navigation - persist versioned turn catalog sidecars with bounded previews and safe recovery - expose catalog metadata and timing through desktop and peer session restore responses - render all turn markers with non-navigable placeholders and full-history fallback navigation - cover catalog persistence, compatibility, and FlowChat rail behavior --- .../cli/src/peer_host/commands/session.rs | 3 +- src/apps/desktop/src/api/agentic_api.rs | 16 +- .../src/runtime/session_application.rs | 6 +- .../core/src/agentic/persistence/manager.rs | 668 +++++++++++++++++- .../src/agentic/session/session_manager.rs | 1 + .../assembly/core/src/product_runtime.rs | 70 +- src/crates/contracts/runtime-ports/src/lib.rs | 2 + .../tests/session_store_contracts.rs | 4 +- .../services-core/src/session/layout.rs | 4 + .../services-core/src/session/types.rs | 34 + .../tests/session_layout_contracts.rs | 7 + .../modern/FlowChatTurnRail.test.tsx | 72 +- .../components/modern/FlowChatTurnRail.tsx | 67 +- ...rnFlowChatContainer.history-state.test.tsx | 107 ++- .../modern/ModernFlowChatContainer.tsx | 115 ++- .../src/flow_chat/store/FlowChatStore.test.ts | 54 ++ .../src/flow_chat/store/FlowChatStore.ts | 86 ++- src/web-ui/src/flow_chat/types/flow-chat.ts | 2 + .../api/service-api/AgentAPI.ts | 3 + .../src/shared/types/session-history.ts | 17 + 20 files changed, 1231 insertions(+), 107 deletions(-) diff --git a/src/apps/cli/src/peer_host/commands/session.rs b/src/apps/cli/src/peer_host/commands/session.rs index b7f936022c..785ed2b156 100644 --- a/src/apps/cli/src/peer_host/commands/session.rs +++ b/src/apps/cli/src/peer_host/commands/session.rs @@ -218,7 +218,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 +239,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 a911bbb251..11f6cdb3c3 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -51,7 +51,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, }; use bitfun_core::service::workspace::WorkspaceKind; use bitfun_core::service::workspace::{WorkspaceActivityMode, WorkspaceCreateOptions}; @@ -463,6 +463,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, @@ -2914,9 +2915,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); @@ -2943,18 +2951,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, diff --git a/src/apps/desktop/src/runtime/session_application.rs b/src/apps/desktop/src/runtime/session_application.rs index 7f71e049b8..5c560fe774 100644 --- a/src/apps/desktop/src/runtime/session_application.rs +++ b/src/apps/desktop/src/runtime/session_application.rs @@ -23,7 +23,7 @@ use bitfun_core::service::remote_ssh::workspace_state::get_effective_session_pat use bitfun_core::service::remote_ssh::SSHConnectionManager; use bitfun_core::service::session::{ DialogTurnData, DialogTurnKind, SessionMetadata, SessionStatus, SessionTranscriptExport, - SessionTranscriptExportOptions, + SessionTranscriptExportOptions, SessionTurnCatalog, }; use bitfun_core::service::session_usage::SessionUsageReport; use bitfun_core::service::token_usage::TokenUsageService; @@ -122,6 +122,7 @@ pub(crate) struct DesktopSessionViewRestore { pub session: Session, pub turns: Vec, pub total_turn_count: usize, + pub turn_catalog: SessionTurnCatalog, pub timings: SessionViewRestoreTiming, } @@ -689,7 +690,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 +710,7 @@ impl DesktopSessionApplication { session, turns, total_turn_count, + turn_catalog, timings, }) } diff --git a/src/crates/assembly/core/src/agentic/persistence/manager.rs b/src/crates/assembly/core/src/agentic/persistence/manager.rs index a8277c5fe3..ea669e0cd0 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,7 +26,8 @@ use crate::service::remote_ssh::workspace_state::{ }; use crate::service::session::{ DialogTurnData, SessionMetadata, SessionTranscriptExport, SessionTranscriptExportOptions, - TranscriptLineRange, SESSION_STORAGE_SCHEMA_VERSION, + SessionTurnCatalog, SessionTurnCatalogEntry, TranscriptLineRange, + SESSION_STORAGE_SCHEMA_VERSION, SESSION_TURN_CATALOG_SCHEMA_VERSION, }; use crate::service::workspace_runtime::WorkspaceRuntimeService; use crate::util::errors::{BitFunError, BitFunResult}; @@ -43,8 +45,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 +63,7 @@ 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; pub const SESSION_REFERENCE_TRANSCRIPT_CHAR_LIMIT: usize = 60_000; static SESSION_PERSISTENCE_LOCKS: OnceLock>>>> = @@ -153,6 +157,117 @@ struct ReadTurnPathsResult { max_turn_read_duration_ms: u64, } +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()); + if let Some(turn_id) = entry.turn_id.as_deref() { + hasher.update([1]); + hasher.update((turn_id.len() as u64).to_le_bytes()); + hasher.update(turn_id.as_bytes()); + } else { + hasher.update([0]); + } + if let Some(preview) = entry.preview.as_deref() { + hasher.update([1]); + hasher.update((preview.len() as u64).to_le_bytes()); + hasher.update(preview.as_bytes()); + } else { + hasher.update([0]); + } + hasher.update([u8::from(entry.preview_truncated)]); + } + let digest = hasher.finalize(); + format!("v1-{}", 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) +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct StoredSessionPromptCacheFile { schema_version: u32, @@ -466,6 +581,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 +2506,234 @@ 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. + /// + /// The read path never mutates Session storage. Missing or stale legacy + /// entries are returned as index-only placeholders and are completed by a + /// full restore or the next protected Turn save. + 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 physical_indices = match self + .list_indexed_turn_paths(workspace_path, session_id) + .await + { + Ok(paths) => complete_turn_catalog_indices( + paths + .into_iter() + .map(|(index, _)| index) + .chain(loaded_turns.iter().map(|turn| turn.turn_index)), + visible_total_turn_count, + ), + 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, + ) + } + }; + + let cached = self + .read_session_turn_catalog_cache(workspace_path, session_id) + .await; + let mut cached_by_index = cached + .map(|catalog| { + catalog + .entries + .into_iter() + .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 + .into_iter() + .take(visible_total_turn_count) + .collect::>(); + Ok(build_turn_catalog(session_id, visible_entries)) + } + + 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::>(); + let can_update_incrementally = cached.as_ref().is_some_and(|catalog| { + catalog.complete + && catalog.entries.len() <= physical_indices.len() + && physical_indices.len().saturating_sub(catalog.entries.len()) <= 1 + && catalog + .entries + .iter() + .zip(physical_indices.iter()) + .all(|(entry, index)| entry.storage_turn_index == *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 +2807,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 +3143,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 +3162,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 +3185,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 +3707,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,6 +3730,18 @@ 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.saturating_add(1), + error + ); + } + Ok(deleted) } @@ -3382,12 +3773,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 +3796,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 +3822,9 @@ impl PersistenceManager { #[cfg(test)] mod tests { use super::{ - context_snapshot_payload_stats, current_unix_secs, PendingSessionDirectory, - PersistenceManager, StoredDialogTurnFile, SESSION_REFERENCE_TRANSCRIPT_CHAR_LIMIT, + context_snapshot_payload_stats, current_unix_secs, truncate_turn_catalog_preview, + 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,8 +3838,8 @@ mod tests { use crate::infrastructure::PathManager; use crate::service::session::{ DialogTurnData, ModelRoundData, SessionMemoryMode, SessionMetadata, SessionRelationship, - SessionRelationshipKind, SessionTranscriptExportOptions, StoredSessionIndexFile, - TextItemData, UserMessageData, + SessionRelationshipKind, SessionTranscriptExportOptions, SessionTurnCatalog, + StoredSessionIndexFile, TextItemData, UserMessageData, }; use crate::BitFunError; use std::path::{Path, PathBuf}; @@ -4275,6 +4677,252 @@ 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")); + + 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 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")); + } + + #[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")); + } + #[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 add3dab667..104f5445b9 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -4707,6 +4707,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 f57eacb911..fe3cc3fe2d 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::{ @@ -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, }; use crate::service::session_usage::{ generate_session_usage_report_from_storage_path, SessionUsageReport, @@ -695,10 +696,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( @@ -706,7 +710,7 @@ impl CoreAgentRuntimeCompatibility { session_id, tail_turn_count, ) - .await + .await? } else { self.coordinator .restore_session_view_from_storage_path_tail_timed( @@ -714,7 +718,7 @@ impl CoreAgentRuntimeCompatibility { session_id, tail_turn_count, ) - .await + .await? } } else { let (session, turns, timing) = if include_internal { @@ -727,8 +731,21 @@ 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 restore_session_with_turns_from_storage_path( @@ -759,41 +776,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 6772e0e81a..9cef4a5b26 100644 --- a/src/crates/contracts/runtime-ports/src/lib.rs +++ b/src/crates/contracts/runtime-ports/src/lib.rs @@ -253,6 +253,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..c7151f8e5a 100644 --- a/src/crates/contracts/runtime-ports/tests/session_store_contracts.rs +++ b/src/crates/contracts/runtime-ports/tests/session_store_contracts.rs @@ -40,7 +40,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 +61,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..a8a2df72a0 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,39 @@ 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, + pub revision: String, + pub total_turn_count: usize, + pub complete: bool, + pub entries: Vec, +} + /// Full dialog turn data #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] 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/flow_chat/components/modern/FlowChatTurnRail.test.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.test.tsx index 44266f89de..e29530fcaf 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 @@ -35,10 +35,10 @@ 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', turnIndex: 1, content: 'First user message' }, + { itemKey: 'storage:1', turnId: 'turn-2', turnIndex: 2, content: 'Second user message' }, + { itemKey: 'storage:2', turnId: 'turn-3', turnIndex: 3, content: 'Third user message' }, + { itemKey: 'storage:3', turnId: 'turn-4', turnIndex: 4, content: 'Fourth user message' }, ]; describe('FlowChatTurnRail', () => { @@ -117,6 +117,70 @@ describe('FlowChatTurnRail', () => { expect(onNavigate).toHaveBeenCalledWith('turn-3'); }); + it('keeps placeholder markers visible and non-navigable', () => { + 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')).toBe('true'); + expect(placeholder?.getAttribute('data-turn-id')).toBeNull(); + + act(() => placeholder?.click()); + + expect(onNavigate).not.toHaveBeenCalled(); + 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')).toBe('true'); + + 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', () => { act(() => { root.render( 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..9fcca5db32 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.tsx @@ -5,9 +5,10 @@ import { observeElementResize } from '@/shared/utils/sharedResizeObserver'; import './FlowChatTurnRail.scss'; export interface FlowChatTurnRailItem { - turnId: string; + itemKey: string; + turnId: string | null; turnIndex: number; - content: string; + content: string | null; } interface FlowChatTurnRailProps { @@ -27,32 +28,36 @@ export const FlowChatTurnRail: React.FC = ({ const railRef = useRef(null); const listRef = useRef(null); const itemRefs = useRef(new Map()); - const [focusTurnId, setFocusTurnId] = useState( - currentTurnId ?? turns[0]?.turnId ?? null, + const currentItemKey = useMemo( + () => turns.find(turn => turn.turnId === currentTurnId)?.itemKey ?? null, + [currentTurnId, turns], + ); + const [focusItemKey, setFocusItemKey] = useState( + currentItemKey ?? turns[0]?.itemKey ?? null, ); const visibleTurnIdSet = useMemo(() => new Set(visibleTurnIds), [visibleTurnIds]); useEffect(() => { - const focusTurnStillExists = focusTurnId !== null && turns.some(turn => turn.turnId === focusTurnId); - if (!focusTurnStillExists) { - setFocusTurnId(currentTurnId ?? turns[0]?.turnId ?? null); + const focusItemStillExists = focusItemKey !== null && turns.some(turn => turn.itemKey === focusItemKey); + if (!focusItemStillExists) { + setFocusItemKey(currentItemKey ?? turns[0]?.itemKey ?? null); return; } if ( - currentTurnId && + currentItemKey && railRef.current && !railRef.current.contains(document.activeElement) ) { - setFocusTurnId(currentTurnId); + setFocusItemKey(currentItemKey); } - }, [currentTurnId, focusTurnId, turns]); + }, [currentItemKey, focusItemKey, turns]); const keepCurrentTurnVisible = useCallback(() => { - if (!currentTurnId) return; + if (!currentItemKey) return; const list = listRef.current; - const activeItem = itemRefs.current.get(currentTurnId); + const activeItem = itemRefs.current.get(currentItemKey); if (!list || !activeItem || list.clientHeight <= 0) return; const itemTop = activeItem.offsetTop; @@ -65,7 +70,7 @@ export const FlowChatTurnRail: React.FC = ({ } else if (itemBottom > visibleBottom) { list.scrollTop = itemBottom - list.clientHeight; } - }, [currentTurnId]); + }, [currentItemKey]); useLayoutEffect(() => { keepCurrentTurnVisible(); @@ -82,8 +87,8 @@ export const FlowChatTurnRail: React.FC = ({ const turn = turns[index]; if (!turn) return; - setFocusTurnId(turn.turnId); - itemRefs.current.get(turn.turnId)?.focus(); + setFocusItemKey(turn.itemKey); + itemRefs.current.get(turn.itemKey)?.focus(); }, [turns]); const handleKeyDown = useCallback((event: React.KeyboardEvent, index: number) => { @@ -125,41 +130,51 @@ export const FlowChatTurnRail: React.FC = ({ >
{turns.map((turn, index) => { - const isCurrent = turn.turnId === currentTurnId; - const isVisible = visibleTurnIdSet.has(turn.turnId); + const isCurrent = turn.turnId !== null && turn.turnId === currentTurnId; + const isVisible = turn.turnId !== null && visibleTurnIdSet.has(turn.turnId); const turnLabel = t('flowChatHeader.turnBadge', { current: turn.turnIndex }); - const content = turn.content.trim() || untitledTurnLabel; + const content = turn.content === null + ? null + : turn.content.trim() || untitledTurnLabel; return ( {turnLabel} - {content} + {content !== null ? ( + {content} + ) : null} )} >
); @@ -227,9 +244,18 @@ export interface VirtualMessageListRef { pinTurnToTop: (turnId: string, options?: { behavior?: ScrollBehavior; pinMode?: FlowChatPinTurnToTopMode }) => boolean; // Detailed status for callers that must distinguish immediate feedback from deferred virtual-list settling. pinTurnToTopWithStatus: (turnId: string, options?: { behavior?: ScrollBehavior; pinMode?: FlowChatPinTurnToTopMode }) => FlowChatTurnPinRequestStatus; + // Establishes the existing pin transaction before a different presentation window is committed. + prepareTurnPinToTop: (turnId: string, options?: { behavior?: ScrollBehavior; pinMode?: FlowChatPinTurnToTopMode }) => FlowChatTurnPinRequestStatus; } export interface VirtualMessageListProps { + items?: VirtualItem[]; + presentationMode?: 'tail' | 'history-window'; + historyWindow?: ActiveTurnRenderRange | null; + presentationRevision?: number; + historyBoundaryState?: Record; + onHistoryWindowBoundaryIntent?: (direction: SessionHistoryWindowDirection) => void; + onRequestJumpToLatest?: () => void; onUserScrollIntent?: () => void; } @@ -387,27 +413,20 @@ 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, + presentationMode = '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 virtuosoIndexStateRef = useRef<{ sessionId: string | null; @@ -425,9 +444,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; } @@ -3231,12 +3257,66 @@ const VirtualMessageListSession = forwardRef { + if ( + presentationMode !== 'history-window' + || !historyWindow + || !onHistoryWindowBoundaryIntent + || historyBoundaryState[direction] === 'loading' + ) { + return false; + } + + const scroller = scrollerElementRef.current; + if (!scroller) { + 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; + } + + const anchor = captureHistoryPrependAnchor(); + if (anchor?.element) { + viewportCoordinatorRef.current.preserveElement(anchor.element); + } + startupTrace.markPhase('flowchat_history_window_boundary_requested', { + sessionId: activeSessionIdRef.current, + direction, + reason, + startOrdinal: historyWindow.startOrdinal, + endOrdinalExclusive: historyWindow.endOrdinalExclusive, + presentationRevision, + }); + onHistoryWindowBoundaryIntent(direction); + return true; + }, [ + captureHistoryPrependAnchor, + historyBoundaryState, + historyWindow, + onHistoryWindowBoundaryIntent, + presentationMode, + presentationRevision, + ]); + const revealPreviousHistoryWindowForUserIntent = useCallback((reason: string) => { const sessionId = activeSession?.sessionId; if ( + presentationMode === 'history-window' || !sessionId || activeSession.historyState !== 'ready' || activeSession.isPartial !== true @@ -3338,6 +3418,7 @@ const VirtualMessageListSession = forwardRef 0) { markStaticHistoryBottomReturnIntent(); + requestHistoryWindowBoundaryForUserIntent('after', 'wheel-down'); } else if (event.deltaY < 0) { clearStaticHistoryBottomReturnIntent(); + requestHistoryWindowBoundaryForUserIntent('before', 'wheel-up'); } if (event.deltaY < 0) { @@ -3895,8 +3978,10 @@ const VirtualMessageListSession = forwardRef { + if (presentationMode === 'history-window') { + return false; + } if (isProcessing) { return true; } @@ -4808,7 +4905,7 @@ const VirtualMessageListSession = forwardRef round.isStreaming); - }, [activeSession, isProcessing]); + }, [activeSession, isProcessing, presentationMode]); const initialTopMostItemIndex = React.useMemo(() => { const pendingTurnId = pendingTurnPin?.turnId ?? pendingStaticTurnPinRef.current?.turnId ?? @@ -4902,6 +4999,52 @@ 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(() => { const wasStreaming = previousIsStreamingOutputRef.current; previousIsStreamingOutputRef.current = isStreamingOutput; @@ -5357,6 +5500,14 @@ const VirtualMessageListSession = forwardRef { + if (presentationMode !== 'history-window') { + return; + } + cancelPendingAutoFollowArm(); + exitFollowOutput('pin-turn-to-top'); + }, [cancelPendingAutoFollowArm, exitFollowOutput, presentationMode]); + useEffect(() => { if (hasPrimedMountedStreamingTurnFollowRef.current) { return; @@ -5894,6 +6045,32 @@ const VirtualMessageListSession = forwardRef { + if (!turnId || !activeSessionIdRef.current) { + return 'rejected'; + } + + exitFollowOutput('pin-turn-to-top'); + clearTurnPinRequest(); + cancelLatestEndAnchorStabilization(); + clearStaticHistoryBottomReturnIntent(); + retainedCollapseAnchorRef.current = null; + viewportCoordinatorRef.current.pinItem('prepare-history-window-turn-pin'); + pendingStaticTurnPinRef.current = { + turnId, + behavior: options?.behavior ?? 'auto', + pinMode: options?.pinMode ?? 'transient', + }; + setStaticAnchorWindowTurnId(turnId); + setIsAtBottom(false); + return 'pending'; + }, [ + cancelLatestEndAnchorStabilization, + clearStaticHistoryBottomReturnIntent, + clearTurnPinRequest, + exitFollowOutput, + ]); + const pinTurnToTop = useCallback((turnId: string, options?: { behavior?: ScrollBehavior; pinMode?: FlowChatPinTurnToTopMode }) => { return pinTurnToTopWithStatus(turnId, options) !== 'rejected'; }, [pinTurnToTopWithStatus]); @@ -6113,6 +6290,14 @@ const VirtualMessageListSession = forwardRef { + if (presentationMode === 'history-window' && onRequestJumpToLatest) { + onRequestJumpToLatest(); + return; + } + scrollToLatestEndPosition(); + }, [onRequestJumpToLatest, presentationMode, scrollToLatestEndPosition]); + useImperativeHandle(ref, () => ({ scrollToTurn, scrollToIndex, @@ -6125,11 +6310,13 @@ const VirtualMessageListSession = forwardRef historyPagingActive && activeSession?.isPartial === true ? ( ) : null, [activeSession?.isPartial, historyPagingActive, historyPagingLoading, 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,6 +7076,8 @@ const VirtualMessageListSession = forwardRef {useStaticInitialHistoryList ? (
+ {previousHistoryWindowSentinelNode} {historyPagingSentinelNode} {previousHistoryBoundaryStatusNode} {omittedInitialHistoryEstimatedHeightPx > 0 ? ( @@ -6912,6 +7130,7 @@ const VirtualMessageListSession = forwardRef + {nextHistoryWindowSentinelNode}
@@ -6994,8 +7213,8 @@ const VirtualMessageListSession = forwardRef 0} - onClick={scrollToLatestEndPosition} + visible={(presentationMode === 'history-window' || !isAtBottom) && virtualItems.length > 0} + onClick={handleScrollToLatestRequest} isInputActive={isInputActive} isInputExpanded={isInputExpanded} inputHeight={inputHeight} diff --git a/src/web-ui/src/flow_chat/components/modern/virtualMessageListLayout.ts b/src/web-ui/src/flow_chat/components/modern/virtualMessageListLayout.ts index 284e00cb0c..41f0fc1e90 100644 --- a/src/web-ui/src/flow_chat/components/modern/virtualMessageListLayout.ts +++ b/src/web-ui/src/flow_chat/components/modern/virtualMessageListLayout.ts @@ -16,6 +16,26 @@ const TOOL_CARD_ESTIMATE_HEIGHT_PX = 88; const EXPLORE_GROUP_BASE_HEIGHT_PX = 96; const ESTIMATED_TEXT_CHARS_PER_LINE = 60; +export function getLeadingVirtualItemIndexDelta( + previousItems: readonly T[], + nextItems: readonly T[], + getStableKey: (item: T) => string, +): number { + if (previousItems.length === 0 || nextItems.length === 0) { + return 0; + } + + const previousFirstKey = getStableKey(previousItems[0]); + const prependedCount = nextItems.findIndex(item => getStableKey(item) === previousFirstKey); + if (prependedCount > 0) { + return -prependedCount; + } + + const nextFirstKey = getStableKey(nextItems[0]); + const removedCount = previousItems.findIndex(item => getStableKey(item) === nextFirstKey); + return removedCount > 0 ? removedCount : 0; +} + export function getVirtualMessageDefaultItemHeight(params: { isHistorical: boolean; hasCompactHistoricalProjection: boolean; diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index 5890b79376..5dd0dfd6dd 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -4013,6 +4013,125 @@ describe('FlowChatStore historical session hydration state', () => { expect(flowChatStore.getState().sessions.get('history-1')?.dialogTurns).toEqual([]); }); + it('activates only the current contiguous target window and extends it with prefetch data', async () => { + const catalog = createTurnCatalog(60); + flowChatStore.setState(() => ({ + sessions: new Map([[ + 'history-1', + createSession({ + sessionId: 'history-1', + historyState: 'ready', + isPartial: true, + totalTurnCount: 60, + turnCatalog: catalog, + }), + ]]), + activeSessionId: 'history-1', + })); + apiMocks.loadSessionTurnWindow + .mockResolvedValueOnce({ + status: 'ready', + catalogRevision: catalog.revision, + totalTurnCount: 60, + startOrdinal: 16, + endOrdinalExclusive: 33, + targetTurnId: 'turn-20', + turns: Array.from({ length: 17 }, (_, index) => createPersistedTurn(index + 16)), + }) + .mockResolvedValueOnce({ + status: 'ready', + catalogRevision: catalog.revision, + totalTurnCount: 60, + startOrdinal: 29, + endOrdinalExclusive: 46, + targetTurnId: 'turn-33', + turns: Array.from({ length: 17 }, (_, index) => createPersistedTurn(index + 29)), + }); + + const navigation = await flowChatStore.loadSessionTurnWindow('history-1', 20); + expect(navigation).toMatchObject({ status: 'ready', isCurrent: true }); + const activated = flowChatStore.activateSessionHistoryWindow( + 'history-1', + navigation.targetOrdinal, + navigation.navigationGeneration, + ); + expect(activated?.range).toMatchObject({ + startOrdinal: 16, + endOrdinalExclusive: 33, + targetTurnId: 'turn-20', + mode: 'history-window', + }); + expect(activated?.turns).toHaveLength(17); + + const generationBeforePrefetch = flowChatStore + .getSessionHistoryViewState('history-1') + ?.navigationGeneration; + const prefetch = await flowChatStore.loadSessionTurnWindow('history-1', 33, { + source: 'prefetch', + }); + expect(prefetch).toMatchObject({ status: 'ready', isCurrent: true }); + expect(flowChatStore.getSessionHistoryViewState('history-1')?.navigationGeneration).toBe( + generationBeforePrefetch, + ); + expect(flowChatStore.extendSessionHistoryWindow('history-1', 'after')?.range).toMatchObject({ + startOrdinal: 16, + endOrdinalExclusive: 46, + mode: 'history-window', + }); + expect(flowChatStore.getState().sessions.get('history-1')?.dialogTurns).toEqual([]); + }); + + it('trims the remote side of an oversized presentation while keeping a contiguous range', async () => { + const catalog = createTurnCatalog(100); + flowChatStore.setState(() => ({ + sessions: new Map([[ + 'history-1', + createSession({ + sessionId: 'history-1', + historyState: 'ready', + isPartial: true, + totalTurnCount: 100, + turnCatalog: catalog, + }), + ]]), + activeSessionId: 'history-1', + })); + apiMocks.loadSessionTurnWindow.mockResolvedValueOnce({ + status: 'ready', + catalogRevision: catalog.revision, + totalTurnCount: 100, + startOrdinal: 0, + endOrdinalExclusive: 100, + targetTurnId: 'turn-50', + turns: Array.from({ length: 100 }, (_, index) => createPersistedTurn(index)), + }); + + const navigation = await flowChatStore.loadSessionTurnWindow('history-1', 50); + const activated = flowChatStore.activateSessionHistoryWindow( + 'history-1', + navigation.targetOrdinal, + navigation.navigationGeneration, + ); + expect(activated?.range).toMatchObject({ + startOrdinal: 46, + endOrdinalExclusive: 94, + }); + + await flowChatStore.loadSessionTurnWindow('history-1', 45, { source: 'prefetch' }); + expect(flowChatStore.extendSessionHistoryWindow('history-1', 'before')?.range).toMatchObject({ + startOrdinal: 30, + endOrdinalExclusive: 94, + }); + await flowChatStore.loadSessionTurnWindow('history-1', 29, { source: 'prefetch' }); + const trimmed = flowChatStore.extendSessionHistoryWindow('history-1', 'before'); + expect(trimmed?.range).toMatchObject({ + startOrdinal: 14, + endOrdinalExclusive: 62, + targetTurnId: 'turn-50', + }); + expect(trimmed?.turns).toHaveLength(48); + }); + it('updates a stale catalog and retries the original Turn identity once', async () => { const catalog = createTurnCatalog(8, 'catalog-v1'); const relocatedCatalog = { diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 219a0949d4..03d69844d8 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -16,11 +16,13 @@ import { ImageAnalysisResult, AnyFlowItem, AcpContextUsage, + ActiveTurnRenderRange, LoadedTurnRange, LoadedTurnRangeSource, SessionConfig, SessionContextRestoreState, SessionHistoryViewState, + SessionHistoryPresentation, SessionHistoryState, TokenUsage, } from '../types/flow-chat'; @@ -151,6 +153,9 @@ const HISTORICAL_SESSION_PREVIOUS_WINDOW_TURN_COUNT = 12; const PEER_SESSION_REFRESH_TAIL_TURN_COUNT = 3; const SESSION_TURN_WINDOW_DEFAULT_BEFORE = 4; const SESSION_TURN_WINDOW_DEFAULT_AFTER = 12; +const SESSION_HISTORY_PRESENTATION_SOFT_TURN_BUDGET = 48; +const SESSION_HISTORY_PRESENTATION_HARD_TURN_BUDGET = 64; +const SESSION_HISTORY_PRESENTATION_PREFETCH_TURN_COUNT = 16; type RemoveSessionOptions = { nextActiveSessionId?: string | null; @@ -177,6 +182,8 @@ export interface LoadSessionTurnWindowOptions { source?: Exclude; } +export type SessionHistoryWindowDirection = 'before' | 'after'; + export type SessionTurnWindowLoadResult = { status: 'ready' | 'stale' | 'not-found' | 'unsupported'; sessionId: string; @@ -891,6 +898,93 @@ function mergeLoadedTurnRanges( return merged; } +function sliceLoadedTurnRange( + range: LoadedTurnRange, + startOrdinal: number, + endOrdinalExclusive: number, +): DialogTurn[] | null { + if ( + startOrdinal < range.startOrdinal + || endOrdinalExclusive > range.endOrdinalExclusive + || endOrdinalExclusive <= startOrdinal + ) { + return null; + } + + const turns = range.turns.slice( + startOrdinal - range.startOrdinal, + endOrdinalExclusive - range.startOrdinal, + ); + return turns.length === endOrdinalExclusive - startOrdinal ? turns : null; +} + +function selectTargetHistoryPresentationRange( + range: LoadedTurnRange, + targetOrdinal: number, +): { startOrdinal: number; endOrdinalExclusive: number } { + if (range.endOrdinalExclusive - range.startOrdinal <= SESSION_HISTORY_PRESENTATION_SOFT_TURN_BUDGET) { + return { + startOrdinal: range.startOrdinal, + endOrdinalExclusive: range.endOrdinalExclusive, + }; + } + + const beforeBudget = Math.min( + SESSION_TURN_WINDOW_DEFAULT_BEFORE, + SESSION_HISTORY_PRESENTATION_SOFT_TURN_BUDGET - 1, + ); + let startOrdinal = Math.max(range.startOrdinal, targetOrdinal - beforeBudget); + const endOrdinalExclusive = Math.min( + range.endOrdinalExclusive, + startOrdinal + SESSION_HISTORY_PRESENTATION_SOFT_TURN_BUDGET, + ); + startOrdinal = Math.max( + range.startOrdinal, + endOrdinalExclusive - SESSION_HISTORY_PRESENTATION_SOFT_TURN_BUDGET, + ); + + return { startOrdinal, endOrdinalExclusive }; +} + +function selectExtendedHistoryPresentationRange( + loadedRange: LoadedTurnRange, + activeRange: ActiveTurnRenderRange, + direction: SessionHistoryWindowDirection, +): { startOrdinal: number; endOrdinalExclusive: number } { + let startOrdinal = activeRange.startOrdinal; + let endOrdinalExclusive = activeRange.endOrdinalExclusive; + + if (direction === 'before') { + startOrdinal = Math.max( + loadedRange.startOrdinal, + activeRange.startOrdinal - SESSION_HISTORY_PRESENTATION_PREFETCH_TURN_COUNT, + ); + } else { + endOrdinalExclusive = Math.min( + loadedRange.endOrdinalExclusive, + activeRange.endOrdinalExclusive + SESSION_HISTORY_PRESENTATION_PREFETCH_TURN_COUNT, + ); + } + + if (endOrdinalExclusive - startOrdinal <= SESSION_HISTORY_PRESENTATION_HARD_TURN_BUDGET) { + return { startOrdinal, endOrdinalExclusive }; + } + + if (direction === 'before') { + endOrdinalExclusive = Math.min( + loadedRange.endOrdinalExclusive, + startOrdinal + SESSION_HISTORY_PRESENTATION_SOFT_TURN_BUDGET, + ); + } else { + startOrdinal = Math.max( + loadedRange.startOrdinal, + endOrdinalExclusive - SESSION_HISTORY_PRESENTATION_SOFT_TURN_BUDGET, + ); + } + + return { startOrdinal, endOrdinalExclusive }; +} + function startsWithStringArray(values: string[], prefix: string[]): boolean { return values.length >= prefix.length && prefix.every((value, index) => values[index] === value); } @@ -1155,6 +1249,108 @@ export class FlowChatStore { }; } + public activateSessionHistoryWindow( + sessionId: string, + targetOrdinal: number, + navigationGeneration: number, + ): SessionHistoryPresentation | null { + const view = this.sessionHistoryViews.get(sessionId); + const normalizedTargetOrdinal = Math.max(0, Math.floor(targetOrdinal)); + if ( + this.state.activeSessionId !== sessionId + || !view + || view.navigationGeneration !== navigationGeneration + || view.pendingTargetOrdinal !== normalizedTargetOrdinal + ) { + return null; + } + + const loadedRange = view.loadedRanges.find(range => + range.startOrdinal <= normalizedTargetOrdinal + && range.endOrdinalExclusive > normalizedTargetOrdinal + ); + if (!loadedRange) { + return null; + } + + const selected = selectTargetHistoryPresentationRange(loadedRange, normalizedTargetOrdinal); + const turns = sliceLoadedTurnRange( + loadedRange, + selected.startOrdinal, + selected.endOrdinalExclusive, + ); + if (!turns) { + return null; + } + + const targetTurn = turns[normalizedTargetOrdinal - selected.startOrdinal]; + const range: ActiveTurnRenderRange = { + ...selected, + targetTurnId: targetTurn?.id ?? null, + mode: 'history-window', + }; + loadedRange.lastAccessedAt = Date.now(); + view.activeRange = range; + view.pendingTargetOrdinal = null; + return { range: { ...range }, turns: [...turns] }; + } + + public extendSessionHistoryWindow( + sessionId: string, + direction: SessionHistoryWindowDirection, + ): SessionHistoryPresentation | null { + const view = this.sessionHistoryViews.get(sessionId); + const activeRange = view?.activeRange; + if ( + this.state.activeSessionId !== sessionId + || !view + || !activeRange + || activeRange.mode !== 'history-window' + ) { + return null; + } + + const loadedRange = view.loadedRanges.find(range => + range.startOrdinal <= activeRange.startOrdinal + && range.endOrdinalExclusive >= activeRange.endOrdinalExclusive + ); + if (!loadedRange) { + return null; + } + + const selected = selectExtendedHistoryPresentationRange(loadedRange, activeRange, direction); + const turns = sliceLoadedTurnRange( + loadedRange, + selected.startOrdinal, + selected.endOrdinalExclusive, + ); + if (!turns) { + return null; + } + + const targetTurnId = activeRange.targetTurnId && turns.some(turn => turn.id === activeRange.targetTurnId) + ? activeRange.targetTurnId + : null; + const range: ActiveTurnRenderRange = { + ...selected, + targetTurnId, + mode: 'history-window', + }; + loadedRange.lastAccessedAt = Date.now(); + view.activeRange = range; + return { range: { ...range }, turns: [...turns] }; + } + + public restoreSessionTailPresentation(sessionId: string): void { + const view = this.sessionHistoryViews.get(sessionId); + if (!view) { + return; + } + view.navigationGeneration += 1; + view.pendingTargetOrdinal = null; + view.activeRange = null; + } + private ensureSessionHistoryView( sessionId: string, catalog?: SessionTurnCatalog | null, @@ -1303,6 +1499,22 @@ export class FlowChatStore { && view.pendingTargetOrdinal === targetOrdinal; } + private isSessionTurnWindowRequestCurrent( + sessionId: string, + generation: number, + targetOrdinal: number, + source: 'target' | 'prefetch', + ): boolean { + if (source === 'target') { + return this.isSessionTurnNavigationCurrent(sessionId, generation, targetOrdinal); + } + + const view = this.sessionHistoryViews.get(sessionId); + return this.state.activeSessionId === sessionId + && view?.navigationGeneration === generation + && view.activeRange?.mode === 'history-window'; + } + private invalidateSessionTurnNavigationIntents(): void { for (const view of this.sessionHistoryViews.values()) { if (view.pendingTargetOrdinal === null) { @@ -1620,7 +1832,8 @@ export class FlowChatStore { : this.sessionHistoryViews.get(sessionId)?.catalog ?? undefined; const normalizedTargetOrdinal = Math.max(0, Math.floor(targetOrdinal)); const view = this.ensureSessionHistoryView(sessionId, catalog ?? null); - if (view.pendingTargetOrdinal !== normalizedTargetOrdinal) { + const source = options?.source ?? 'target'; + if (source === 'target' && view.pendingTargetOrdinal !== normalizedTargetOrdinal) { view.navigationGeneration += 1; view.pendingTargetOrdinal = normalizedTargetOrdinal; } @@ -1634,10 +1847,11 @@ export class FlowChatStore { sessionId, targetOrdinal: normalizedTargetOrdinal, navigationGeneration: generation, - isCurrent: this.isSessionTurnNavigationCurrent( + isCurrent: this.isSessionTurnWindowRequestCurrent( sessionId, generation, normalizedTargetOrdinal, + source, ), cacheHit: false, ...(catalog ? { catalog } : {}), @@ -1658,10 +1872,11 @@ export class FlowChatStore { targetOrdinal: normalizedTargetOrdinal, targetTurnId: cachedTarget?.id, navigationGeneration: generation, - isCurrent: this.isSessionTurnNavigationCurrent( + isCurrent: this.isSessionTurnWindowRequestCurrent( sessionId, generation, normalizedTargetOrdinal, + source, ), cacheHit: true, range: cachedRange, @@ -1677,10 +1892,11 @@ export class FlowChatStore { sessionId, targetOrdinal: normalizedTargetOrdinal, navigationGeneration: generation, - isCurrent: this.isSessionTurnNavigationCurrent( + isCurrent: this.isSessionTurnWindowRequestCurrent( sessionId, generation, normalizedTargetOrdinal, + source, ), cacheHit: false, catalog, @@ -1688,11 +1904,11 @@ export class FlowChatStore { } const before = Math.min( - SESSION_TURN_WINDOW_DEFAULT_BEFORE, + SESSION_HISTORY_PRESENTATION_PREFETCH_TURN_COUNT, Math.max(0, Math.floor(options?.before ?? SESSION_TURN_WINDOW_DEFAULT_BEFORE)), ); const after = Math.min( - SESSION_TURN_WINDOW_DEFAULT_AFTER, + SESSION_HISTORY_PRESENTATION_PREFETCH_TURN_COUNT, Math.max(1, Math.floor(options?.after ?? SESSION_TURN_WINDOW_DEFAULT_AFTER)), ); return this.loadSessionTurnWindowAttempt({ @@ -1709,7 +1925,7 @@ export class FlowChatStore { catalog, before, after, - source: options?.source ?? 'target', + source, generation, staleRetryCount: 0, }); @@ -1747,10 +1963,11 @@ export class FlowChatStore { targetOrdinal: request.targetOrdinal, targetTurnId: request.originalTargetTurnId, navigationGeneration: request.generation, - isCurrent: this.isSessionTurnNavigationCurrent( + isCurrent: this.isSessionTurnWindowRequestCurrent( request.sessionId, request.generation, request.targetOrdinal, + request.source, ), cacheHit: false, catalog: request.catalog, @@ -1800,10 +2017,11 @@ export class FlowChatStore { targetOrdinal: request.targetOrdinal, targetTurnId: request.originalTargetTurnId, navigationGeneration: request.generation, - isCurrent: this.isSessionTurnNavigationCurrent( + isCurrent: this.isSessionTurnWindowRequestCurrent( request.sessionId, request.generation, request.targetOrdinal, + request.source, ), cacheHit: false, catalog: request.catalog, @@ -1842,10 +2060,11 @@ export class FlowChatStore { targetOrdinal: request.targetOrdinal, targetTurnId: response.targetTurnId, navigationGeneration: request.generation, - isCurrent: this.isSessionTurnNavigationCurrent( + isCurrent: this.isSessionTurnWindowRequestCurrent( request.sessionId, request.generation, request.targetOrdinal, + request.source, ), cacheHit: false, range, @@ -1853,10 +2072,11 @@ export class FlowChatStore { }; } - const isCurrent = this.isSessionTurnNavigationCurrent( + const isCurrent = this.isSessionTurnWindowRequestCurrent( request.sessionId, request.generation, request.targetOrdinal, + request.source, ); const currentCatalog = this.sessionHistoryViews.get(request.sessionId)?.catalog; if ( @@ -1874,7 +2094,7 @@ export class FlowChatStore { ); if (relocatedEntry) { const view = this.sessionHistoryViews.get(request.sessionId); - if (view?.navigationGeneration === request.generation) { + if (request.source === 'target' && view?.navigationGeneration === request.generation) { view.pendingTargetOrdinal = relocatedEntry.ordinal; } return this.loadSessionTurnWindowAttempt({ diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index 08b67f1ef4..6a73260d50 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -306,6 +306,11 @@ export interface SessionHistoryViewState { navigationGeneration: number; } +export interface SessionHistoryPresentation { + range: ActiveTurnRenderRange; + turns: DialogTurn[]; +} + export type SessionContextRestoreState = | 'ready' | 'pending' From 01969aef89c70c0b97786ac2eb0566c529006573 Mon Sep 17 00:00:00 2001 From: wsp Date: Sat, 1 Aug 2026 11:27:05 +0800 Subject: [PATCH 04/23] perf(flow-chat): virtualize turn rail markers - Render turn markers in a fixed-height ordinal window with overscan. - Keep active turns visible and preserve keyboard focus across virtual windows. - Retain tooltip, visible-turn highlighting, and hidden-scrollbar behavior with regression coverage. --- .../components/modern/FlowChatTurnRail.scss | 14 +- .../modern/FlowChatTurnRail.test.tsx | 87 +++++- .../components/modern/FlowChatTurnRail.tsx | 294 +++++++++++++----- ...rnFlowChatContainer.history-state.test.tsx | 51 ++- .../modern/flowChatTurnRailWindow.test.ts | 66 ++++ .../modern/flowChatTurnRailWindow.ts | 82 +++++ 6 files changed, 492 insertions(+), 102 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/modern/flowChatTurnRailWindow.test.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/flowChatTurnRailWindow.ts 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 c78513b8af..3d099eca32 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: 14px; - 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: 14px; 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 2b5fa7ec90..129295dc6c 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 @@ -41,6 +41,16 @@ const turns: FlowChatTurnRailItem[] = [ { 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; @@ -198,14 +208,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(() => { @@ -219,7 +225,7 @@ describe('FlowChatTurnRail', () => { ); }); - expect(list.scrollTop).toBe(40); + expect(list.scrollTop).toBe(19); }); it('moves keyboard focus through the vertical turn list', () => { @@ -252,4 +258,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 * 14; + 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 7be9acb936..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,6 +2,13 @@ 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 { @@ -19,6 +26,11 @@ interface FlowChatTurnRailProps { onNavigate: (turn: FlowChatTurnRailItem) => void; } +interface FlowChatTurnRailViewportMetrics { + scrollTop: number; + clientHeight: number; +} + export const FlowChatTurnRail: React.FC = ({ turns, currentTurnId, @@ -28,50 +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 currentItemKey = useMemo( - () => turns.find(turn => turn.turnId === currentTurnId)?.itemKey ?? null, + const itemRefs = useRef(new Map()); + const pendingFocusOrdinalRef = useRef(null); + const currentTurnOrdinal = useMemo( + () => turns.find(turn => turn.turnId === currentTurnId)?.ordinal ?? null, [currentTurnId, turns], ); - const [focusItemKey, setFocusItemKey] = useState( - currentItemKey ?? turns[0]?.itemKey ?? null, + 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 focusItemStillExists = focusItemKey !== null && turns.some(turn => turn.itemKey === focusItemKey); - if (!focusItemStillExists) { - setFocusItemKey(currentItemKey ?? turns[0]?.itemKey ?? null); + const focusOrdinalStillExists = focusOrdinal !== null && turnByOrdinal.has(focusOrdinal); + if (!focusOrdinalStillExists) { + setFocusOrdinal(currentTurnOrdinal ?? turns[0]?.ordinal ?? null); return; } if ( - currentItemKey && - railRef.current && - !railRef.current.contains(document.activeElement) + currentTurnOrdinal !== null + && railRef.current + && !railRef.current.contains(document.activeElement) ) { - setFocusItemKey(currentItemKey); + setFocusOrdinal(currentTurnOrdinal); } - }, [currentItemKey, focusItemKey, turns]); + }, [currentTurnOrdinal, focusOrdinal, turnByOrdinal, turns]); const keepCurrentTurnVisible = useCallback(() => { - if (!currentItemKey) return; - - const list = listRef.current; - const activeItem = itemRefs.current.get(currentItemKey); - 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; } - }, [currentItemKey]); + scrollOrdinalIntoView(currentTurnOrdinal); + }, [currentTurnOrdinal, scrollOrdinalIntoView]); useLayoutEffect(() => { keepCurrentTurnVisible(); @@ -81,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; - setFocusItemKey(turn.itemKey); - itemRefs.current.get(turn.itemKey)?.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; @@ -117,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'); @@ -128,58 +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 !== null && turn.turnId === currentTurnId; - const isVisible = turn.turnId !== null && visibleTurnIdSet.has(turn.turnId); - const turnLabel = t('flowChatHeader.turnBadge', { current: turn.turnIndex }); - const content = turn.content === null - ? null - : turn.content.trim() || untitledTurnLabel; - - return ( - - {turnLabel} - {content !== null ? ( - {content} - ) : null} - - )} - > - - - ); - })} + + + ); + })} +
); 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 32663af35f..d3d64c1679 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 @@ -11,6 +11,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; @@ -255,6 +256,22 @@ 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; @@ -1232,7 +1249,12 @@ 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'); @@ -1326,15 +1348,23 @@ describe('ModernFlowChatContainer historical empty state', () => { currentTurn: 99, totalTurns: 100, }); - expect(container.querySelectorAll('.flowchat-turn-rail__item')).toHaveLength(100); - expect(container.querySelector('[data-turn-key="storage:0"]')?.getAttribute('aria-disabled')).toBeNull(); + 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('renders every catalog marker and resolves loaded tail identities', async () => { + it('windows catalog markers while resolving loaded tail identities', async () => { stateMocks.activeSession = createSession({ isHistorical: false, historyState: 'ready', @@ -1379,11 +1409,18 @@ describe('ModernFlowChatContainer historical empty state', () => { root.render(); }); - const markers = container.querySelectorAll('.flowchat-turn-rail__item'); - expect(markers).toHaveLength(100); - expect(container.querySelector('[data-turn-key="storage:0"]')?.getAttribute('aria-disabled')).toBeNull(); + 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 existing full-history fallback for an unloaded catalog target', async () => { diff --git a/src/web-ui/src/flow_chat/components/modern/flowChatTurnRailWindow.test.ts b/src/web-ui/src/flow_chat/components/modern/flowChatTurnRailWindow.test.ts new file mode 100644 index 0000000000..e32497fda3 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/flowChatTurnRailWindow.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { + FLOWCHAT_TURN_RAIL_ROW_HEIGHT_PX, + FLOWCHAT_TURN_RAIL_VERTICAL_PADDING_PX, + getFlowChatTurnRailScrollTopForOrdinal, + getFlowChatTurnRailTotalHeight, + getFlowChatTurnRailWindowRange, +} from './flowChatTurnRailWindow'; + +describe('flowChatTurnRailWindow', () => { + it('expresses the full ordinal range with fixed-height rows', () => { + expect(getFlowChatTurnRailTotalHeight(100)).toBe( + 100 * FLOWCHAT_TURN_RAIL_ROW_HEIGHT_PX + + FLOWCHAT_TURN_RAIL_VERTICAL_PADDING_PX * 2, + ); + }); + + it('bounds a long-session render window to visible rows plus overscan', () => { + expect(getFlowChatTurnRailWindowRange({ + scrollTop: 50 * FLOWCHAT_TURN_RAIL_ROW_HEIGHT_PX, + clientHeight: 4 * FLOWCHAT_TURN_RAIL_ROW_HEIGHT_PX, + totalOrdinalCount: 100, + })).toEqual({ + startOrdinal: 43, + endOrdinalExclusive: 60, + }); + }); + + it('keeps ordinals visible without moving an already visible viewport', () => { + expect(getFlowChatTurnRailScrollTopForOrdinal({ + ordinal: 1, + currentScrollTop: 0, + clientHeight: 40, + totalOrdinalCount: 4, + })).toBe(0); + expect(getFlowChatTurnRailScrollTopForOrdinal({ + ordinal: 3, + currentScrollTop: 0, + clientHeight: 40, + totalOrdinalCount: 4, + })).toBe(19); + expect(getFlowChatTurnRailScrollTopForOrdinal({ + ordinal: 0, + currentScrollTop: 50, + clientHeight: 40, + totalOrdinalCount: 4, + })).toBe(FLOWCHAT_TURN_RAIL_VERTICAL_PADDING_PX); + }); + + it('clamps ordinals and scroll positions to the track bounds', () => { + expect(getFlowChatTurnRailScrollTopForOrdinal({ + ordinal: 999, + currentScrollTop: 999, + clientHeight: 40, + totalOrdinalCount: 4, + })).toBe(22); + expect(getFlowChatTurnRailWindowRange({ + scrollTop: 999, + clientHeight: 40, + totalOrdinalCount: 4, + })).toEqual({ + startOrdinal: 0, + endOrdinalExclusive: 4, + }); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/modern/flowChatTurnRailWindow.ts b/src/web-ui/src/flow_chat/components/modern/flowChatTurnRailWindow.ts new file mode 100644 index 0000000000..7856b4fb09 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/flowChatTurnRailWindow.ts @@ -0,0 +1,82 @@ +export const FLOWCHAT_TURN_RAIL_ROW_HEIGHT_PX = 14; +export const FLOWCHAT_TURN_RAIL_VERTICAL_PADDING_PX = 3; +export const FLOWCHAT_TURN_RAIL_OVERSCAN_ROWS = 6; + +export interface FlowChatTurnRailWindowRange { + startOrdinal: number; + endOrdinalExclusive: number; +} + +export function getFlowChatTurnRailTotalHeight(totalOrdinalCount: number): number { + const normalizedCount = Math.max(0, Math.floor(totalOrdinalCount)); + return normalizedCount * FLOWCHAT_TURN_RAIL_ROW_HEIGHT_PX + + FLOWCHAT_TURN_RAIL_VERTICAL_PADDING_PX * 2; +} + +export function getFlowChatTurnRailWindowRange(options: { + scrollTop: number; + clientHeight: number; + totalOrdinalCount: number; + overscanRows?: number; +}): FlowChatTurnRailWindowRange { + const totalOrdinalCount = Math.max(0, Math.floor(options.totalOrdinalCount)); + if (totalOrdinalCount === 0) { + return { startOrdinal: 0, endOrdinalExclusive: 0 }; + } + + const clientHeight = Math.max(FLOWCHAT_TURN_RAIL_ROW_HEIGHT_PX, options.clientHeight); + const maxScrollTop = Math.max( + 0, + getFlowChatTurnRailTotalHeight(totalOrdinalCount) - clientHeight, + ); + const scrollTop = Math.max(0, Math.min(maxScrollTop, options.scrollTop)); + const overscanRows = Math.max( + 0, + Math.floor(options.overscanRows ?? FLOWCHAT_TURN_RAIL_OVERSCAN_ROWS), + ); + const contentScrollTop = Math.max(0, scrollTop - FLOWCHAT_TURN_RAIL_VERTICAL_PADDING_PX); + const visibleStartOrdinal = Math.floor(contentScrollTop / FLOWCHAT_TURN_RAIL_ROW_HEIGHT_PX); + const visibleEndOrdinalExclusive = Math.ceil( + (contentScrollTop + clientHeight) / FLOWCHAT_TURN_RAIL_ROW_HEIGHT_PX, + ); + + return { + startOrdinal: Math.max(0, visibleStartOrdinal - overscanRows), + endOrdinalExclusive: Math.min( + totalOrdinalCount, + visibleEndOrdinalExclusive + overscanRows, + ), + }; +} + +export function getFlowChatTurnRailScrollTopForOrdinal(options: { + ordinal: number; + currentScrollTop: number; + clientHeight: number; + totalOrdinalCount: number; +}): number { + const totalOrdinalCount = Math.max(0, Math.floor(options.totalOrdinalCount)); + if (totalOrdinalCount === 0 || options.clientHeight <= 0) { + return Math.max(0, options.currentScrollTop); + } + + const ordinal = Math.max(0, Math.min(totalOrdinalCount - 1, Math.floor(options.ordinal))); + const itemTop = FLOWCHAT_TURN_RAIL_VERTICAL_PADDING_PX + + ordinal * FLOWCHAT_TURN_RAIL_ROW_HEIGHT_PX; + const itemBottom = itemTop + FLOWCHAT_TURN_RAIL_ROW_HEIGHT_PX; + const currentScrollTop = Math.max(0, options.currentScrollTop); + const visibleBottom = currentScrollTop + options.clientHeight; + let nextScrollTop = currentScrollTop; + + if (itemTop < currentScrollTop) { + nextScrollTop = itemTop; + } else if (itemBottom > visibleBottom) { + nextScrollTop = itemBottom - options.clientHeight; + } + + const maxScrollTop = Math.max( + 0, + getFlowChatTurnRailTotalHeight(totalOrdinalCount) - options.clientHeight, + ); + return Math.max(0, Math.min(maxScrollTop, nextScrollTop)); +} From ada22a62c3a2aad03cbe86ab54a93974ef703ed7 Mon Sep 17 00:00:00 2001 From: wsp Date: Sat, 1 Aug 2026 13:01:22 +0800 Subject: [PATCH 05/23] perf(flow-chat): hydrate full history on demand - Skip automatic full restores for catalog-backed sessions and centralize explicit hydration. - Load adjacent tail history through bounded Turn windows while preserving legacy Host fallback. - Hydrate search, edit, and rollback consumers on demand and document scroll and Peer invariants. --- .../modern/FLOWCHAT_SCROLL_STABILITY.md | 34 ++- ...rnFlowChatContainer.history-state.test.tsx | 140 ++++++++++-- .../modern/ModernFlowChatContainer.tsx | 136 ++++++++---- .../modern/UserMessageItem.test.tsx | 92 +++++++- .../components/modern/UserMessageItem.tsx | 63 ++++-- ...rtualMessageList.session-boundary.test.tsx | 69 +++++- .../components/modern/VirtualMessageList.tsx | 47 +++- .../src/flow_chat/store/FlowChatStore.test.ts | 165 ++++++++------ .../src/flow_chat/store/FlowChatStore.ts | 201 +++++++++++++++--- .../src/infrastructure/peer-device/README.md | 10 + 10 files changed, 771 insertions(+), 186 deletions(-) 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..5cf5d0189f 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 @@ -367,10 +367,12 @@ This path is safer than doing nothing, but it is more likely to show visible mov ## C. Static Initial-History Turn Navigation 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. +and trailing spacers before handing the current presentation to Virtuoso. A +catalog-backed partial session keeps only its restored tail as the default +presentation; it does not hydrate the complete transcript after first paint. +The left-side turn rail may target a turn outside that tail. The container first +loads and materializes a bounded Turn window around the target and then issues +the requested scroll. 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 @@ -424,6 +426,30 @@ 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`. +- 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 captures the existing + element anchor, loads the adjacent ordinal window, and changes to one + contiguous history-window presentation. It must not expose a later cached + range across an unloaded gap. +- 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. +- 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/ModernFlowChatContainer.history-state.test.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx index d3d64c1679..1fcd98c544 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 @@ -277,6 +277,7 @@ describe('ModernFlowChatContainer historical empty state', () => { let root: Root; beforeEach(() => { + vi.restoreAllMocks(); rafCallbacks = []; vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => { rafCallbacks.push(callback); @@ -853,19 +854,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'), ], @@ -879,14 +877,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 () => { @@ -1423,10 +1422,10 @@ describe('ModernFlowChatContainer historical empty state', () => { expect(container.querySelector('[data-turn-id="turn-100"]')).toBeNull(); }); - it('requests the existing full-history fallback for an unloaded catalog target', async () => { - const projectionSpy = vi - .spyOn(flowChatStore, 'requestSessionFullHistoryProjection') - .mockReturnValue(true); + 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', @@ -1466,12 +1465,11 @@ describe('ModernFlowChatContainer historical empty state', () => { await Promise.resolve(); }); - expect(projectionSpy).toHaveBeenCalledWith('session-1', 'turn-rail-navigation'); + expect(ensureSpy).toHaveBeenCalledWith('session-1', 'turn-rail-navigation'); expect(virtualListMock.pinTurnToTopWithStatus).not.toHaveBeenCalledWith( 'turn-1', expect.anything(), ); - projectionSpy.mockRestore(); }); it('materializes a loaded Turn window before reusing the shared pin transaction', async () => { @@ -1599,6 +1597,106 @@ describe('ModernFlowChatContainer historical empty state', () => { 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}`), + ); + vi.spyOn(flowChatStore, 'getState').mockReturnValue({ + sessions: new Map([['session-1', stateMocks.activeSession]]), + activeSessionId: 'session-1', + }); + vi.spyOn(flowChatStore, 'getSessionHistoryViewState').mockReturnValue({ + catalog, + loadedRanges: [{ + startOrdinal: 8, + endOrdinalExclusive: 10, + turns: stateMocks.activeSession.dialogTurns, + lastAccessedAt: 1, + source: 'initial-tail', + }], + activeRange: null, + pendingTargetOrdinal: null, + navigationGeneration: 0, + }); + const loadSpy = vi.spyOn(flowChatStore, 'loadSessionTurnWindow').mockResolvedValue({ + status: 'ready', + sessionId: 'session-1', + targetOrdinal: 7, + targetTurnId: 'turn-8', + navigationGeneration: 0, + isCurrent: true, + cacheHit: false, + catalog, + }); + const activateSpy = vi.spyOn( + flowChatStore, + 'activateSessionHistoryWindowFromTail', + ).mockReturnValue({ + range: { + startOrdinal: 2, + endOrdinalExclusive: 10, + targetTurnId: null, + mode: 'history-window', + }, + turns: presentationTurns, + }); + + await act(async () => { + root.render(); + }); + await act(async () => { + const handled = await ( + virtualListPropsMock.latest?.onHistoryWindowBoundaryIntent as + | ((direction: 'before' | 'after') => Promise) + | undefined + )?.('before'); + expect(handled).toBe(true); + }); + + 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 51836606b5..92c84cba44 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -283,7 +283,7 @@ export const ModernFlowChatContainer: React.FC = ( IDLE_HISTORY_BOUNDARY_STATE, ); const historyPresentationRef = useRef(null); - const historyBoundaryRequestsRef = useRef | null>>({ + const historyBoundaryRequestsRef = useRef | null>>({ before: null, after: null, }); @@ -331,6 +331,7 @@ export const ModernFlowChatContainer: React.FC = ( const turnSummariesRef = useRef([]); const turnRailTurnIdsRef = useRef>(new Set()); const requestTurnPinRef = useRef<((turnId: string, behavior?: ScrollBehavior) => FlowChatTurnPinRequestStatus) | null>(null); + const searchFullHistorySessionIdRef = useRef(null); const virtualListRef = useRef(null); const chatScopeRef = useRef(null); const activeSessionIdRef = useRef(null); @@ -381,7 +382,7 @@ export const ModernFlowChatContainer: React.FC = ( }, [activeSession?.workspacePath, workspacePath]); const { searchQuery, - onSearchChange, + onSearchChange: setSearchQuery, matches: searchMatches, matchIndices: searchMatchIndices, currentMatchIndex: searchCurrentMatchIndex, @@ -998,6 +999,7 @@ export const ModernFlowChatContainer: React.FC = ( useLayoutEffect(() => { autoPinnedTurnKeyRef.current = null; releasedHistoryCompletionKeyRef.current = null; + searchFullHistorySessionIdRef.current = null; }, [activeSession?.sessionId]); useEffect(() => { @@ -1329,6 +1331,28 @@ export const ModernFlowChatContainer: React.FC = ( return true; }, [activeSession?.sessionId]); + 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(); + } else if (activeSessionIdRef.current === sessionId) { + searchFullHistorySessionIdRef.current = null; + } + }); + }, [activeSession?.isPartial, activeSession?.sessionId, restoreTailPresentation, setSearchQuery]); + const handleJumpToTurn = useCallback(async (target: FlowChatTurnRailItem | string) => { const targetItem = typeof target === 'string' ? turnRailItems.find(turn => turn.turnId === target) @@ -1408,12 +1432,12 @@ export const ModernFlowChatContainer: React.FC = ( } if (result.status === 'unsupported' || result.status === 'not-found') { - const historyRequested = flowChatStore.requestSessionFullHistoryProjection( + const historyReady = await flowChatStore.ensureSessionFullHistory( sessionId, 'turn-rail-navigation', ); - const historyPending = flowChatStore.hasPendingSessionHistoryCompletion(sessionId); - if (historyRequested || historyPending || result.fallbackRequested) { + if (historyReady && activeSessionIdRef.current === sessionId) { + restoreTailPresentation(); setQueuedTurnNavigation({ ordinal: targetItem.ordinal, turnId: targetItem.turnId, @@ -1444,31 +1468,54 @@ export const ModernFlowChatContainer: React.FC = ( activeSessionIdRef.current = activeSession?.sessionId ?? null; }, [activeSession?.sessionId]); - const handleHistoryWindowBoundaryIntent = useCallback((direction: SessionHistoryWindowDirection) => { - const presentation = historyPresentationRef.current; - const sessionId = activeSessionIdRef.current; - if ( - !presentation - || !sessionId - || presentation.sessionId !== sessionId - || historyBoundaryRequestsRef.current[direction] - ) { - return; + const handleHistoryWindowBoundaryIntent = useCallback(( + direction: SessionHistoryWindowDirection, + ): Promise => { + const existingRequest = historyBoundaryRequestsRef.current[direction]; + if (existingRequest) { + return existingRequest; } - const totalTurnCount = Math.max( - flowChatStore.getSessionHistoryViewState(sessionId)?.catalog?.totalTurnCount ?? 0, - flowChatStore.getState().sessions.get(sessionId)?.totalTurnCount ?? 0, - ); - const targetOrdinal = direction === 'before' - ? presentation.range.startOrdinal - 1 - : presentation.range.endOrdinalExclusive; - if (targetOrdinal < 0 || targetOrdinal >= totalTurnCount) { - return; - } - - setHistoryBoundaryState(previous => ({ ...previous, [direction]: 'loading' })); const request = (async () => { + const presentation = historyPresentationRef.current; + const sessionId = activeSessionIdRef.current; + if (!sessionId || (presentation && presentation.sessionId !== sessionId)) { + return false; + } + + 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 false; + } + const tailOrdinal = totalTurnCount - 1; + const tailRange = historyView?.loadedRanges.find(range => + range.startOrdinal <= tailOrdinal && range.endOrdinalExclusive > tailOrdinal + ); + if (!tailRange) { + return false; + } + targetOrdinal = tailRange.startOrdinal - 1; + } + if (targetOrdinal < 0 || targetOrdinal >= totalTurnCount) { + return false; + } + + setHistoryBoundaryState(previous => ({ ...previous, [direction]: 'loading' })); try { const result = await flowChatStore.loadSessionTurnWindow(sessionId, targetOrdinal, { source: 'prefetch', @@ -1476,19 +1523,32 @@ export const ModernFlowChatContainer: React.FC = ( after: direction === 'after' ? 12 : 1, }); if (!result.isCurrent || activeSessionIdRef.current !== sessionId) { - return; + return false; } 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 true; + } + } setHistoryBoundaryState(previous => ({ ...previous, [direction]: 'error' })); - return; + return false; } - const extended = flowChatStore.extendSessionHistoryWindow(sessionId, direction); - if (!extended) { + const nextPresentation = presentation + ? flowChatStore.extendSessionHistoryWindow(sessionId, direction) + : flowChatStore.activateSessionHistoryWindowFromTail(sessionId, targetOrdinal); + if (!nextPresentation) { setHistoryBoundaryState(previous => ({ ...previous, [direction]: 'error' })); - return; + return false; } - applyHistoryPresentation(sessionId, extended, { completedBoundary: direction }); + applyHistoryPresentation(sessionId, nextPresentation, { completedBoundary: direction }); + return true; } catch (error) { if (activeSessionIdRef.current === sessionId) { setHistoryBoundaryState(previous => ({ ...previous, [direction]: 'error' })); @@ -1499,11 +1559,13 @@ export const ModernFlowChatContainer: React.FC = ( targetOrdinal, error, }); - } finally { - historyBoundaryRequestsRef.current[direction] = null; + return false; } - })(); + })().finally(() => { + historyBoundaryRequestsRef.current[direction] = null; + }); historyBoundaryRequestsRef.current[direction] = request; + return request; }, [applyHistoryPresentation]); useEffect(() => { @@ -1904,7 +1966,7 @@ export const ModernFlowChatContainer: React.FC = ( if (turnId) handleJumpToTurn(turnId); }} searchQuery={searchQuery} - onSearchChange={onSearchChange} + onSearchChange={handleSearchChange} searchMatchCount={searchMatches.length} searchCurrentMatch={searchMatches.length > 0 ? searchCurrentMatchIndex + 1 : 0} onSearchNext={handleSearchNext} 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..b437d21919 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 @@ -12,6 +12,12 @@ 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), +})); vi.mock('react-i18next', () => ({ initReactI18next: { @@ -44,6 +50,7 @@ const flowChatStoreMock = vi.hoisted(() => ({ sessions: new Map(), activeSessionId: null, })), + ensureSessionFullHistory: vi.fn(async () => true), truncateDialogTurnsFrom: vi.fn(), })); @@ -55,9 +62,7 @@ vi.mock('../../store/FlowChatStore', () => ({ })); vi.mock('@/infrastructure/api', () => ({ - snapshotAPI: { - rollbackToTurn: vi.fn(), - }, + snapshotAPI: snapshotApiMock, })); vi.mock('@/shared/notification-system', () => ({ @@ -76,7 +81,7 @@ 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, })); describe('UserMessageItem steering tag', () => { @@ -86,6 +91,13 @@ 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([]); dom = new JSDOM('
', { pretendToBeVisual: true, }); @@ -404,7 +416,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 view', () => { + activeSessionRef.current = { + sessionId: 'partial-session', + sessionKind: 'normal', + isPartial: true, + loadedTurnCount: 1, + totalTurnCount: 20, + dialogTurns: [ + { + id: 'turn-20', + status: 'completed', + backendTurnIndex: 19, + }, + ], + }; + + act(() => { + root.render( + + + , + ); + }); + + expect(container.querySelector('.user-message-item__edit-btn')?.disabled).toBe(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 +472,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 +509,17 @@ 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); }); }); 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 5f3a41bd52..b992868c46 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx @@ -139,7 +139,17 @@ export const UserMessageItem = React.memo( const dialogTurn = turnIndex >= 0 ? currentSession?.dialogTurns[turnIndex] : null; const isFailed = dialogTurn?.status === 'error'; const resolvedSessionId = sessionId ?? currentSession?.sessionId; - const historyActionsBlockedByPartialRestore = currentSession?.isPartial === true; + const catalogTurnOrdinal = currentSession?.turnCatalog?.entries.find( + entry => entry.turnId === turnId, + )?.ordinal; + const partialTurnOffset = currentSession?.isPartial === true + ? Math.max( + 0, + (currentSession.totalTurnCount ?? currentSession.dialogTurns.length) + - currentSession.dialogTurns.length, + ) + : 0; + const actionTurnIndex = catalogTurnOrdinal ?? partialTurnOffset + turnIndex; const isRemoteSession = isRemoteWorkspaceSession(currentSession ?? undefined, null); const isSystemTriggered = Boolean( message?.metadata?.triggerSource && message.metadata.triggerSource !== 'desktop_ui', @@ -149,7 +159,6 @@ export const UserMessageItem = React.memo( canShowRollbackAction && !!resolvedSessionId && turnIndex >= 0 && - !historyActionsBlockedByPartialRestore && !isRemoteSession && !isRollingBack && !isEditSubmitting; @@ -157,7 +166,6 @@ export const UserMessageItem = React.memo( allowUserMessageEdit && !!resolvedSessionId && turnIndex >= 0 && - !historyActionsBlockedByPartialRestore && !isRemoteSession && !isThreadGoalSystemMessage && !isSystemTriggered && @@ -170,13 +178,11 @@ export const UserMessageItem = React.memo( ? t('message.cannotEdit') : steeringStatus ? t('message.cannotEdit') - : historyActionsBlockedByPartialRestore - ? t('message.editDisabledHistoryNotReady') - : !resolvedSessionId || turnIndex < 0 + : !resolvedSessionId || turnIndex < 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 +259,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 +276,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 +322,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(); @@ -321,7 +344,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 +361,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 +412,7 @@ export const UserMessageItem = React.memo( } }, [ cancelEdit, + actionTurnIndex, composerPresentation, currentSession?.mode, editDraft, 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 7031b9dc5b..869f6ea489 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 @@ -55,7 +55,6 @@ const virtuosoMocks = vi.hoisted(() => ({ const flowStoreMocks = vi.hoisted(() => ({ hasPendingSessionHistoryCompletion: vi.fn(() => false), hasDeferredSessionHistoryProjection: vi.fn(() => false), - requestSessionFullHistoryProjection: vi.fn(), revealPreviousSessionHistoryWindow: vi.fn(() => false), releaseSessionHistoryCompletionAfterInitialPaint: vi.fn(() => false), })); @@ -210,7 +209,6 @@ vi.mock('../../store/FlowChatStore', () => ({ }), hasPendingSessionHistoryCompletion: flowStoreMocks.hasPendingSessionHistoryCompletion, hasDeferredSessionHistoryProjection: flowStoreMocks.hasDeferredSessionHistoryProjection, - requestSessionFullHistoryProjection: flowStoreMocks.requestSessionFullHistoryProjection, revealPreviousSessionHistoryWindow: flowStoreMocks.revealPreviousSessionHistoryWindow, releaseSessionHistoryCompletionAfterInitialPaint: flowStoreMocks.releaseSessionHistoryCompletionAfterInitialPaint, }, @@ -406,7 +404,6 @@ describe('VirtualMessageList session boundary', () => { flowStoreMocks.hasPendingSessionHistoryCompletion.mockReturnValue(false); flowStoreMocks.hasDeferredSessionHistoryProjection.mockReset(); flowStoreMocks.hasDeferredSessionHistoryProjection.mockReturnValue(false); - flowStoreMocks.requestSessionFullHistoryProjection.mockReset(); flowStoreMocks.revealPreviousSessionHistoryWindow.mockReset(); flowStoreMocks.revealPreviousSessionHistoryWindow.mockReturnValue(false); flowStoreMocks.releaseSessionHistoryCompletionAfterInitialPaint.mockReset(); @@ -2608,7 +2605,6 @@ describe('VirtualMessageList session boundary', () => { flushAnimationFrame(); flushAnimationFrame(); - expect(flowStoreMocks.requestSessionFullHistoryProjection).not.toHaveBeenCalled(); expect(flowStoreMocks.revealPreviousSessionHistoryWindow).toHaveBeenCalledWith('session-a', 'wheel-up'); }); @@ -2782,11 +2778,71 @@ 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 () => 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(flowStoreMocks.revealPreviousSessionHistoryWindow).not.toHaveBeenCalled(); + expect(flowStoreMocks.releaseSessionHistoryCompletionAfterInitialPaint).not.toHaveBeenCalled(); + }); + it('surfaces a not-ready boundary state when a deferred history window cannot be revealed', () => { flowStoreMocks.hasDeferredSessionHistoryProjection.mockReturnValue(true); flowStoreMocks.revealPreviousSessionHistoryWindow.mockReturnValue(false); @@ -2824,7 +2880,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.'); }); @@ -2865,7 +2920,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, @@ -2909,7 +2963,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 11607a4136..38ba852903 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -44,6 +44,7 @@ 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, @@ -105,6 +106,8 @@ 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; @@ -254,7 +257,9 @@ export interface VirtualMessageListProps { historyWindow?: ActiveTurnRenderRange | null; presentationRevision?: number; historyBoundaryState?: Record; - onHistoryWindowBoundaryIntent?: (direction: SessionHistoryWindowDirection) => void; + onHistoryWindowBoundaryIntent?: ( + direction: SessionHistoryWindowDirection, + ) => boolean | void | Promise; onRequestJumpToLatest?: () => void; onUserScrollIntent?: () => void; } @@ -519,6 +524,7 @@ const VirtualMessageListSession = forwardRef(null); const historyPagingRetryTimerRef = useRef(null); + const catalogHistoryWindowRequestRef = useRef | null>(null); const pendingStaticTurnPinRef = useRef(null); const pendingStaticLatestScrollBehaviorRef = useRef<('auto' | 'smooth') | null>(null); const staticHistoryBottomReturnIntentUntilMsRef = useRef(0); @@ -3364,6 +3370,42 @@ const VirtualMessageListSession = forwardRef { + if (handled || activeSessionIdRef.current !== sessionId) { + return handled; + } + setHistoryPagingLoading(false); + showPreviousHistoryBoundaryStatus(sessionId, reason, 'not-ready'); + return handled; + }) + .catch(error => { + if (activeSessionIdRef.current === sessionId) { + setHistoryPagingLoading(false); + showPreviousHistoryBoundaryStatus(sessionId, reason, 'not-ready'); + } + log.warn('Failed to request an older catalog Turn window', { + sessionId, + error, + }); + return false; + }) + .finally(() => { + if (catalogHistoryWindowRequestRef.current === request) { + catalogHistoryWindowRequestRef.current = null; + } + }); + catalogHistoryWindowRequestRef.current = request; + return; + } + if (!flowChatStore.hasDeferredSessionHistoryProjection(sessionId)) { if (flowChatStore.hasPendingSessionHistoryCompletion(sessionId)) { const released = flowChatStore.releaseSessionHistoryCompletionAfterInitialPaint(sessionId, { @@ -3415,10 +3457,12 @@ const VirtualMessageListSession = forwardRef { describe('FlowChatStore historical session hydration state', () => { beforeEach(() => { peerModeFlagMock.active = false; + apiMocks.restoreSessionView.mockReset(); + apiMocks.restoreSessionWithTurns.mockReset(); + apiMocks.loadSessionTurnWindow.mockReset(); apiMocks.accountFetchSessionTurns.mockResolvedValue(false); vi.stubGlobal('CustomEvent', class { type: string; @@ -2448,7 +2451,7 @@ describe('FlowChatStore historical session hydration state', () => { flowChatStore.getState().sessions.get('history-1')?.dialogTurns.map(turn => turn.userMessage.content) ).toEqual(['latest prompt']); - expect(flowChatStore.requestSessionFullHistoryProjection('history-1', 'test')).toBe(true); + expect(await flowChatStore.ensureSessionFullHistory('history-1', 'test')).toBe(true); expect( flowChatStore.getState().sessions.get('history-1')?.dialogTurns.map(turn => turn.userMessage.content) ).toEqual(['older prompt', 'latest prompt']); @@ -2546,7 +2549,7 @@ describe('FlowChatStore historical session hydration state', () => { expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(2); expect(flowChatStore.hasDeferredSessionHistoryProjection('history-1')).toBe(true); - expect(flowChatStore.requestSessionFullHistoryProjection('history-1', 'test')).toBe(true); + expect(await flowChatStore.ensureSessionFullHistory('history-1', 'test')).toBe(true); expect( flowChatStore.getState().sessions.get('history-1')?.dialogTurns.map(turn => turn.userMessage.content) ).toEqual(['older prompt', 'latest prompt', 'new prompt']); @@ -2556,7 +2559,7 @@ describe('FlowChatStore historical session hydration state', () => { } }); - it('reveals a bounded previous history window from deferred cache without applying the full projection', async () => { + it('skips automatic full hydration for catalog-capable restores and hydrates on demand', async () => { vi.useFakeTimers(); const turnData = (turnIndex: number) => ({ turnId: `turn-${turnIndex}`, @@ -2639,53 +2642,36 @@ describe('FlowChatStore historical session hydration state', () => { try { await flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun'); - flowChatStore.releaseSessionHistoryCompletionAfterInitialPaint('history-1'); - await advanceReleasedLocalFullHistoryCompletion(); + expect(flowChatStore.hasPendingSessionHistoryCompletion('history-1')).toBe(false); + expect(flowChatStore.releaseSessionHistoryCompletionAfterInitialPaint('history-1')).toBe(false); + await vi.runOnlyPendingTimersAsync(); + await flushAsyncWork(); - expect(flowChatStore.hasDeferredSessionHistoryProjection('history-1')).toBe(true); + expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(1); + expect(flowChatStore.hasDeferredSessionHistoryProjection('history-1')).toBe(false); expect( flowChatStore.getState().sessions.get('history-1')?.dialogTurns.map(turn => turn.id) ).toEqual(['turn-5']); expect(flowChatStore.getState().sessions.get('history-1')?.turnCatalog).toMatchObject({ - revision: 'complete-catalog', - complete: true, + revision: 'partial-catalog', + complete: false, totalTurnCount: 5, }); - expect(flowChatStore.revealPreviousSessionHistoryWindow('history-1', 'wheel-up', 2)).toBe(true); - - const session = flowChatStore.getState().sessions.get('history-1'); - expect(session?.dialogTurns.map(turn => turn.id)).toEqual(['turn-3', 'turn-4', 'turn-5']); - expect(session).toMatchObject({ - isPartial: true, - loadedTurnCount: 3, - totalTurnCount: 5, - }); - expect(flowChatStore.hasDeferredSessionHistoryProjection('history-1')).toBe(true); - - const newTurn = { - id: 'turn-6', - sessionId: 'history-1', - userMessage: { id: 'user-6', content: 'new prompt', timestamp: 6 }, - modelRounds: [], - status: 'processing', - startTime: 6, - } as any; - flowChatStore.addDialogTurn('history-1', newTurn); - expect(flowChatStore.getState().sessions.get('history-1')).toMatchObject({ - loadedTurnCount: 4, - totalTurnCount: 6, - }); - - expect(flowChatStore.requestSessionFullHistoryProjection('history-1', 'search')).toBe(true); + expect(await flowChatStore.ensureSessionFullHistory('history-1', 'search')).toBe(true); + expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(2); expect( flowChatStore.getState().sessions.get('history-1')?.dialogTurns.map(turn => turn.id) - ).toEqual(['turn-1', 'turn-2', 'turn-3', 'turn-4', 'turn-5', 'turn-6']); - expect(flowChatStore.getState().sessions.get('history-1')?.dialogTurns[5]).toBe(newTurn); + ).toEqual(['turn-1', 'turn-2', 'turn-3', 'turn-4', 'turn-5']); + expect(flowChatStore.getState().sessions.get('history-1')?.turnCatalog).toMatchObject({ + revision: 'complete-catalog', + complete: true, + totalTurnCount: 5, + }); expect(flowChatStore.getState().sessions.get('history-1')).toMatchObject({ isPartial: false, - loadedTurnCount: 6, - totalTurnCount: 6, + loadedTurnCount: 5, + totalTurnCount: 5, }); expect(flowChatStore.hasDeferredSessionHistoryProjection('history-1')).toBe(false); } finally { @@ -2844,7 +2830,7 @@ describe('FlowChatStore historical session hydration state', () => { expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(1); expect(flowChatStore.hasDeferredSessionHistoryProjection('history-1')).toBe(true); - expect(flowChatStore.requestSessionFullHistoryProjection('history-1', 'test')).toBe(true); + expect(await flowChatStore.ensureSessionFullHistory('history-1', 'test')).toBe(true); expect( flowChatStore.getState().sessions.get('history-1')?.dialogTurns.map(turn => turn.userMessage.content) ).toEqual(['older prompt', 'latest prompt']); @@ -3141,7 +3127,7 @@ describe('FlowChatStore historical session hydration state', () => { ); }); - it('defers remote idle full history completion instead of replacing the visible tail', async () => { + it('lets an explicit full-history consumer bypass remote idle deferral', async () => { vi.useFakeTimers(); let idleCallback: (() => void) | null = null; const originalRequestIdleCallback = (globalThis as any).requestIdleCallback; @@ -3234,25 +3220,22 @@ describe('FlowChatStore historical session hydration state', () => { immediate: true, reason: 'test', })).toBe(false); - expect(flowChatStore.requestSessionFullHistoryProjection('history-remote', 'search-before-idle')).toBe(false); - expect( - ((flowChatStore as any).fullHistoryProjectionApplyRequests as Set).has('history-remote') - ).toBe(false); - - idleCallback?.(); + expect(await flowChatStore.ensureSessionFullHistory( + 'history-remote', + 'search-before-idle', + )).toBe(true); await hydrationPromise; expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(2); - expect(flowChatStore.hasDeferredSessionHistoryProjection('history-remote')).toBe(true); + expect(flowChatStore.hasDeferredSessionHistoryProjection('history-remote')).toBe(false); expect( flowChatStore.getState().sessions.get('history-remote')?.dialogTurns.map(turn => turn.userMessage.content) - ).toEqual(['latest prompt']); + ).toEqual(['older prompt', 'latest prompt']); + expect(flowChatStore.getState().sessions.get('history-remote')?.isPartial).toBe(false); - expect(flowChatStore.requestSessionFullHistoryProjection('history-remote', 'search')).toBe(false); - expect(flowChatStore.hasDeferredSessionHistoryProjection('history-remote')).toBe(true); - expect( - flowChatStore.getState().sessions.get('history-remote')?.dialogTurns.map(turn => turn.userMessage.content) - ).toEqual(['latest prompt']); + idleCallback?.(); + await flushAsyncWork(); + expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(2); } finally { (globalThis as any).requestIdleCallback = originalRequestIdleCallback; (globalThis as any).cancelIdleCallback = originalCancelIdleCallback; @@ -3355,7 +3338,7 @@ describe('FlowChatStore historical session hydration state', () => { expect( flowChatStore.getState().sessions.get('history-1')?.dialogTurns.map(turn => turn.userMessage.content) ).toEqual(['latest prompt']); - expect(flowChatStore.requestSessionFullHistoryProjection('history-1', 'test')).toBe(true); + expect(await flowChatStore.ensureSessionFullHistory('history-1', 'test')).toBe(true); expect( flowChatStore.getState().sessions.get('history-1')?.dialogTurns.map(turn => turn.userMessage.content) ).toEqual(['older prompt', 'latest prompt']); @@ -3461,7 +3444,7 @@ describe('FlowChatStore historical session hydration state', () => { expect( flowChatStore.getState().sessions.get('history-1')?.dialogTurns.map(turn => turn.userMessage.content) ).toEqual(['latest prompt']); - expect(flowChatStore.requestSessionFullHistoryProjection('history-1', 'test')).toBe(true); + expect(await flowChatStore.ensureSessionFullHistory('history-1', 'test')).toBe(true); expect( flowChatStore.getState().sessions.get('history-1')?.dialogTurns.map(turn => turn.userMessage.content) ).toEqual(['older prompt', 'latest prompt']); @@ -3961,6 +3944,69 @@ describe('FlowChatStore historical session hydration state', () => { ).toEqual(['turn-12', 'turn-13', 'turn-14']); }); + it('activates an adjacent catalog window from the restored tail without hydrating the session', async () => { + const catalog = createTurnCatalog(15); + apiMocks.restoreSessionView.mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 15, + createdAt: 1, + }, + turns: [12, 13, 14].map(index => createPersistedTurn(index)), + turnCatalog: catalog, + contextRestoreState: 'pending', + isPartial: true, + loadedTurnCount: 3, + totalTurnCount: 15, + }); + apiMocks.loadSessionTurnWindow.mockResolvedValueOnce({ + status: 'ready', + catalogRevision: catalog.revision, + totalTurnCount: 15, + startOrdinal: 0, + endOrdinalExclusive: 12, + targetTurnId: 'turn-11', + turns: Array.from({ length: 12 }, (_, index) => createPersistedTurn(index)), + }); + flowChatStore.setState(() => ({ + sessions: new Map([[ + 'history-1', + createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + }), + ]]), + activeSessionId: 'history-1', + })); + + await flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun'); + const loaded = await flowChatStore.loadSessionTurnWindow('history-1', 11, { + source: 'prefetch', + before: 12, + after: 1, + }); + expect(loaded).toMatchObject({ status: 'ready', isCurrent: true }); + + const presentation = flowChatStore.activateSessionHistoryWindowFromTail('history-1', 11); + expect(presentation?.range).toMatchObject({ + startOrdinal: 0, + endOrdinalExclusive: 15, + targetTurnId: null, + mode: 'history-window', + }); + expect(presentation?.turns).toHaveLength(15); + expect(flowChatStore.getState().sessions.get('history-1')).toMatchObject({ + isPartial: true, + loadedTurnCount: 3, + totalTurnCount: 15, + }); + expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(1); + }); + it('caches an older response without letting it remain the current navigation intent', async () => { const catalog = createTurnCatalog(10); flowChatStore.setState(() => ({ @@ -4215,14 +4261,14 @@ describe('FlowChatStore historical session hydration state', () => { ]]), activeSessionId: 'history-1', })); - const releaseFallback = vi.fn(); + const startFallback = vi.fn(); ((flowChatStore as any).fullHistoryHydrationRequests as Map).set('fallback', { sessionId: 'history-1', remote: false, requireActiveSession: true, sessionTraceId: 'fallback', promise: Promise.resolve(), - releaseAfterInitialPaint: releaseFallback, + startNow: startFallback, }); apiMocks.loadSessionTurnWindow.mockRejectedValueOnce( new Error('unknown command load_session_turn_window'), @@ -4232,10 +4278,7 @@ describe('FlowChatStore historical session hydration state', () => { status: 'unsupported', fallbackRequested: true, }); - expect(releaseFallback).toHaveBeenCalledWith({ - immediate: true, - reason: 'turn-window-unsupported', - }); + expect(startFallback).toHaveBeenCalledTimes(1); await expect(flowChatStore.loadSessionTurnWindow('history-1', 1)).resolves.toMatchObject({ status: 'unsupported', }); diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 03d69844d8..1383e4f2bb 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -783,6 +783,7 @@ interface FullHistoryHydrationRequest { sessionTraceId: string; promise: Promise; cancel?: () => void; + startNow?: () => void; releaseAfterInitialPaint?: (options?: FullHistoryHydrationReleaseOptions) => void; } @@ -793,6 +794,7 @@ interface CompleteSessionHistoryLoadRequest { remoteSshHost?: string; includeInternal?: boolean; requireActiveSession?: boolean; + startImmediately?: boolean; initialSessionTraceId: string; expectedDialogTurnIds: string[]; } @@ -1341,6 +1343,62 @@ export class FlowChatStore { return { range: { ...range }, turns: [...turns] }; } + public activateSessionHistoryWindowFromTail( + sessionId: string, + targetOrdinal: number, + ): SessionHistoryPresentation | null { + const session = this.state.sessions.get(sessionId); + const view = this.sessionHistoryViews.get(sessionId); + if ( + this.state.activeSessionId !== sessionId + || !session + || !view + || view.activeRange !== null + ) { + return null; + } + + const totalTurnCount = Math.max( + view.catalog?.totalTurnCount ?? 0, + session.totalTurnCount ?? 0, + session.dialogTurns.length, + ); + const normalizedTargetOrdinal = Math.max(0, Math.floor(targetOrdinal)); + const tailOrdinal = totalTurnCount - 1; + const loadedRange = view.loadedRanges.find(range => + range.startOrdinal <= normalizedTargetOrdinal + && range.endOrdinalExclusive > normalizedTargetOrdinal + && range.startOrdinal <= tailOrdinal + && range.endOrdinalExclusive > tailOrdinal + ); + if (!loadedRange) { + return null; + } + + const endOrdinalExclusive = Math.min(totalTurnCount, loadedRange.endOrdinalExclusive); + const startOrdinal = Math.max( + loadedRange.startOrdinal, + endOrdinalExclusive - SESSION_HISTORY_PRESENTATION_HARD_TURN_BUDGET, + ); + if (normalizedTargetOrdinal < startOrdinal) { + return null; + } + const turns = sliceLoadedTurnRange(loadedRange, startOrdinal, endOrdinalExclusive); + if (!turns) { + return null; + } + + const range: ActiveTurnRenderRange = { + startOrdinal, + endOrdinalExclusive, + targetTurnId: null, + mode: 'history-window', + }; + loadedRange.lastAccessedAt = Date.now(); + view.activeRange = range; + return { range: { ...range }, turns: [...turns] }; + } + public restoreSessionTailPresentation(sessionId: string): void { const view = this.sessionHistoryViews.get(sessionId); if (!view) { @@ -1512,7 +1570,7 @@ export class FlowChatStore { const view = this.sessionHistoryViews.get(sessionId); return this.state.activeSessionId === sessionId && view?.navigationGeneration === generation - && view.activeRange?.mode === 'history-window'; + && (view.activeRange === null || view.activeRange.mode === 'history-window'); } private invalidateSessionTurnNavigationIntents(): void { @@ -1569,7 +1627,9 @@ export class FlowChatStore { ]); } - private scheduleCompleteSessionHistoryLoad(request: CompleteSessionHistoryLoadRequest): void { + private scheduleCompleteSessionHistoryLoad( + request: CompleteSessionHistoryLoadRequest, + ): FullHistoryHydrationRequest { const requestKey = this.getFullHistoryHydrationKey( request.sessionId, request.workspacePath, @@ -1577,8 +1637,12 @@ export class FlowChatStore { request.remoteSshHost, request.includeInternal, ); - if (this.fullHistoryHydrationRequests.has(requestKey)) { - return; + const existingRequest = this.fullHistoryHydrationRequests.get(requestKey); + if (existingRequest) { + if (request.startImmediately === true) { + existingRequest.startNow?.(); + } + return existingRequest; } const remote = isRemoteTraceContext(request.remoteConnectionId, request.remoteSshHost); @@ -1595,9 +1659,17 @@ export class FlowChatStore { let cancelScheduled: (() => void) | undefined; let releaseAfterInitialPaint: ((options?: FullHistoryHydrationReleaseOptions) => void) | undefined; let resolveRequest: (() => void) | undefined; + let started = false; + let startFullHydrate: ( + trigger: 'idle' | 'initial_paint' | 'timeout' | 'explicit', + ) => void = () => undefined; const promise = new Promise(resolve => { resolveRequest = resolve; - const startFullHydrate = (trigger: 'idle' | 'initial_paint' | 'timeout' | 'explicit') => { + startFullHydrate = (trigger: 'idle' | 'initial_paint' | 'timeout' | 'explicit') => { + if (started) { + return; + } + started = true; startupTrace.markPhase('historical_session_full_hydrate_released', { remote, sessionId: request.sessionId, @@ -1619,6 +1691,11 @@ export class FlowChatStore { .finally(resolve); }; + if (request.startImmediately === true) { + startFullHydrate('explicit'); + return; + } + if (remote) { cancelScheduled = scheduleHistoricalSessionFullHydrate(() => startFullHydrate('idle')); return; @@ -1644,6 +1721,10 @@ export class FlowChatStore { cancelScheduled?.(); resolveRequest?.(); }, + startNow: () => { + cancelScheduled?.(); + startFullHydrate('explicit'); + }, }; if (releaseAfterInitialPaint) { @@ -1653,6 +1734,7 @@ export class FlowChatStore { } this.fullHistoryHydrationRequests.set(requestKey, hydrationRequest); + return hydrationRequest; } private cancelLocalSessionHistoryCompletion(sessionId: string, reason: string): boolean { @@ -1713,7 +1795,7 @@ export class FlowChatStore { } } - private scheduleActiveLocalPartialSessionHistoryCompletion( + private scheduleActiveLegacyPartialSessionHistoryCompletion( sessionId: string, reason: string ): boolean { @@ -1723,6 +1805,7 @@ export class FlowChatStore { !session || session.historyState !== 'ready' || session.isPartial !== true || + session.turnCatalog?.sessionId === sessionId || isRemoteTraceContext(session.remoteConnectionId, session.remoteSshHost) || this.hasPendingSessionHistoryCompletion(sessionId) || this.hasDeferredSessionHistoryProjection(sessionId) @@ -1767,38 +1850,78 @@ export class FlowChatStore { return this.deferredFullHistoryProjections.has(sessionId); } - public requestSessionFullHistoryProjection(sessionId: string, reason: string): boolean { + public async ensureSessionFullHistory(sessionId: string, reason: string): Promise { + const session = this.state.sessions.get(sessionId); + if (!session || session.historyState !== 'ready') { + return false; + } + if (session.isPartial !== true) { + return true; + } + this.fullHistoryProjectionApplyRequests.add(sessionId); const applied = this.applyDeferredSessionHistoryProjection(sessionId, reason); - const released = this.releaseSessionHistoryCompletionAfterInitialPaint(sessionId, { - immediate: true, - reason, - }); - - if (!applied && !released) { - this.fullHistoryProjectionApplyRequests.delete(sessionId); + if (applied) { + return true; } - if (applied || released) { - startupTrace.markPhase('historical_session_full_hydrate_projection_requested', { + let hydrationRequest = Array.from(this.fullHistoryHydrationRequests.values()).find( + request => request.sessionId === sessionId, + ); + if (!hydrationRequest) { + const workspacePath = sessionProjectWorkspacePath(session); + if (!workspacePath || session.dialogTurns.length === 0) { + this.fullHistoryProjectionApplyRequests.delete(sessionId); + return false; + } + + const sessionTraceId = `${sessionId.slice(0, 8)}-${Math.random().toString(36).slice(2, 8)}`; + hydrationRequest = this.scheduleCompleteSessionHistoryLoad({ sessionId, - reason, - applied, - released, + workspacePath, + remoteConnectionId: session.remoteConnectionId, + remoteSshHost: session.remoteSshHost, + includeInternal: session.sessionKind === 'subagent', + requireActiveSession: false, + startImmediately: true, + initialSessionTraceId: sessionTraceId, + expectedDialogTurnIds: session.dialogTurns.map(turn => turn.id), }); + } else { + hydrationRequest.startNow?.(); } - return applied || released; + startupTrace.markPhase('historical_session_full_history_ensure_requested', { + sessionId, + reason, + remote: hydrationRequest.remote, + loadedTurnCount: session.dialogTurns.length, + totalTurnCount: session.totalTurnCount, + }); + await hydrationRequest.promise; + + const appliedAfterLoad = this.applyDeferredSessionHistoryProjection(sessionId, reason); + const ready = this.state.sessions.get(sessionId)?.isPartial !== true; + if (!ready) { + this.fullHistoryProjectionApplyRequests.delete(sessionId); + } + startupTrace.markPhase('historical_session_full_history_ensure_finished', { + sessionId, + reason, + remote: hydrationRequest.remote, + ready, + applied: appliedAfterLoad, + }); + return ready; } private requestTurnWindowCompatibilityFallback(sessionId: string): boolean { - const requested = this.requestSessionFullHistoryProjection( - sessionId, - 'turn-window-unsupported', - ); - return requested - || this.hasPendingSessionHistoryCompletion(sessionId) - || this.hasDeferredSessionHistoryProjection(sessionId); + const session = this.state.sessions.get(sessionId); + if (!session || session.historyState !== 'ready' || session.isPartial !== true) { + return false; + } + void this.ensureSessionFullHistory(sessionId, 'turn-window-unsupported'); + return true; } private async invokeSessionTurnWindowRequest( @@ -2149,14 +2272,14 @@ export class FlowChatStore { } private shouldDeferFullHistoryProjection(sessionId: string, remote: boolean, _requireActiveSession: boolean): boolean { - if (remote) { - return true; - } - if (this.fullHistoryProjectionApplyRequests.has(sessionId)) { return false; } + if (remote) { + return true; + } + return this.state.activeSessionId === sessionId; } @@ -2188,7 +2311,7 @@ export class FlowChatStore { return false; } - if (projection.remote) { + if (projection.remote && !this.fullHistoryProjectionApplyRequests.has(sessionId)) { startupTrace.markPhase('historical_session_full_hydrate_remote_projection_blocked', { remote: true, sessionId, @@ -2906,7 +3029,7 @@ export class FlowChatStore { })); if (targetSessionExists && previousSessionId !== sessionId) { - this.scheduleActiveLocalPartialSessionHistoryCompletion(sessionId, 'session-switch'); + this.scheduleActiveLegacyPartialSessionHistoryCompletion(sessionId, 'session-switch'); } } @@ -6195,11 +6318,21 @@ export class FlowChatStore { durationMs: elapsedMs(traceStartedAt), }); if (restoredHistoryPartial) { + const supportsTurnCatalog = restoredTurnCatalog?.sessionId === sessionId; const deferFullHistoryUntilActive = !remote && options?.deferFullHistoryUntilActive === true && this.state.activeSessionId !== sessionId; - if (!deferFullHistoryUntilActive) { + if (supportsTurnCatalog) { + startupTrace.markPhase('historical_session_full_hydrate_skipped', { + remote, + sessionId, + sessionTraceId, + reason: 'turn-catalog-windowing-available', + loadedTurnCount: dialogTurns.length, + totalTurnCount: restoredTotalTurnCount, + }); + } else if (!deferFullHistoryUntilActive) { this.scheduleCompleteSessionHistoryLoad({ sessionId, workspacePath: storageWorkspacePath, diff --git a/src/web-ui/src/infrastructure/peer-device/README.md b/src/web-ui/src/infrastructure/peer-device/README.md index 7aa84707e1..264a3bed74 100644 --- a/src/web-ui/src/infrastructure/peer-device/README.md +++ b/src/web-ui/src/infrastructure/peer-device/README.md @@ -85,6 +85,16 @@ Controller-side React/transport layer for Peer Device Mode. Architecture: before replaying either command; an older host stays single-shot. A failed session list must leave its loading state and offer an explicit retry. +14. **Catalog-backed history stays windowed across the peer boundary.** + `restore_session_view` returns the compact `turnCatalog` plus the restored + tail; the controller must not follow it with an unconditional full restore. + `load_session_turn_window` is a high-priority, retryable read and carries + the same session/workspace scope as restore. Sequential history scrolling + and turn-rail navigation request bounded windows. Search, edit, rollback, + and an older Host that rejects the window command use the shared explicit + full-history ensure fallback. Never include catalog preview text in Peer + request/response logs. + ## Related account-login guards Incomplete login (cloud vs local settings choice) must not persist a session From d80f1334f62a89441da5a35ca4673f3ccf624e18 Mon Sep 17 00:00:00 2001 From: wsp Date: Sat, 1 Aug 2026 14:05:16 +0800 Subject: [PATCH 06/23] perf(flow-chat): bound loaded history with LRU eviction - Enforce soft and hard budgets for non-tail loaded Turn ranges. - Preserve live tail, active presentation, pending targets, and in-flight windows during eviction. - Track per-ordinal recency and safely slice merged ranges when reclaiming cache. - Add regression coverage for LRU ordering, protected windows, and tail retention. --- .../modern/FLOWCHAT_SCROLL_STABILITY.md | 5 + .../src/flow_chat/store/FlowChatStore.test.ts | 182 ++++- .../src/flow_chat/store/FlowChatStore.ts | 642 +++++++++++++++--- 3 files changed, 719 insertions(+), 110 deletions(-) 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 5cf5d0189f..ad0efefc78 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 @@ -443,6 +443,11 @@ these ownership rules intact: Prepending or trimming above it must retain the existing element-anchor transaction until the same user message returns to its captured viewport offset. +- 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. - 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. diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index f46c0f8a5e..4b7852b2d9 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -4127,7 +4127,7 @@ describe('FlowChatStore historical session hydration state', () => { expect(flowChatStore.getState().sessions.get('history-1')?.dialogTurns).toEqual([]); }); - it('trims the remote side of an oversized presentation while keeping a contiguous range', async () => { + it('slices oversized merged ranges around the protected presentation window', async () => { const catalog = createTurnCatalog(100); flowChatStore.setState(() => ({ sessions: new Map([[ @@ -4142,17 +4142,31 @@ describe('FlowChatStore historical session hydration state', () => { ]]), activeSessionId: 'history-1', })); - apiMocks.loadSessionTurnWindow.mockResolvedValueOnce({ - status: 'ready', - catalogRevision: catalog.revision, - totalTurnCount: 100, - startOrdinal: 0, - endOrdinalExclusive: 100, - targetTurnId: 'turn-50', - turns: Array.from({ length: 100 }, (_, index) => createPersistedTurn(index)), - }); + apiMocks.loadSessionTurnWindow + .mockResolvedValueOnce({ + status: 'ready', + catalogRevision: catalog.revision, + totalTurnCount: 100, + startOrdinal: 0, + endOrdinalExclusive: 100, + targetTurnId: 'turn-50', + turns: Array.from({ length: 100 }, (_, index) => createPersistedTurn(index)), + }) + .mockResolvedValueOnce({ + status: 'ready', + catalogRevision: catalog.revision, + totalTurnCount: 100, + startOrdinal: 29, + endOrdinalExclusive: 47, + targetTurnId: 'turn-45', + turns: Array.from({ length: 18 }, (_, index) => createPersistedTurn(index + 29)), + }); const navigation = await flowChatStore.loadSessionTurnWindow('history-1', 50); + expect(flowChatStore.getSessionHistoryViewState('history-1')?.loadedRanges).toMatchObject([{ + startOrdinal: 46, + endOrdinalExclusive: 94, + }]); const activated = flowChatStore.activateSessionHistoryWindow( 'history-1', navigation.targetOrdinal, @@ -4163,19 +4177,151 @@ describe('FlowChatStore historical session hydration state', () => { endOrdinalExclusive: 94, }); - await flowChatStore.loadSessionTurnWindow('history-1', 45, { source: 'prefetch' }); + await flowChatStore.loadSessionTurnWindow('history-1', 45, { + source: 'prefetch', + before: 16, + after: 1, + }); expect(flowChatStore.extendSessionHistoryWindow('history-1', 'before')?.range).toMatchObject({ startOrdinal: 30, endOrdinalExclusive: 94, }); - await flowChatStore.loadSessionTurnWindow('history-1', 29, { source: 'prefetch' }); - const trimmed = flowChatStore.extendSessionHistoryWindow('history-1', 'before'); - expect(trimmed?.range).toMatchObject({ - startOrdinal: 14, - endOrdinalExclusive: 62, - targetTurnId: 'turn-50', + expect(flowChatStore.getSessionHistoryViewState('history-1')?.loadedRanges).toMatchObject([{ + startOrdinal: 30, + endOrdinalExclusive: 94, + }]); + }); + + it('evicts least-recently-used non-tail ordinals while retaining recent target windows', async () => { + const catalog = createTurnCatalog(120); + flowChatStore.setState(() => ({ + sessions: new Map([[ + 'history-1', + createSession({ + sessionId: 'history-1', + historyState: 'ready', + isPartial: true, + totalTurnCount: 120, + turnCatalog: catalog, + }), + ]]), + activeSessionId: 'history-1', + })); + const windows = new Map([ + [5, { startOrdinal: 0, endOrdinalExclusive: 17 }], + [30, { startOrdinal: 25, endOrdinalExclusive: 42 }], + [55, { startOrdinal: 50, endOrdinalExclusive: 67 }], + [80, { startOrdinal: 75, endOrdinalExclusive: 92 }], + ]); + apiMocks.loadSessionTurnWindow.mockImplementation(async request => { + const targetOrdinal = request.targetStorageTurnIndex; + const window = windows.get(targetOrdinal); + if (!window) { + throw new Error(`Unexpected target ordinal ${targetOrdinal}`); + } + return { + status: 'ready', + catalogRevision: catalog.revision, + totalTurnCount: catalog.totalTurnCount, + ...window, + targetTurnId: `turn-${targetOrdinal}`, + turns: Array.from( + { length: window.endOrdinalExclusive - window.startOrdinal }, + (_, index) => createPersistedTurn(index + window.startOrdinal), + ), + }; + }); + + await flowChatStore.loadSessionTurnWindow('history-1', 5); + await flowChatStore.loadSessionTurnWindow('history-1', 30); + await flowChatStore.loadSessionTurnWindow('history-1', 55); + await expect(flowChatStore.loadSessionTurnWindow('history-1', 5)).resolves.toMatchObject({ + cacheHit: true, }); - expect(trimmed?.turns).toHaveLength(48); + await flowChatStore.loadSessionTurnWindow('history-1', 80); + + const loadedRanges = flowChatStore.getSessionHistoryViewState('history-1')?.loadedRanges ?? []; + const loadedTurnCount = loadedRanges.reduce((count, range) => count + range.turns.length, 0); + const containsOrdinal = (ordinal: number) => loadedRanges.some(range => + range.startOrdinal <= ordinal && range.endOrdinalExclusive > ordinal + ); + expect(loadedTurnCount).toBe(48); + expect(containsOrdinal(5)).toBe(true); + expect(containsOrdinal(30)).toBe(false); + expect(containsOrdinal(80)).toBe(true); + expect(apiMocks.loadSessionTurnWindow).toHaveBeenCalledTimes(4); + }); + + it('keeps the restored live tail outside the non-tail LRU budget', async () => { + const catalog = createTurnCatalog(100); + apiMocks.restoreSessionView.mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 100, + createdAt: 1, + }, + turns: [97, 98, 99].map(index => createPersistedTurn(index)), + turnCatalog: catalog, + contextRestoreState: 'pending', + isPartial: true, + loadedTurnCount: 3, + totalTurnCount: 100, + }); + flowChatStore.setState(() => ({ + sessions: new Map([[ + 'history-1', + createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + }), + ]]), + activeSessionId: 'history-1', + })); + await flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun'); + + const windows = new Map([ + [5, { startOrdinal: 0, endOrdinalExclusive: 17 }], + [25, { startOrdinal: 20, endOrdinalExclusive: 37 }], + [45, { startOrdinal: 40, endOrdinalExclusive: 57 }], + [65, { startOrdinal: 60, endOrdinalExclusive: 77 }], + ]); + apiMocks.loadSessionTurnWindow.mockImplementation(async request => { + const targetOrdinal = request.targetStorageTurnIndex; + const window = windows.get(targetOrdinal); + if (!window) { + throw new Error(`Unexpected target ordinal ${targetOrdinal}`); + } + return { + status: 'ready', + catalogRevision: catalog.revision, + totalTurnCount: catalog.totalTurnCount, + ...window, + targetTurnId: `turn-${targetOrdinal}`, + turns: Array.from( + { length: window.endOrdinalExclusive - window.startOrdinal }, + (_, index) => createPersistedTurn(index + window.startOrdinal), + ), + }; + }); + + await flowChatStore.loadSessionTurnWindow('history-1', 5); + await flowChatStore.loadSessionTurnWindow('history-1', 25); + await flowChatStore.loadSessionTurnWindow('history-1', 45); + await flowChatStore.loadSessionTurnWindow('history-1', 65); + + const loadedRanges = flowChatStore.getSessionHistoryViewState('history-1')?.loadedRanges ?? []; + const nonTailTurnCount = loadedRanges.reduce((count, range) => { + const nonTailEndOrdinalExclusive = Math.min(range.endOrdinalExclusive, 97); + return count + Math.max(0, nonTailEndOrdinalExclusive - range.startOrdinal); + }, 0); + expect(nonTailTurnCount).toBe(48); + expect(loadedRanges.some(range => + range.startOrdinal <= 97 && range.endOrdinalExclusive >= 100 + )).toBe(true); }); it('updates a stale catalog and retries the original Turn identity once', async () => { diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 1383e4f2bb..5fbc895fe4 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -156,6 +156,8 @@ const SESSION_TURN_WINDOW_DEFAULT_AFTER = 12; const SESSION_HISTORY_PRESENTATION_SOFT_TURN_BUDGET = 48; const SESSION_HISTORY_PRESENTATION_HARD_TURN_BUDGET = 64; const SESSION_HISTORY_PRESENTATION_PREFETCH_TURN_COUNT = 16; +const SESSION_HISTORY_LOADED_RANGE_CACHE_SOFT_TURN_BUDGET = 48; +const SESSION_HISTORY_LOADED_RANGE_CACHE_HARD_TURN_BUDGET = 64; type RemoveSessionOptions = { nextActiveSessionId?: string | null; @@ -197,6 +199,16 @@ export type SessionTurnWindowLoadResult = { fallbackRequested?: boolean; }; +interface OrdinalInterval { + startOrdinal: number; + endOrdinalExclusive: number; +} + +interface SessionTurnWindowProtection extends OrdinalInterval { + sessionId: string; + retainCount: number; +} + function dispatchTerminalTurnStatus( state: NonNullable, ): 'completed' | 'cancelled' | 'error' | null { @@ -920,6 +932,96 @@ function sliceLoadedTurnRange( return turns.length === endOrdinalExclusive - startOrdinal ? turns : null; } +function mergeOrdinalIntervals(intervals: readonly OrdinalInterval[]): OrdinalInterval[] { + const sorted = intervals + .filter(interval => interval.endOrdinalExclusive > interval.startOrdinal) + .map(interval => ({ ...interval })) + .sort((left, right) => + left.startOrdinal - right.startOrdinal + || left.endOrdinalExclusive - right.endOrdinalExclusive + ); + const merged: OrdinalInterval[] = []; + + for (const interval of sorted) { + const previous = merged[merged.length - 1]; + if (!previous || interval.startOrdinal > previous.endOrdinalExclusive) { + merged.push(interval); + continue; + } + previous.endOrdinalExclusive = Math.max( + previous.endOrdinalExclusive, + interval.endOrdinalExclusive, + ); + } + + return merged; +} + +function ordinalIsInIntervals( + ordinal: number, + intervals: readonly OrdinalInterval[], +): boolean { + return intervals.some(interval => + interval.startOrdinal <= ordinal && interval.endOrdinalExclusive > ordinal + ); +} + +function ordinalDistanceFromIntervals( + ordinal: number, + intervals: readonly OrdinalInterval[], +): number { + if (intervals.length === 0) { + return Number.MAX_SAFE_INTEGER; + } + + let distance = Number.MAX_SAFE_INTEGER; + for (const interval of intervals) { + if (ordinal < interval.startOrdinal) { + distance = Math.min(distance, interval.startOrdinal - ordinal); + } else if (ordinal >= interval.endOrdinalExclusive) { + distance = Math.min(distance, ordinal - interval.endOrdinalExclusive + 1); + } else { + return 0; + } + } + return distance; +} + +function subtractOrdinalIntervals( + interval: OrdinalInterval, + exclusions: readonly OrdinalInterval[], +): OrdinalInterval[] { + const remaining: OrdinalInterval[] = []; + let cursor = interval.startOrdinal; + + for (const exclusion of exclusions) { + if (exclusion.endOrdinalExclusive <= cursor) { + continue; + } + if (exclusion.startOrdinal >= interval.endOrdinalExclusive) { + break; + } + if (exclusion.startOrdinal > cursor) { + remaining.push({ + startOrdinal: cursor, + endOrdinalExclusive: Math.min(exclusion.startOrdinal, interval.endOrdinalExclusive), + }); + } + cursor = Math.max(cursor, exclusion.endOrdinalExclusive); + if (cursor >= interval.endOrdinalExclusive) { + break; + } + } + + if (cursor < interval.endOrdinalExclusive) { + remaining.push({ + startOrdinal: cursor, + endOrdinalExclusive: interval.endOrdinalExclusive, + }); + } + return remaining; +} + function selectTargetHistoryPresentationRange( range: LoadedTurnRange, targetOrdinal: number, @@ -1187,7 +1289,12 @@ export class FlowChatStore { private deferredFullHistoryProjections = new Map(); private fullHistoryProjectionApplyRequests = new Set(); private sessionHistoryViews = new Map(); + /** Per-ordinal recency survives adjacent range merges and enables stable range slicing. */ + private sessionHistoryTurnAccessTimes = new Map>(); + private sessionHistoryAccessClock = 0; private sessionTurnWindowRequests = new Map>(); + /** Requested intervals remain protected until every deduplicated caller processes the response. */ + private sessionTurnWindowProtections = new Map(); private unsupportedRestoreCommands = new Set(); private pendingRemoveSessionOptions = new Map(); private onPersistUnreadCompletion?: (sessionId: string, value: 'completed' | 'error' | 'interrupted' | undefined) => void; @@ -1291,9 +1398,15 @@ export class FlowChatStore { targetTurnId: targetTurn?.id ?? null, mode: 'history-window', }; - loadedRange.lastAccessedAt = Date.now(); + this.touchSessionHistoryTurnRange( + sessionId, + loadedRange, + selected.startOrdinal, + selected.endOrdinalExclusive, + ); view.activeRange = range; view.pendingTargetOrdinal = null; + this.pruneSessionLoadedTurnRanges(sessionId, view); return { range: { ...range }, turns: [...turns] }; } @@ -1338,8 +1451,14 @@ export class FlowChatStore { targetTurnId, mode: 'history-window', }; - loadedRange.lastAccessedAt = Date.now(); + this.touchSessionHistoryTurnRange( + sessionId, + loadedRange, + selected.startOrdinal, + selected.endOrdinalExclusive, + ); view.activeRange = range; + this.pruneSessionLoadedTurnRanges(sessionId, view); return { range: { ...range }, turns: [...turns] }; } @@ -1394,8 +1513,14 @@ export class FlowChatStore { targetTurnId: null, mode: 'history-window', }; - loadedRange.lastAccessedAt = Date.now(); + this.touchSessionHistoryTurnRange( + sessionId, + loadedRange, + startOrdinal, + endOrdinalExclusive, + ); view.activeRange = range; + this.pruneSessionLoadedTurnRanges(sessionId, view); return { range: { ...range }, turns: [...turns] }; } @@ -1407,6 +1532,7 @@ export class FlowChatStore { view.navigationGeneration += 1; view.pendingTargetOrdinal = null; view.activeRange = null; + this.pruneSessionLoadedTurnRanges(sessionId, view); } private ensureSessionHistoryView( @@ -1432,17 +1558,286 @@ export class FlowChatStore { return created; } + private nextSessionHistoryAccessTime(candidate: number = Date.now()): number { + this.sessionHistoryAccessClock = Math.max( + this.sessionHistoryAccessClock + 1, + candidate, + ); + return this.sessionHistoryAccessClock; + } + + private getSessionHistoryTurnAccessTimes(sessionId: string): Map { + const existing = this.sessionHistoryTurnAccessTimes.get(sessionId); + if (existing) { + return existing; + } + const created = new Map(); + this.sessionHistoryTurnAccessTimes.set(sessionId, created); + return created; + } + + private touchSessionHistoryTurnRange( + sessionId: string, + range: LoadedTurnRange, + startOrdinal: number, + endOrdinalExclusive: number, + accessedAt: number = this.nextSessionHistoryAccessTime(), + ): void { + const boundedStartOrdinal = Math.max(range.startOrdinal, startOrdinal); + const boundedEndOrdinalExclusive = Math.min( + range.endOrdinalExclusive, + endOrdinalExclusive, + ); + if (boundedEndOrdinalExclusive <= boundedStartOrdinal) { + return; + } + + const accessTimes = this.getSessionHistoryTurnAccessTimes(sessionId); + for ( + let ordinal = boundedStartOrdinal; + ordinal < boundedEndOrdinalExclusive; + ordinal += 1 + ) { + accessTimes.set(ordinal, accessedAt); + } + range.lastAccessedAt = Math.max(range.lastAccessedAt, accessedAt); + } + + private getSessionHistoryTailProtectedIntervals( + sessionId: string, + view: SessionHistoryViewState, + ): OrdinalInterval[] { + const session = this.state.sessions.get(sessionId); + if (!session || session.dialogTurns.length === 0) { + return []; + } + + const catalog = view.catalog?.sessionId === sessionId + ? view.catalog + : session.turnCatalog?.sessionId === sessionId + ? session.turnCatalog + : null; + const totalTurnCount = Math.max( + catalog?.totalTurnCount ?? 0, + session.totalTurnCount ?? 0, + session.dialogTurns.length, + ); + if (totalTurnCount <= 0) { + return []; + } + + return [{ + startOrdinal: Math.max(0, totalTurnCount - session.dialogTurns.length), + endOrdinalExclusive: totalTurnCount, + }]; + } + + private getSessionHistoryProtectedIntervals( + sessionId: string, + view: SessionHistoryViewState, + tailIntervals: readonly OrdinalInterval[], + ): OrdinalInterval[] { + const intervals: OrdinalInterval[] = [...tailIntervals]; + if (view.activeRange) { + intervals.push({ + startOrdinal: view.activeRange.startOrdinal, + endOrdinalExclusive: view.activeRange.endOrdinalExclusive, + }); + } + const pendingTargetOrdinal = view.pendingTargetOrdinal; + if (pendingTargetOrdinal !== null) { + const pendingRange = view.loadedRanges.find(range => + range.startOrdinal <= pendingTargetOrdinal + && range.endOrdinalExclusive > pendingTargetOrdinal + ); + if (pendingRange) { + intervals.push(selectTargetHistoryPresentationRange( + pendingRange, + pendingTargetOrdinal, + )); + } else { + intervals.push({ + startOrdinal: pendingTargetOrdinal, + endOrdinalExclusive: pendingTargetOrdinal + 1, + }); + } + } + for (const protection of this.sessionTurnWindowProtections.values()) { + if (protection.sessionId !== sessionId) { + continue; + } + intervals.push({ + startOrdinal: protection.startOrdinal, + endOrdinalExclusive: protection.endOrdinalExclusive, + }); + } + return mergeOrdinalIntervals(intervals); + } + + private pruneSessionLoadedTurnRanges( + sessionId: string, + view: SessionHistoryViewState = this.ensureSessionHistoryView(sessionId), + ): void { + // The canonical tail is retained outside the ordinary cache budget. When + // non-tail data crosses the hard limit, LRU entries are sliced back toward + // the soft limit without touching active or pending navigation intervals. + const tailIntervals = mergeOrdinalIntervals( + this.getSessionHistoryTailProtectedIntervals(sessionId, view), + ); + const nonTailSegments = view.loadedRanges.flatMap(range => + subtractOrdinalIntervals(range, tailIntervals).map(interval => ({ + ...interval, + range, + })) + ); + const nonTailTurnCount = nonTailSegments.reduce( + (count, segment) => count + segment.endOrdinalExclusive - segment.startOrdinal, + 0, + ); + if ( + nonTailTurnCount + <= SESSION_HISTORY_LOADED_RANGE_CACHE_HARD_TURN_BUDGET + ) { + return; + } + const nonTailOrdinals = nonTailSegments.flatMap(segment => + Array.from( + { length: segment.endOrdinalExclusive - segment.startOrdinal }, + (_, index) => ({ + ordinal: segment.startOrdinal + index, + range: segment.range, + }), + ) + ); + + const protectedIntervals = this.getSessionHistoryProtectedIntervals( + sessionId, + view, + tailIntervals, + ); + const protectedNonTailCount = nonTailOrdinals.reduce( + (count, entry) => count + Number( + ordinalIsInIntervals(entry.ordinal, protectedIntervals), + ), + 0, + ); + const retainedNonTailTarget = Math.max( + SESSION_HISTORY_LOADED_RANGE_CACHE_SOFT_TURN_BUDGET, + protectedNonTailCount, + ); + const evictionCount = Math.max( + 0, + nonTailOrdinals.length - retainedNonTailTarget, + ); + if (evictionCount === 0) { + return; + } + + const accessTimes = this.getSessionHistoryTurnAccessTimes(sessionId); + const candidates = nonTailOrdinals + .filter(entry => !ordinalIsInIntervals(entry.ordinal, protectedIntervals)) + .sort((left, right) => { + const leftAccessedAt = accessTimes.get(left.ordinal) ?? left.range.lastAccessedAt; + const rightAccessedAt = accessTimes.get(right.ordinal) ?? right.range.lastAccessedAt; + return leftAccessedAt - rightAccessedAt + || ordinalDistanceFromIntervals(right.ordinal, protectedIntervals) + - ordinalDistanceFromIntervals(left.ordinal, protectedIntervals) + || left.ordinal - right.ordinal; + }); + const evictedOrdinals = new Set( + candidates.slice(0, evictionCount).map(entry => entry.ordinal), + ); + if (evictedOrdinals.size === 0) { + return; + } + + const retainedRanges: LoadedTurnRange[] = []; + const retainedOrdinals = new Set(); + for (const range of view.loadedRanges) { + let segmentStartOrdinal: number | null = null; + for ( + let ordinal = range.startOrdinal; + ordinal <= range.endOrdinalExclusive; + ordinal += 1 + ) { + const retained = ordinal < range.endOrdinalExclusive + && !evictedOrdinals.has(ordinal); + if (retained && segmentStartOrdinal === null) { + segmentStartOrdinal = ordinal; + continue; + } + if (retained || segmentStartOrdinal === null) { + continue; + } + + const segmentTurns = range.turns.slice( + segmentStartOrdinal - range.startOrdinal, + ordinal - range.startOrdinal, + ); + if (segmentTurns.length > 0) { + let lastAccessedAt = range.lastAccessedAt; + for ( + let retainedOrdinal = segmentStartOrdinal; + retainedOrdinal < ordinal; + retainedOrdinal += 1 + ) { + retainedOrdinals.add(retainedOrdinal); + lastAccessedAt = Math.max( + lastAccessedAt, + accessTimes.get(retainedOrdinal) ?? range.lastAccessedAt, + ); + } + retainedRanges.push({ + startOrdinal: segmentStartOrdinal, + endOrdinalExclusive: ordinal, + turns: segmentTurns, + lastAccessedAt, + source: range.source, + }); + } + segmentStartOrdinal = null; + } + } + + for (const ordinal of accessTimes.keys()) { + if (!retainedOrdinals.has(ordinal)) { + accessTimes.delete(ordinal); + } + } + if (accessTimes.size === 0) { + this.sessionHistoryTurnAccessTimes.delete(sessionId); + } + view.loadedRanges = retainedRanges; + } + private cacheSessionLoadedTurnRange( sessionId: string, range: LoadedTurnRange, catalog?: SessionTurnCatalog | null, + preferredOrdinal: number = range.startOrdinal, ): LoadedTurnRange { const view = this.ensureSessionHistoryView(sessionId, catalog); - view.loadedRanges = mergeLoadedTurnRanges(view.loadedRanges, range); + const accessedAt = this.nextSessionHistoryAccessTime(range.lastAccessedAt); + const incomingRange = { ...range, lastAccessedAt: accessedAt }; + view.loadedRanges = mergeLoadedTurnRanges(view.loadedRanges, incomingRange); + const mergedRange = view.loadedRanges.find(candidate => + candidate.startOrdinal <= incomingRange.startOrdinal + && candidate.endOrdinalExclusive >= incomingRange.endOrdinalExclusive + ); + if (mergedRange) { + this.touchSessionHistoryTurnRange( + sessionId, + mergedRange, + incomingRange.startOrdinal, + incomingRange.endOrdinalExclusive, + accessedAt, + ); + } + this.pruneSessionLoadedTurnRanges(sessionId, view); return view.loadedRanges.find(candidate => - candidate.startOrdinal <= range.startOrdinal - && candidate.endOrdinalExclusive >= range.endOrdinalExclusive - ) ?? range; + candidate.startOrdinal <= preferredOrdinal + && candidate.endOrdinalExclusive > preferredOrdinal + ) ?? incomingRange; } private seedSessionHistoryLoadedRanges( @@ -1514,13 +1909,13 @@ export class FlowChatStore { session.dialogTurns.length, ); const startOrdinal = Math.max(0, totalTurnCount - session.dialogTurns.length); - view.loadedRanges = mergeLoadedTurnRanges(view.loadedRanges, { + this.cacheSessionLoadedTurnRange(sessionId, { startOrdinal, endOrdinalExclusive: startOrdinal + session.dialogTurns.length, turns: [...session.dialogTurns], lastAccessedAt: now, source, - }); + }, catalog ?? null); } private updateAuthoritativeSessionTurnCatalog( @@ -1574,12 +1969,13 @@ export class FlowChatStore { } private invalidateSessionTurnNavigationIntents(): void { - for (const view of this.sessionHistoryViews.values()) { + for (const [sessionId, view] of this.sessionHistoryViews) { if (view.pendingTargetOrdinal === null) { continue; } view.navigationGeneration += 1; view.pendingTargetOrdinal = null; + this.pruneSessionLoadedTurnRanges(sessionId, view); } } @@ -1781,6 +2177,7 @@ export class FlowChatStore { this.deferredFullHistoryProjections.delete(sessionId); this.fullHistoryProjectionApplyRequests.delete(sessionId); this.sessionHistoryViews.delete(sessionId); + this.sessionHistoryTurnAccessTimes.delete(sessionId); } for (const requestKey of this.sessionTurnWindowRequests.keys()) { @@ -1793,6 +2190,11 @@ export class FlowChatStore { // Ignore malformed internal keys; they expire when their request settles. } } + for (const [requestKey, protection] of this.sessionTurnWindowProtections) { + if (removedSessionIds.has(protection.sessionId)) { + this.sessionTurnWindowProtections.delete(requestKey); + } + } } private scheduleActiveLegacyPartialSessionHistoryCompletion( @@ -1924,6 +2326,32 @@ export class FlowChatStore { return true; } + private retainSessionTurnWindowProtection( + key: string, + protection: Omit, + ): () => void { + const existing = this.sessionTurnWindowProtections.get(key); + if (existing) { + existing.retainCount += 1; + } else { + this.sessionTurnWindowProtections.set(key, { + ...protection, + retainCount: 1, + }); + } + + return () => { + const current = this.sessionTurnWindowProtections.get(key); + if (!current) { + return; + } + current.retainCount -= 1; + if (current.retainCount <= 0) { + this.sessionTurnWindowProtections.delete(key); + } + }; + } + private async invokeSessionTurnWindowRequest( key: string, request: Parameters[0], @@ -1988,7 +2416,16 @@ export class FlowChatStore { if (cachedRange) { const cachedTarget = cachedRange.turns[normalizedTargetOrdinal - cachedRange.startOrdinal]; if (!entry.turnId || cachedTarget?.id === entry.turnId) { - cachedRange.lastAccessedAt = Date.now(); + const selected = selectTargetHistoryPresentationRange( + cachedRange, + normalizedTargetOrdinal, + ); + this.touchSessionHistoryTurnRange( + sessionId, + cachedRange, + selected.startOrdinal, + selected.endOrdinalExclusive, + ); return { status: 'ready', sessionId, @@ -2108,6 +2545,17 @@ export class FlowChatStore { request.before, request.after, ]); + const releaseWindowProtection = this.retainSessionTurnWindowProtection( + requestKey, + { + sessionId: request.sessionId, + startOrdinal: Math.max(0, request.targetOrdinal - request.before), + endOrdinalExclusive: Math.min( + request.catalog.totalTurnCount, + request.targetOrdinal + request.after + 1, + ), + }, + ); let response: LoadSessionTurnWindowResponse; try { response = await this.invokeSessionTurnWindowRequest(requestKey, { @@ -2123,6 +2571,7 @@ export class FlowChatStore { remoteSshHost: request.remoteSshHost, }); } catch (error) { + releaseWindowProtection(); if (!isUnsupportedTauriCommandError(error, 'load_session_turn_window')) { throw error; } @@ -2152,85 +2601,103 @@ export class FlowChatStore { }; } - if (response.status === 'ready') { + try { + if (response.status === 'ready') { + if ( + response.endOrdinalExclusive <= response.startOrdinal + || response.turns.length !== response.endOrdinalExclusive - response.startOrdinal + ) { + throw new Error('Session Turn window response is not contiguous'); + } + const liveTurnId = [...(this.state.sessions.get(request.sessionId)?.dialogTurns ?? [])] + .reverse() + .find(turn => !['completed', 'cancelled', 'error'].includes(turn.status))?.id; + const turns = this.convertToDialogTurns(response.turns, { + activeTurnId: liveTurnId, + }); + const incomingRange: LoadedTurnRange = { + startOrdinal: response.startOrdinal, + endOrdinalExclusive: response.endOrdinalExclusive, + turns, + lastAccessedAt: Date.now(), + source: request.source, + }; + const currentCatalog = this.sessionHistoryViews.get(request.sessionId)?.catalog; + const range = this.state.sessions.has(request.sessionId) + && (!currentCatalog || currentCatalog.revision === response.catalogRevision) + ? this.cacheSessionLoadedTurnRange( + request.sessionId, + incomingRange, + undefined, + request.targetOrdinal, + ) + : incomingRange; + return { + status: 'ready', + sessionId: request.sessionId, + targetOrdinal: request.targetOrdinal, + targetTurnId: response.targetTurnId, + navigationGeneration: request.generation, + isCurrent: this.isSessionTurnWindowRequestCurrent( + request.sessionId, + request.generation, + request.targetOrdinal, + request.source, + ), + cacheHit: false, + range, + catalog: request.catalog, + }; + } + + const isCurrent = this.isSessionTurnWindowRequestCurrent( + request.sessionId, + request.generation, + request.targetOrdinal, + request.source, + ); + const currentCatalog = this.sessionHistoryViews.get(request.sessionId)?.catalog; if ( - response.endOrdinalExclusive <= response.startOrdinal - || response.turns.length !== response.endOrdinalExclusive - response.startOrdinal + isCurrent + || !currentCatalog + || currentCatalog.revision === request.catalog.revision ) { - throw new Error('Session Turn window response is not contiguous'); + this.updateAuthoritativeSessionTurnCatalog(request.sessionId, response.catalog); } - const liveTurnId = [...(this.state.sessions.get(request.sessionId)?.dialogTurns ?? [])] - .reverse() - .find(turn => !['completed', 'cancelled', 'error'].includes(turn.status))?.id; - const turns = this.convertToDialogTurns(response.turns, { - activeTurnId: liveTurnId, - }); - const incomingRange: LoadedTurnRange = { - startOrdinal: response.startOrdinal, - endOrdinalExclusive: response.endOrdinalExclusive, - turns, - lastAccessedAt: Date.now(), - source: request.source, - }; - const currentCatalog = this.sessionHistoryViews.get(request.sessionId)?.catalog; - const range = this.state.sessions.has(request.sessionId) - && (!currentCatalog || currentCatalog.revision === response.catalogRevision) - ? this.cacheSessionLoadedTurnRange(request.sessionId, incomingRange) - : incomingRange; - return { - status: 'ready', - sessionId: request.sessionId, - targetOrdinal: request.targetOrdinal, - targetTurnId: response.targetTurnId, - navigationGeneration: request.generation, - isCurrent: this.isSessionTurnWindowRequestCurrent( - request.sessionId, - request.generation, - request.targetOrdinal, - request.source, - ), - cacheHit: false, - range, - catalog: request.catalog, - }; - } - - const isCurrent = this.isSessionTurnWindowRequestCurrent( - request.sessionId, - request.generation, - request.targetOrdinal, - request.source, - ); - const currentCatalog = this.sessionHistoryViews.get(request.sessionId)?.catalog; - if ( - isCurrent - || !currentCatalog - || currentCatalog.revision === request.catalog.revision - ) { - this.updateAuthoritativeSessionTurnCatalog(request.sessionId, response.catalog); - } - if (response.status === 'stale' && request.staleRetryCount === 0 && isCurrent) { - const relocatedEntry = request.originalTargetTurnId - ? response.catalog.entries.find(entry => entry.turnId === request.originalTargetTurnId) - : response.catalog.entries.find( + if (response.status === 'stale' && request.staleRetryCount === 0 && isCurrent) { + const relocatedEntry = request.originalTargetTurnId + ? response.catalog.entries.find(entry => entry.turnId === request.originalTargetTurnId) + : response.catalog.entries.find( entry => entry.storageTurnIndex === request.targetStorageTurnIndex, ); - if (relocatedEntry) { - const view = this.sessionHistoryViews.get(request.sessionId); - if (request.source === 'target' && view?.navigationGeneration === request.generation) { - view.pendingTargetOrdinal = relocatedEntry.ordinal; + if (relocatedEntry) { + const view = this.sessionHistoryViews.get(request.sessionId); + if (request.source === 'target' && view?.navigationGeneration === request.generation) { + view.pendingTargetOrdinal = relocatedEntry.ordinal; + } + return this.loadSessionTurnWindowAttempt({ + ...request, + targetOrdinal: relocatedEntry.ordinal, + targetStorageTurnIndex: relocatedEntry.storageTurnIndex, + originalTargetTurnId: relocatedEntry.turnId ?? request.originalTargetTurnId, + catalog: response.catalog, + staleRetryCount: 1, + }); } - return this.loadSessionTurnWindowAttempt({ - ...request, - targetOrdinal: relocatedEntry.ordinal, - targetStorageTurnIndex: relocatedEntry.storageTurnIndex, - originalTargetTurnId: relocatedEntry.turnId ?? request.originalTargetTurnId, + return { + status: 'not-found', + sessionId: request.sessionId, + targetOrdinal: request.targetOrdinal, + targetTurnId: request.originalTargetTurnId, + navigationGeneration: request.generation, + isCurrent, + cacheHit: false, catalog: response.catalog, - staleRetryCount: 1, - }); + }; } + return { - status: 'not-found', + status: response.status, sessionId: request.sessionId, targetOrdinal: request.targetOrdinal, targetTurnId: request.originalTargetTurnId, @@ -2239,18 +2706,9 @@ export class FlowChatStore { cacheHit: false, catalog: response.catalog, }; + } finally { + releaseWindowProtection(); } - - return { - status: response.status, - sessionId: request.sessionId, - targetOrdinal: request.targetOrdinal, - targetTurnId: request.originalTargetTurnId, - navigationGeneration: request.generation, - isCurrent, - cacheHit: false, - catalog: response.catalog, - }; } public releaseSessionHistoryCompletionAfterInitialPaint( From 5005558d49e5bd227d29fc322f0d4e95a90c8e2f Mon Sep 17 00:00:00 2001 From: wsp Date: Sat, 1 Aug 2026 14:21:18 +0800 Subject: [PATCH 07/23] perf(flow-chat): repair incomplete turn catalogs incrementally - Update aligned incomplete catalogs without reading the full Turn history. - Preserve untouched placeholders while repairing saved or newly appended Turn entries. - Fall back to a complete catalog rebuild when physical Turn indices are misaligned. - Add regression coverage for incremental repair, tail append, and rebuild fallback. --- .../core/src/agentic/persistence/manager.rs | 229 +++++++++++++++++- 1 file changed, 217 insertions(+), 12 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/persistence/manager.rs b/src/crates/assembly/core/src/agentic/persistence/manager.rs index 211af3cda3..989bcabef7 100644 --- a/src/crates/assembly/core/src/agentic/persistence/manager.rs +++ b/src/crates/assembly/core/src/agentic/persistence/manager.rs @@ -272,6 +272,31 @@ fn is_well_formed_turn_catalog(catalog: &SessionTurnCatalog) -> bool { && 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, @@ -2549,8 +2574,9 @@ impl PersistenceManager { /// Build the lightweight navigation catalog for a Session view. /// /// The read path never mutates Session storage. Missing or stale legacy - /// entries are returned as index-only placeholders and are completed by a - /// full restore or the next protected Turn save. + /// entries are returned as index-only placeholders. Aligned sidecars are + /// repaired incrementally as those Turns are saved; a full restore can + /// still complete the entire projection. pub async fn load_session_turn_catalog( &self, workspace_path: &Path, @@ -2804,15 +2830,15 @@ impl PersistenceManager { .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| { - catalog.complete - && catalog.entries.len() <= physical_indices.len() - && physical_indices.len().saturating_sub(catalog.entries.len()) <= 1 - && catalog - .entries - .iter() - .zip(physical_indices.iter()) - .all(|(entry, index)| entry.storage_turn_index == *index) + can_incrementally_update_turn_catalog_after_save( + catalog, + &physical_indices, + turn.turn_index, + ) }); let next_catalog = if can_update_incrementally { @@ -3980,8 +4006,9 @@ impl PersistenceManager { #[cfg(test)] mod tests { use super::{ - context_snapshot_payload_stats, current_unix_secs, truncate_turn_catalog_preview, - PendingSessionDirectory, PersistenceManager, StoredDialogTurnFile, + 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}; @@ -5007,6 +5034,184 @@ mod tests { assert_eq!(complete.entries[0].turn_id.as_deref(), Some("turn-0")); } + #[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(); From a2728ff808c2a87f962c07adbe1e76099f327996 Mon Sep 17 00:00:00 2001 From: wsp Date: Sat, 1 Aug 2026 15:16:44 +0800 Subject: [PATCH 08/23] fix(flow-chat): restore tail when submitting from history - Exit the history-window presentation before handling a new-message Turn pin. - Materialize the submitted Turn in the canonical tail before applying sticky pinning. - Preserve history-window reading during passive live-tail and streaming updates. - Add regression coverage for presentation restoration and pin ordering. --- .../modern/FLOWCHAT_SCROLL_STABILITY.md | 4 + ...rnFlowChatContainer.history-state.test.tsx | 48 +++++++++++- .../modern/ModernFlowChatContainer.tsx | 27 +++++-- .../modern/useFlowChatNavigation.test.ts | 76 ++++++++++++++++++- .../modern/useFlowChatNavigation.ts | 5 +- 5 files changed, 149 insertions(+), 11 deletions(-) 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 ad0efefc78..b05703b442 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 @@ -448,6 +448,10 @@ these ownership rules intact: 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 remain hidden while the user reads a history + window. An explicit `send-message` Turn-pin request first restores the tail + presentation, then lets the existing sticky-latest pin materialize the newly + submitted Turn. - 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. 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 1fcd98c544..c28b2562b4 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 @@ -62,6 +62,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()), @@ -184,7 +187,9 @@ vi.mock('./useFlowChatFileActions', () => ({ })); vi.mock('./useFlowChatNavigation', () => ({ - useFlowChatNavigation: vi.fn(), + useFlowChatNavigation: (options: Record) => { + navigationOptionsMock.latest = options; + }, })); vi.mock('./useFlowChatCopyDialog', () => ({ @@ -338,6 +343,7 @@ describe('ModernFlowChatContainer historical empty state', () => { searchStateMock.clearSearch.mockReset(); headerPropsMock.latest = null; virtualListPropsMock.latest = null; + navigationOptionsMock.latest = null; clearHistorySessionOpenTransition(); }); @@ -1584,13 +1590,49 @@ describe('ModernFlowChatContainer historical empty state', () => { 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).toHaveBeenCalledWith('session-1'); + expect(restoreTailSpy).toHaveBeenCalledTimes(2); + expect(restoreTailSpy).toHaveBeenLastCalledWith('session-1'); expect(virtualListPropsMock.latest).toMatchObject({ presentationMode: 'tail' }); - expect(virtualListMock.scrollToLatestEndPosition).toHaveBeenCalledOnce(); + expect(virtualListMock.scrollToLatestEndPosition.mock.calls.length).toBe(latestEndCallCountBeforeSend + 1); restoreTailSpy.mockRestore(); loadSpy.mockRestore(); 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 92c84cba44..ab9df6c872 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -48,6 +48,7 @@ import type { SessionHistoryPresentation, } from '../../types/flow-chat'; import type { SessionHistoryWindowDirection } from '../../store/FlowChatStore'; +import type { FlowChatPinTurnToTopRequest } from '../../events/flowchatNavigation'; import { useBackgroundCommandActivityStore, visibleBackgroundCommandActivitiesForSession, @@ -401,11 +402,29 @@ export const ModernFlowChatContainer: React.FC = ( useFlowChatSync(); useFlowChatCopyDialog(); + const clearHistoryPresentationForSession = useCallback((sessionId: string) => { + flowChatStore.restoreSessionTailPresentation(sessionId); + historyPresentationRef.current = null; + setHistoryPresentation(null); + setHistoryBoundaryState(IDLE_HISTORY_BOUNDARY_STATE); + setQueuedTurnNavigation(null); + }, []); + + const handleBeforeTurnPinRequest = useCallback((request: FlowChatPinTurnToTopRequest) => { + if ( + request.source === 'send-message' + && historyPresentationRef.current?.sessionId === request.sessionId + ) { + clearHistoryPresentationForSession(request.sessionId); + } + }, [clearHistoryPresentationForSession]); + useFlowChatNavigation({ activeSessionId: activeSession?.sessionId, virtualItems, virtualListRef, onExpandExploreGroup: handleExpandGroup, + onBeforeTurnPinRequest: handleBeforeTurnPinRequest, }); useEffect(() => { @@ -1315,11 +1334,7 @@ export const ModernFlowChatContainer: React.FC = ( return false; } - flowChatStore.restoreSessionTailPresentation(sessionId); - historyPresentationRef.current = null; - setHistoryPresentation(null); - setHistoryBoundaryState(IDLE_HISTORY_BOUNDARY_STATE); - setQueuedTurnNavigation(null); + clearHistoryPresentationForSession(sessionId); if (options?.followLatest) { requestAnimationFrame(() => { @@ -1329,7 +1344,7 @@ export const ModernFlowChatContainer: React.FC = ( }); } return true; - }, [activeSession?.sessionId]); + }, [activeSession?.sessionId, clearHistoryPresentationForSession]); const handleSearchChange = useCallback((query: string) => { setSearchQuery(query); diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatNavigation.test.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatNavigation.test.ts index 9d2100d728..950d6026d5 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatNavigation.test.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatNavigation.test.ts @@ -1,7 +1,20 @@ -import { describe, expect, it } from 'vitest'; +// @vitest-environment jsdom + +import React, { act, useRef } from 'react'; +import { createRoot } from 'react-dom/client'; +import { describe, expect, it, vi } from 'vitest'; +import { globalEventBus } from '@/infrastructure/event-bus'; import type { FlowToolItem, ModelRound, Session } from '../../types/flow-chat'; import type { VirtualItem } from '../../store/modernFlowChatStore'; +import { + FLOWCHAT_PIN_TURN_TO_TOP_EVENT, + type FlowChatPinTurnToTopRequest, +} from '../../events/flowchatNavigation'; import { resolveFlowChatFocusTarget } from './flowChatFocusTarget'; +import { useFlowChatNavigation } from './useFlowChatNavigation'; +import type { VirtualMessageListRef } from './VirtualMessageList'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; function makeReadTool(id: string): FlowToolItem { return { @@ -103,3 +116,64 @@ describe('useFlowChatNavigation focus resolution', () => { }); }); }); + +describe('useFlowChatNavigation Turn pin handling', () => { + it('runs the pre-pin callback before pinning a newly submitted Turn', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const onBeforeTurnPinRequest = vi.fn(); + const pinTurnToTop = vi.fn(() => true); + const virtualItems: VirtualItem[] = [{ + type: 'user-message', + turnId: 'turn-2', + data: { + id: 'user-2', + content: 'New prompt', + timestamp: 1000, + }, + }]; + + function Harness() { + const virtualListRef = useRef({ + pinTurnToTop, + } as unknown as VirtualMessageListRef); + useFlowChatNavigation({ + activeSessionId: 'session-1', + virtualItems, + virtualListRef, + onBeforeTurnPinRequest, + }); + return null; + } + + await act(async () => { + root.render(React.createElement(Harness)); + }); + const request: FlowChatPinTurnToTopRequest = { + sessionId: 'session-1', + turnId: 'turn-2', + behavior: 'auto', + source: 'send-message', + pinMode: 'sticky-latest', + }; + await act(async () => { + globalEventBus.emit(FLOWCHAT_PIN_TURN_TO_TOP_EVENT, request, 'test'); + await Promise.resolve(); + }); + + expect(onBeforeTurnPinRequest).toHaveBeenCalledWith(request); + expect(pinTurnToTop).toHaveBeenCalledWith('turn-2', { + behavior: 'auto', + pinMode: 'sticky-latest', + }); + expect(onBeforeTurnPinRequest.mock.invocationCallOrder[0]).toBeLessThan( + pinTurnToTop.mock.invocationCallOrder[0], + ); + + await act(async () => { + root.unmount(); + }); + container.remove(); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatNavigation.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatNavigation.ts index 5f7e436141..48c1263d2a 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatNavigation.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatNavigation.ts @@ -27,6 +27,7 @@ interface UseFlowChatNavigationOptions { virtualItems: VirtualItem[]; virtualListRef: RefObject; onExpandExploreGroup?: (groupId: string) => void; + onBeforeTurnPinRequest?: (request: FlowChatPinTurnToTopRequest) => void; } async function waitForCondition(predicate: () => boolean, timeoutMs: number): Promise { @@ -73,6 +74,7 @@ export function useFlowChatNavigation({ virtualItems, virtualListRef, onExpandExploreGroup, + onBeforeTurnPinRequest, }: UseFlowChatNavigationOptions): void { const [pendingTurnPinRequest, setPendingTurnPinRequest] = useState(null); @@ -82,11 +84,12 @@ export function useFlowChatNavigation({ return; } + onBeforeTurnPinRequest?.(request); setPendingTurnPinRequest(request); }); return unsubscribe; - }, [activeSessionId]); + }, [activeSessionId, onBeforeTurnPinRequest]); useEffect(() => { if (!pendingTurnPinRequest) return; From f735d6056c56c5b397030a921fee54525455ee83 Mon Sep 17 00:00:00 2001 From: wsp Date: Sat, 1 Aug 2026 16:02:20 +0800 Subject: [PATCH 09/23] fix(flow-chat): make cross-feature turn navigation window-aware - Resolve focus targets using stable Turn IDs and absolute session ordinals - Route usage, BTW, and subagent parent navigation through catalog-backed history windows - Prevent absolute Turn indexes from being reused as partial-list indexes - Add regression coverage for partial tails and history-window materialization --- .../components/btw/BtwSessionPanel.tsx | 1 + .../modern/FLOWCHAT_SCROLL_STABILITY.md | 6 + ...rnFlowChatContainer.history-state.test.tsx | 16 ++- .../modern/ModernFlowChatContainer.tsx | 77 ++++++++-- .../components/modern/flowChatFocusTarget.ts | 17 ++- .../modern/useFlowChatNavigation.test.ts | 136 +++++++++++++++++- .../modern/useFlowChatNavigation.ts | 83 +++++++++-- .../usage/SessionUsageComponents.test.tsx | 36 ++--- .../components/usage/SessionUsagePanel.tsx | 47 +++--- .../flow_chat/events/flowchatNavigation.ts | 3 + .../services/BtwThreadService.test.ts | 23 +++ .../flow_chat/services/BtwThreadService.ts | 7 +- .../EventHandlerModule.test.ts | 65 +++++++++ .../flow-chat-manager/EventHandlerModule.ts | 9 +- .../utils/flowChatTurnOrdinal.test.ts | 66 +++++++++ .../flow_chat/utils/flowChatTurnOrdinal.ts | 73 ++++++++++ 16 files changed, 589 insertions(+), 76 deletions(-) create mode 100644 src/web-ui/src/flow_chat/utils/flowChatTurnOrdinal.test.ts create mode 100644 src/web-ui/src/flow_chat/utils/flowChatTurnOrdinal.ts 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 b05703b442..0cf607d46f 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 @@ -452,6 +452,12 @@ these ownership rules intact: window. 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. 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 c28b2562b4..1d0297cf39 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 @@ -1478,7 +1478,7 @@ describe('ModernFlowChatContainer historical empty state', () => { ); }); - it('materializes a loaded Turn window before reusing the shared pin transaction', async () => { + 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', @@ -1540,8 +1540,18 @@ describe('ModernFlowChatContainer historical empty state', () => { const target = container.querySelector('[data-turn-id="turn-5"]'); expect(target).not.toBeNull(); await act(async () => { - target?.click(); - await Promise.resolve(); + 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' }); 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 ab9df6c872..febbd4fd0d 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -48,7 +48,10 @@ import type { SessionHistoryPresentation, } from '../../types/flow-chat'; import type { SessionHistoryWindowDirection } from '../../store/FlowChatStore'; -import type { FlowChatPinTurnToTopRequest } from '../../events/flowchatNavigation'; +import type { + FlowChatFocusItemRequest, + FlowChatPinTurnToTopRequest, +} from '../../events/flowchatNavigation'; import { useBackgroundCommandActivityStore, visibleBackgroundCommandActivitiesForSession, @@ -419,14 +422,6 @@ export const ModernFlowChatContainer: React.FC = ( } }, [clearHistoryPresentationForSession]); - useFlowChatNavigation({ - activeSessionId: activeSession?.sessionId, - virtualItems, - virtualListRef, - onExpandExploreGroup: handleExpandGroup, - onBeforeTurnPinRequest: handleBeforeTurnPinRequest, - }); - useEffect(() => { historyPresentationRef.current = historyPresentation; }, [historyPresentation]); @@ -1400,7 +1395,7 @@ export const ModernFlowChatContainer: React.FC = ( ordinal: targetItem.ordinal, turnId: renderedTargetId, }); - return false; + return true; } let result; @@ -1473,6 +1468,68 @@ export const ModernFlowChatContainer: React.FC = ( turnSummaries, ]); + 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 handleJumpToTurn(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(); + 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 ? handleJumpToTurn(targetByOrdinal) : false; + }, [ + activeSession?.sessionId, + handleJumpToTurn, + restoreTailPresentation, + turnRailItems, + ]); + + useFlowChatNavigation({ + activeSessionId: activeSession?.sessionId, + virtualItems, + virtualListRef, + onExpandExploreGroup: handleExpandGroup, + onBeforeTurnPinRequest: handleBeforeTurnPinRequest, + onNavigateToFocusTurn: handleNavigateToFocusTurn, + }); + const handleRetryHistoryLoad = useCallback(() => { const sessionId = activeSession?.sessionId; if (!sessionId) return; diff --git a/src/web-ui/src/flow_chat/components/modern/flowChatFocusTarget.ts b/src/web-ui/src/flow_chat/components/modern/flowChatFocusTarget.ts index d9ecb74513..d2455687df 100644 --- a/src/web-ui/src/flow_chat/components/modern/flowChatFocusTarget.ts +++ b/src/web-ui/src/flow_chat/components/modern/flowChatFocusTarget.ts @@ -1,6 +1,11 @@ import type { FlowChatFocusItemRequest } from '../../events/flowchatNavigation'; import type { VirtualItem } from '../../store/modernFlowChatStore'; import type { Session } from '../../types/flow-chat'; +import { + absoluteSessionTurnIndexForId, + absoluteSessionTurnIndexForLocalIndex, + loadedSessionTurnIdForAbsoluteIndex, +} from '../../utils/flowChatTurnOrdinal'; export interface ResolvedFocusTarget { resolvedVirtualIndex?: number; @@ -19,11 +24,14 @@ export function resolveFlowChatFocusTarget( const { turnIndex, itemId, source } = request; let resolvedVirtualIndex: number | undefined = undefined; let resolvedTurnIndex = turnIndex; - let resolvedTurnId: string | undefined = undefined; + let resolvedTurnId = request.turnId?.trim() || undefined; let expandExploreGroupId: string | undefined = undefined; - if (targetSession && turnIndex && turnIndex >= 1 && turnIndex <= targetSession.dialogTurns.length) { - resolvedTurnId = targetSession.dialogTurns[turnIndex - 1]?.id; + if (targetSession && turnIndex && turnIndex >= 1 && !resolvedTurnId) { + resolvedTurnId = loadedSessionTurnIdForAbsoluteIndex(targetSession, turnIndex); + } + if (targetSession && resolvedTurnId && resolvedTurnIndex === undefined) { + resolvedTurnIndex = absoluteSessionTurnIndexForId(targetSession, resolvedTurnId); } if (itemId) { @@ -32,7 +40,7 @@ export function resolveFlowChatFocusTarget( const turn = targetSession.dialogTurns[i]; const found = turn.modelRounds?.some(round => round.items?.some(item => item.id === itemId)); if (found) { - resolvedTurnIndex = i + 1; + resolvedTurnIndex = absoluteSessionTurnIndexForLocalIndex(targetSession, i); resolvedTurnId = turn.id; break; } @@ -45,6 +53,7 @@ export function resolveFlowChatFocusTarget( const hit = item.data?.items?.some(flowItem => flowItem?.id === itemId); if (hit) { resolvedVirtualIndex = i; + resolvedTurnId = resolvedTurnId ?? item.turnId; break; } } else if (item.type === 'explore-group') { diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatNavigation.test.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatNavigation.test.ts index 950d6026d5..2827475dd9 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatNavigation.test.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatNavigation.test.ts @@ -5,9 +5,11 @@ import { createRoot } from 'react-dom/client'; import { describe, expect, it, vi } from 'vitest'; import { globalEventBus } from '@/infrastructure/event-bus'; import type { FlowToolItem, ModelRound, Session } from '../../types/flow-chat'; -import type { VirtualItem } from '../../store/modernFlowChatStore'; +import { useModernFlowChatStore, type VirtualItem } from '../../store/modernFlowChatStore'; import { + FLOWCHAT_FOCUS_ITEM_EVENT, FLOWCHAT_PIN_TURN_TO_TOP_EVENT, + type FlowChatFocusItemRequest, type FlowChatPinTurnToTopRequest, } from '../../events/flowchatNavigation'; import { resolveFlowChatFocusTarget } from './flowChatFocusTarget'; @@ -115,9 +117,141 @@ describe('useFlowChatNavigation focus resolution', () => { preferPinnedTurnNavigation: false, }); }); + + it('resolves absolute Turn indexes through the catalog instead of the partial tail array', () => { + const session: Session = { + ...makeSession(makeRound([])), + dialogTurns: [98, 99, 100].map(turnIndex => ({ + id: `turn-${turnIndex}`, + sessionId: 'session-1', + backendTurnIndex: turnIndex - 1, + userMessage: { + id: `user-${turnIndex}`, + content: `Prompt ${turnIndex}`, + timestamp: 1000, + }, + modelRounds: [], + status: 'completed' as const, + startTime: 1000, + })), + isPartial: true, + loadedTurnCount: 3, + totalTurnCount: 100, + turnCatalog: { + schemaVersion: 1, + sessionId: 'session-1', + revision: 'catalog-1', + totalTurnCount: 100, + complete: true, + entries: Array.from({ length: 100 }, (_, ordinal) => ({ + ordinal, + storageTurnIndex: ordinal, + turnId: `turn-${ordinal + 1}`, + preview: `Prompt ${ordinal + 1}`, + previewTruncated: false, + })), + }, + }; + + expect(resolveFlowChatFocusTarget({ + sessionId: session.sessionId, + turnIndex: 2, + source: 'usage-report', + }, [], session)).toMatchObject({ + resolvedTurnId: 'turn-2', + resolvedTurnIndex: 2, + }); + expect(resolveFlowChatFocusTarget({ + sessionId: session.sessionId, + turnIndex: 99, + source: 'usage-report', + }, [], session)).toMatchObject({ + resolvedTurnId: 'turn-99', + resolvedTurnIndex: 99, + }); + }); + + it('treats an explicit stable Turn id as authoritative', () => { + const session = makeSession(makeRound([])); + const target = resolveFlowChatFocusTarget({ + sessionId: session.sessionId, + turnId: 'persisted-parent-turn', + turnIndex: 1, + source: 'btw-back', + }, [], session); + + expect(target).toMatchObject({ + resolvedTurnId: 'persisted-parent-turn', + resolvedTurnIndex: 1, + preferPinnedTurnNavigation: true, + }); + }); }); describe('useFlowChatNavigation Turn pin handling', () => { + it('does not reuse an absolute Turn index as a local index after delegated navigation fails', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + const scrollToTurn = vi.fn(); + const onNavigateToFocusTurn = vi.fn(async () => false); + const partialSession: Session = { + ...makeSession(makeRound([])), + dialogTurns: [98, 99, 100].map(turnIndex => ({ + id: `turn-${turnIndex}`, + sessionId: 'session-1', + backendTurnIndex: turnIndex - 1, + userMessage: { + id: `user-${turnIndex}`, + content: `Prompt ${turnIndex}`, + timestamp: 1000, + }, + modelRounds: [], + status: 'completed' as const, + startTime: 1000, + })), + isPartial: true, + loadedTurnCount: 3, + totalTurnCount: 100, + }; + useModernFlowChatStore.getState().setActiveSession(partialSession); + + function Harness() { + const virtualListRef = useRef({ + scrollToTurn, + } as unknown as VirtualMessageListRef); + useFlowChatNavigation({ + activeSessionId: partialSession.sessionId, + virtualItems: useModernFlowChatStore.getState().virtualItems, + virtualListRef, + onNavigateToFocusTurn, + }); + return null; + } + + await act(async () => { + root.render(React.createElement(Harness)); + }); + const request: FlowChatFocusItemRequest = { + sessionId: partialSession.sessionId, + turnIndex: 2, + source: 'usage-report', + }; + await act(async () => { + globalEventBus.emit(FLOWCHAT_FOCUS_ITEM_EVENT, request, 'test'); + await new Promise(resolve => window.setTimeout(resolve, 0)); + }); + + expect(onNavigateToFocusTurn).toHaveBeenCalledWith(request); + expect(scrollToTurn).not.toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + }); + useModernFlowChatStore.getState().clear(); + container.remove(); + }); + it('runs the pre-pin callback before pinning a newly submitted Turn', async () => { const container = document.createElement('div'); document.body.appendChild(container); diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatNavigation.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatNavigation.ts index 48c1263d2a..69f457ba74 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatNavigation.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatNavigation.ts @@ -5,7 +5,7 @@ * virtualized list. */ -import { useEffect, useState, type RefObject } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react'; import { globalEventBus } from '@/infrastructure/event-bus'; import { createLogger } from '@/shared/utils/logger'; import { flowChatStore } from '../../store/FlowChatStore'; @@ -28,6 +28,7 @@ interface UseFlowChatNavigationOptions { virtualListRef: RefObject; onExpandExploreGroup?: (groupId: string) => void; onBeforeTurnPinRequest?: (request: FlowChatPinTurnToTopRequest) => void; + onNavigateToFocusTurn?: (request: FlowChatFocusItemRequest) => Promise | boolean; } async function waitForCondition(predicate: () => boolean, timeoutMs: number): Promise { @@ -50,6 +51,7 @@ async function waitForAnimationFrames(frameCount: number): Promise { function navigateToResolvedTarget( virtualListRef: RefObject, target: ResolvedFocusTarget, + options?: { allowLocalTurnIndex?: boolean }, ): void { const list = virtualListRef.current; if (!list) return; @@ -64,7 +66,7 @@ function navigateToResolvedTarget( return; } - if (target.resolvedTurnIndex) { + if (options?.allowLocalTurnIndex !== false && target.resolvedTurnIndex) { list.scrollToTurn(target.resolvedTurnIndex); } } @@ -75,8 +77,18 @@ export function useFlowChatNavigation({ virtualListRef, onExpandExploreGroup, onBeforeTurnPinRequest, + onNavigateToFocusTurn, }: UseFlowChatNavigationOptions): void { const [pendingTurnPinRequest, setPendingTurnPinRequest] = useState(null); + const virtualItemsRef = useRef(virtualItems); + const onExpandExploreGroupRef = useRef(onExpandExploreGroup); + const onNavigateToFocusTurnRef = useRef(onNavigateToFocusTurn); + + useLayoutEffect(() => { + virtualItemsRef.current = virtualItems; + onExpandExploreGroupRef.current = onExpandExploreGroup; + onNavigateToFocusTurnRef.current = onNavigateToFocusTurn; + }, [onExpandExploreGroup, onNavigateToFocusTurn, virtualItems]); useEffect(() => { const unsubscribe = globalEventBus.on(FLOWCHAT_PIN_TURN_TO_TOP_EVENT, (request) => { @@ -121,37 +133,84 @@ export function useFlowChatNavigation({ } } - await waitForCondition(() => { + const ready = await waitForCondition(() => { const modernActiveSessionId = useModernFlowChatStore.getState().activeSession?.sessionId; return modernActiveSessionId === sessionId && !!virtualListRef.current; }, 1500); + if (!ready) { + log.warn('FlowChat focus target did not become active before timeout', { sessionId }); + return; + } + + const delegatedTurnNavigationAttempted = Boolean( + (request.turnId || request.turnIndex) + && onNavigateToFocusTurnRef.current, + ); + let delegatedTurnNavigation = false; + if (delegatedTurnNavigationAttempted && onNavigateToFocusTurnRef.current) { + try { + delegatedTurnNavigation = await onNavigateToFocusTurnRef.current(request); + } catch (error) { + log.warn('Failed to navigate to the requested FlowChat Turn window', { + sessionId, + turnId: request.turnId, + turnIndex: request.turnIndex, + error, + }); + } + } const targetSession = flowChatStore.getState().sessions.get(sessionId); const resolvedTarget = resolveFlowChatFocusTarget( request, - useModernFlowChatStore.getState().virtualItems, + virtualItemsRef.current, targetSession, ); - if (resolvedTarget.expandExploreGroupId) { - onExpandExploreGroup?.(resolvedTarget.expandExploreGroupId); + if (!delegatedTurnNavigation) { + if (resolvedTarget.expandExploreGroupId) { + onExpandExploreGroupRef.current?.(resolvedTarget.expandExploreGroupId); + } + navigateToResolvedTarget(virtualListRef, resolvedTarget, { + // A focus request carries an absolute Session Turn index. Once the + // container-owned catalog/window transaction has handled that + // coordinate, it must never be reused as an index into the current + // partial presentation. Stable ids and already-rendered item indexes + // remain safe fallbacks. + allowLocalTurnIndex: !delegatedTurnNavigationAttempted, + }); } - navigateToResolvedTarget(virtualListRef, resolvedTarget); - if (!itemId) return; await waitForAnimationFrames(2); const maxAttempts = 120; let attempts = 0; + let expandedExploreGroupId: string | null = null; const tryFocus = () => { attempts += 1; - const focusItemId = resolvedTarget.focusItemId ?? itemId; + const currentTarget = resolveFlowChatFocusTarget( + request, + virtualItemsRef.current, + flowChatStore.getState().sessions.get(sessionId), + ); + if ( + currentTarget.expandExploreGroupId + && currentTarget.expandExploreGroupId !== expandedExploreGroupId + ) { + expandedExploreGroupId = currentTarget.expandExploreGroupId; + onExpandExploreGroupRef.current?.(currentTarget.expandExploreGroupId); + } + const focusItemId = currentTarget.focusItemId ?? itemId; const element = document.querySelector(`[data-flow-item-id="${CSS.escape(focusItemId)}"]`) as HTMLElement | null; if (!element) { - if (attempts % 12 === 0 && !resolvedTarget.preferPinnedTurnNavigation) { - navigateToResolvedTarget(virtualListRef, resolvedTarget); + if ( + attempts % 12 === 0 + && !delegatedTurnNavigationAttempted + && !currentTarget.preferPinnedTurnNavigation + ) { + navigateToResolvedTarget(virtualListRef, currentTarget); } if (attempts < maxAttempts) { requestAnimationFrame(tryFocus); @@ -168,5 +227,5 @@ export function useFlowChatNavigation({ }); return unsubscribe; - }, [activeSessionId, onExpandExploreGroup, virtualListRef]); + }, [activeSessionId, virtualListRef]); } diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx b/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx index e5aeca43e8..7ed23cd5ac 100644 --- a/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx @@ -12,9 +12,7 @@ import zhCnFlowChat from '@/locales/zh-CN/flow-chat.json'; import zhTwFlowChat from '@/locales/zh-TW/flow-chat.json'; import { FLOWCHAT_FOCUS_ITEM_EVENT, - FLOWCHAT_PIN_TURN_TO_TOP_EVENT, type FlowChatFocusItemRequest, - type FlowChatPinTurnToTopRequest, } from '../../events/flowchatNavigation'; import { SessionRuntimeStatusEntry } from './SessionRuntimeStatusEntry'; import { SessionUsagePanel } from './SessionUsagePanel'; @@ -1063,6 +1061,7 @@ describe('Session usage report UI components', () => { successCount: 1, errorCount: 0, durationMs: 2_000, + sampleTurnId: 'turn-3', sampleTurnIndex: 2, sampleItemId: 'tool-3', redacted: false, @@ -1076,6 +1075,7 @@ describe('Session usage report UI components', () => { { label: 'write_file', count: 1, + sampleTurnId: 'turn-4', sampleTurnIndex: 3, sampleItemId: 'tool-4', redacted: false, @@ -1127,17 +1127,20 @@ describe('Session usage report UI components', () => { expect(focusEvents).toEqual([ { sessionId: 'session-1', + turnId: 'turn-2', turnIndex: 2, source: 'usage-report', }, { sessionId: 'session-1', + turnId: 'turn-3', turnIndex: 3, itemId: 'tool-3', source: 'usage-report', }, { sessionId: 'session-1', + turnId: 'turn-4', turnIndex: 4, itemId: 'tool-4', source: 'usage-report', @@ -1442,10 +1445,10 @@ describe('Session usage report UI components', () => { ], }); - const pinEvents: FlowChatPinTurnToTopRequest[] = []; - const unsubscribe = globalEventBus.on( - FLOWCHAT_PIN_TURN_TO_TOP_EVENT, - event => pinEvents.push(event), + const focusEvents: FlowChatFocusItemRequest[] = []; + const unsubscribe = globalEventBus.on( + FLOWCHAT_FOCUS_ITEM_EVENT, + event => focusEvents.push(event), ); render(); @@ -1468,12 +1471,11 @@ describe('Session usage report UI components', () => { turnLink?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); }); - expect(pinEvents).toEqual([ + expect(focusEvents).toEqual([ { sessionId: 'session-1', turnId: 'turn-2', - behavior: 'smooth', - pinMode: 'transient', + turnIndex: 3, source: 'usage-report', }, ]); @@ -1535,7 +1537,8 @@ describe('Session usage report UI components', () => { expect(focusEvents).toEqual([ { sessionId: 'session-1', - turnIndex: 2, + turnId: 'turn-2', + turnIndex: 3, itemId: 'tool-slow', source: 'usage-report', }, @@ -1557,10 +1560,10 @@ describe('Session usage report UI components', () => { ], }); - const pinEvents: FlowChatPinTurnToTopRequest[] = []; - const unsubscribe = globalEventBus.on( - FLOWCHAT_PIN_TURN_TO_TOP_EVENT, - event => pinEvents.push(event), + const focusEvents: FlowChatFocusItemRequest[] = []; + const unsubscribe = globalEventBus.on( + FLOWCHAT_FOCUS_ITEM_EVENT, + event => focusEvents.push(event), ); render(); @@ -1582,12 +1585,11 @@ describe('Session usage report UI components', () => { turnLink?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); }); - expect(pinEvents).toEqual([ + expect(focusEvents).toEqual([ { sessionId: 'session-1', turnId: 'turn-4', - behavior: 'smooth', - pinMode: 'transient', + turnIndex: 5, source: 'usage-report', }, ]); diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.tsx b/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.tsx index 48f3305f60..374d901ad0 100644 --- a/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.tsx +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.tsx @@ -20,9 +20,7 @@ import { createDiffEditorTab } from '@/shared/utils/tabUtils'; import { createLogger } from '@/shared/utils/logger'; import { FLOWCHAT_FOCUS_ITEM_EVENT, - FLOWCHAT_PIN_TURN_TO_TOP_EVENT, type FlowChatFocusItemRequest, - type FlowChatPinTurnToTopRequest, } from '../../events/flowchatNavigation'; import { calculateShare, @@ -414,12 +412,14 @@ function UsageRowAnchorLink({ label, help, sessionId, + turnId, turnIndex, itemId, }: { label: string; help?: string; sessionId?: string; + turnId?: string; turnIndex?: number; itemId?: string; }) { @@ -427,7 +427,7 @@ function UsageRowAnchorLink({ const displayTurnIndex = typeof turnIndex === 'number' && Number.isFinite(turnIndex) ? getDisplayTurnIndex(turnIndex) : undefined; - const canJump = Boolean(sessionId && displayTurnIndex); + const canJump = Boolean(sessionId && (turnId || displayTurnIndex)); if (!canJump) { return ; @@ -441,9 +441,14 @@ function UsageRowAnchorLink({ onClick={() => { const request: FlowChatFocusItemRequest = { sessionId: sessionId as string, - turnIndex: displayTurnIndex as number, source: 'usage-report', }; + if (turnId) { + request.turnId = turnId; + } + if (displayTurnIndex) { + request.turnIndex = displayTurnIndex; + } if (itemId) { request.itemId = itemId; } @@ -704,6 +709,7 @@ function UsageModels({ report, sessionId }: { report: SessionUsageReport; sessio label={modelLabel} help={modelHelp} sessionId={sessionId} + turnId={model.sampleTurnId} turnIndex={model.sampleTurnIndex} /> ), @@ -765,6 +771,7 @@ function UsageTools({ report, sessionId }: { report: SessionUsageReport; session @@ -1036,6 +1043,7 @@ function UsageErrors({ report, sessionId }: { report: SessionUsageReport; sessio label={example.redacted ? getRedactedLabel(t) : example.label} help={t('usage.help.errorExampleRow')} sessionId={sessionId} + turnId={example.sampleTurnId} turnIndex={example.sampleTurnIndex} itemId={example.sampleItemId} /> @@ -1080,27 +1088,22 @@ function UsageSlowest({ report, sessionId }: { report: SessionUsageReport; sessi const handleJumpToSpan = useCallback((span: SessionUsageReport['slowest'][number]) => { if (!sessionId) return; - if (span.itemId && typeof span.turnIndex === 'number') { - const request: FlowChatFocusItemRequest = { - sessionId, - turnIndex: span.turnIndex, - itemId: span.itemId, - source: 'usage-report', - }; - globalEventBus.emit(FLOWCHAT_FOCUS_ITEM_EVENT, request, 'SessionUsagePanel'); - return; - } - - if (!span.turnId) return; + if (!span.turnId && typeof span.turnIndex !== 'number') return; - const request: FlowChatPinTurnToTopRequest = { + const request: FlowChatFocusItemRequest = { sessionId, - turnId: span.turnId, - behavior: 'smooth', - pinMode: 'transient', source: 'usage-report', }; - globalEventBus.emit(FLOWCHAT_PIN_TURN_TO_TOP_EVENT, request, 'SessionUsagePanel'); + if (span.turnId) { + request.turnId = span.turnId; + } + if (typeof span.turnIndex === 'number' && Number.isFinite(span.turnIndex)) { + request.turnIndex = getDisplayTurnIndex(span.turnIndex); + } + if (span.itemId) { + request.itemId = span.itemId; + } + globalEventBus.emit(FLOWCHAT_FOCUS_ITEM_EVENT, request, 'SessionUsagePanel'); }, [sessionId]); return ( @@ -1130,7 +1133,7 @@ function UsageSlowest({ report, sessionId }: { report: SessionUsageReport; sessi const detailRows = getSlowSpanDetailRows(span, t); const canJumpToSpan = Boolean( sessionId && - (span.turnId || (span.itemId && typeof span.turnIndex === 'number')) + (span.turnId || (typeof span.turnIndex === 'number' && Number.isFinite(span.turnIndex))) ); const jumpHelp = t('usage.actions.jumpToTurn'); const labelCell: UsageTableCell = canJumpToSpan diff --git a/src/web-ui/src/flow_chat/events/flowchatNavigation.ts b/src/web-ui/src/flow_chat/events/flowchatNavigation.ts index 4dd35b01f8..db94e9d953 100644 --- a/src/web-ui/src/flow_chat/events/flowchatNavigation.ts +++ b/src/web-ui/src/flow_chat/events/flowchatNavigation.ts @@ -11,6 +11,9 @@ export type FlowChatPinTurnToTopMode = 'transient' | 'sticky-latest'; export interface FlowChatFocusItemRequest { sessionId: string; + /** Stable Turn identity. When present, this is authoritative over `turnIndex`. */ + turnId?: string; + /** Absolute, one-based visible Turn index within the Session. */ turnIndex?: number; itemId?: string; source?: FlowChatFocusItemSource; diff --git a/src/web-ui/src/flow_chat/services/BtwThreadService.test.ts b/src/web-ui/src/flow_chat/services/BtwThreadService.test.ts index e0bc49b39c..cc525418df 100644 --- a/src/web-ui/src/flow_chat/services/BtwThreadService.test.ts +++ b/src/web-ui/src/flow_chat/services/BtwThreadService.test.ts @@ -181,6 +181,29 @@ describe('BtwThreadService', () => { }); }); + it('stores an absolute parent Turn index for a partial restored tail', () => { + sessions.set('parent-1', { + ...sessions.get('parent-1'), + isPartial: true, + loadedTurnCount: 3, + totalTurnCount: 100, + dialogTurns: [ + { id: 'turn-98' }, + { id: 'turn-99' }, + { id: 'turn-parent-1' }, + ], + }); + + const result = createBtwSessionPlaceholder({ + parentSessionId: 'parent-1', + workspacePath: '/workspace', + childSessionName: 'Side question', + }); + + expect(result.parentDialogTurnId).toBe('turn-parent-1'); + expect(result.parentTurnIndex).toBe(100); + }); + it('passes image contexts and parent turn metadata through to the desktop /btw API', async () => { sessions.set('btw-child', { sessionId: 'btw-child', diff --git a/src/web-ui/src/flow_chat/services/BtwThreadService.ts b/src/web-ui/src/flow_chat/services/BtwThreadService.ts index 97c715961f..f4032e7c53 100644 --- a/src/web-ui/src/flow_chat/services/BtwThreadService.ts +++ b/src/web-ui/src/flow_chat/services/BtwThreadService.ts @@ -10,6 +10,7 @@ import type { ReviewTeamRunManifest, } from '@/shared/services/reviewTeamService'; import type { ImagePayload } from '../utils/imagePayload'; +import { absoluteSessionTurnIndexForId } from '../utils/flowChatTurnOrdinal'; export function createBtwRequestId(prefix = 'btw'): string { try { @@ -49,8 +50,10 @@ function getParentInterruptionContext(parentSessionId: string): { parentDialogTu const parentDialogTurnId = machineTurnId || session.dialogTurns[session.dialogTurns.length - 1]?.id; if (!parentDialogTurnId) return { parentDialogTurnId: undefined, parentTurnIndex: undefined }; - const idx = session.dialogTurns.findIndex(t => t.id === parentDialogTurnId); - return { parentDialogTurnId, parentTurnIndex: idx >= 0 ? idx + 1 : undefined }; + return { + parentDialogTurnId, + parentTurnIndex: absoluteSessionTurnIndexForId(session, parentDialogTurnId), + }; } function requireSession(sessionId: string): Session { diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts index 88ccee49a3..b6129be9d0 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts @@ -266,6 +266,71 @@ describe('subagent parent helpers', () => { FlowChatStore.getInstance().getState().sessions.get('review-child')?.focusedReviewDisplayLabel, ).toBe('Authentication boundary'); }); + + it('stores an absolute parent Turn index when linking from a partial restored tail', () => { + const task = makeTaskTool('task-tail'); + FlowChatStore.getInstance().setState(() => ({ + sessions: new Map([[ + 'parent-session', + { + sessionId: 'parent-session', + title: 'Parent Session', + dialogTurns: [{ + id: 'parent-turn-100', + sessionId: 'parent-session', + backendTurnIndex: 99, + userMessage: { id: 'user-100', content: 'Delegate work', timestamp: 900 }, + modelRounds: [makeRound('round-100', [task])], + status: 'processing', + startTime: 900, + }], + status: 'idle', + config: { agentType: 'agentic' }, + createdAt: 800, + lastActiveAt: 1000, + error: null, + sessionKind: 'normal', + isPartial: true, + loadedTurnCount: 1, + totalTurnCount: 100, + turnCatalog: { + schemaVersion: 1, + sessionId: 'parent-session', + revision: 'catalog-1', + totalTurnCount: 100, + complete: false, + entries: [{ + ordinal: 99, + storageTurnIndex: 99, + turnId: 'parent-turn-100', + preview: 'Delegate work', + previewTruncated: false, + }], + }, + } as Session, + ]]), + activeSessionId: 'parent-session', + })); + + __test_only__.handleSubagentSessionLinked( + { currentWorkspacePath: 'D:\\workspace\\repo' } as FlowChatContext, + { + sessionId: 'child-session', + parentSessionId: 'parent-session', + parentDialogTurnId: 'parent-turn-100', + parentToolCallId: 'task-tail', + agentType: 'Explore', + }, + ); + + expect( + FlowChatStore.getInstance().getState().sessions.get('child-session')?.btwOrigin, + ).toMatchObject({ + parentSessionId: 'parent-session', + parentDialogTurnId: 'parent-turn-100', + parentTurnIndex: 100, + }); + }); }); describe('shouldProcessEvent', () => { diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 024edc331e..dad272767c 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -25,6 +25,7 @@ import { handleThreadGoalUpdated } from '../threadGoalEventService'; import { resolveThreadGoalUserMessageDisplay } from '../../utils/threadGoalDisplay'; import { cleanRemoteUserInput } from '../../utils/userInputText'; import { effectiveToolInvocation, getEffectiveToolName } from '../../utils/toolInvocationIdentity'; +import { absoluteSessionTurnIndexForId } from '../../utils/flowChatTurnOrdinal'; import type { DeepReviewQueueStateChangedEvent, ImageAnalysisEvent, @@ -428,8 +429,8 @@ function ensureSubagentSession( const parentSession = store.getState().sessions.get(parentInfo.sessionId); const parentTurnIndex = parentSession - ?.dialogTurns - .findIndex(turn => turn.id === parentInfo.dialogTurnId); + ? absoluteSessionTurnIndexForId(parentSession, parentInfo.dialogTurnId) + : undefined; store.addExternalSession( subagentSessionId, buildSubagentSessionTitleWithType(parentInfo, explicitSubagentType), @@ -443,9 +444,7 @@ function ensureSubagentSession( btwOrigin: { parentSessionId: parentInfo.sessionId, parentDialogTurnId: parentInfo.dialogTurnId, - parentTurnIndex: typeof parentTurnIndex === 'number' && parentTurnIndex >= 0 - ? parentTurnIndex + 1 - : undefined, + parentTurnIndex, }, focusedReviewDisplayLabel, projectWorkspacePath: diff --git a/src/web-ui/src/flow_chat/utils/flowChatTurnOrdinal.test.ts b/src/web-ui/src/flow_chat/utils/flowChatTurnOrdinal.test.ts new file mode 100644 index 0000000000..0dcf033bed --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/flowChatTurnOrdinal.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import type { Session } from '../types/flow-chat'; +import { + absoluteSessionTurnIndexForId, + loadedSessionTurnIdForAbsoluteIndex, +} from './flowChatTurnOrdinal'; + +function createPartialSession(): Session { + return { + sessionId: 'session-1', + dialogTurns: [98, 99, 100].map(turnIndex => ({ + id: `turn-${turnIndex}`, + sessionId: 'session-1', + userMessage: { + id: `user-${turnIndex}`, + content: `Prompt ${turnIndex}`, + timestamp: 1000, + }, + modelRounds: [], + status: 'completed', + startTime: 1000, + })), + status: 'idle', + config: {}, + createdAt: 1000, + lastActiveAt: 1000, + error: null, + sessionKind: 'flow_chat', + isPartial: true, + loadedTurnCount: 3, + totalTurnCount: 100, + }; +} + +describe('FlowChat absolute Turn ordinal helpers', () => { + it('derives absolute indexes for a partial restored tail', () => { + const session = createPartialSession(); + + expect(absoluteSessionTurnIndexForId(session, 'turn-98')).toBe(98); + expect(absoluteSessionTurnIndexForId(session, 'turn-100')).toBe(100); + expect(loadedSessionTurnIdForAbsoluteIndex(session, 99)).toBe('turn-99'); + expect(loadedSessionTurnIdForAbsoluteIndex(session, 2)).toBeUndefined(); + }); + + it('prefers catalog ordinals over sparse storage indexes', () => { + const session = createPartialSession(); + session.dialogTurns[0].backendTurnIndex = 140; + session.turnCatalog = { + schemaVersion: 1, + sessionId: session.sessionId, + revision: 'catalog-1', + totalTurnCount: 100, + complete: false, + entries: Array.from({ length: 100 }, (_, ordinal) => ({ + ordinal, + storageTurnIndex: ordinal === 97 ? 140 : ordinal, + turnId: ordinal === 97 ? 'turn-98' : undefined, + preview: ordinal === 97 ? 'Prompt 98' : undefined, + previewTruncated: false, + })), + }; + + expect(absoluteSessionTurnIndexForId(session, 'turn-98')).toBe(98); + expect(loadedSessionTurnIdForAbsoluteIndex(session, 98)).toBe('turn-98'); + }); +}); diff --git a/src/web-ui/src/flow_chat/utils/flowChatTurnOrdinal.ts b/src/web-ui/src/flow_chat/utils/flowChatTurnOrdinal.ts new file mode 100644 index 0000000000..4cc779c673 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/flowChatTurnOrdinal.ts @@ -0,0 +1,73 @@ +import type { Session } from '../types/flow-chat'; + +function sessionTurnCatalog(session: Session) { + return session.turnCatalog?.sessionId === session.sessionId + ? session.turnCatalog + : undefined; +} + +export function absoluteSessionTurnIndexForLocalIndex( + session: Session, + localIndex: number, +): number { + const turn = session.dialogTurns[localIndex]; + if (!turn) { + return localIndex + 1; + } + + const catalog = sessionTurnCatalog(session); + const catalogEntry = catalog?.entries.find(entry => + entry.turnId === turn.id + || ( + typeof turn.backendTurnIndex === 'number' + && entry.storageTurnIndex === turn.backendTurnIndex + ) + ); + if (catalogEntry) { + return catalogEntry.ordinal + 1; + } + if (typeof turn.backendTurnIndex === 'number') { + return turn.backendTurnIndex + 1; + } + if (session.isPartial === true) { + const totalTurnCount = Math.max( + session.totalTurnCount ?? 0, + catalog?.totalTurnCount ?? 0, + session.dialogTurns.length, + ); + return Math.max(0, totalTurnCount - session.dialogTurns.length) + localIndex + 1; + } + return localIndex + 1; +} + +export function absoluteSessionTurnIndexForId( + session: Session, + turnId: string, +): number | undefined { + const catalogEntry = sessionTurnCatalog(session)?.entries.find(entry => entry.turnId === turnId); + if (catalogEntry) { + return catalogEntry.ordinal + 1; + } + + const localIndex = session.dialogTurns.findIndex(turn => turn.id === turnId); + return localIndex >= 0 + ? absoluteSessionTurnIndexForLocalIndex(session, localIndex) + : undefined; +} + +export function loadedSessionTurnIdForAbsoluteIndex( + session: Session, + turnIndex: number, +): string | undefined { + const ordinal = turnIndex - 1; + const catalogTurnId = sessionTurnCatalog(session)?.entries.find( + entry => entry.ordinal === ordinal, + )?.turnId; + if (catalogTurnId) { + return catalogTurnId; + } + + return session.dialogTurns.find((_, localIndex) => + absoluteSessionTurnIndexForLocalIndex(session, localIndex) === turnIndex + )?.id; +} From 734723948ca1e9ea1e570bc80dab4e9c813cb166 Mon Sep 17 00:00:00 2001 From: wsp Date: Sat, 1 Aug 2026 16:27:16 +0800 Subject: [PATCH 10/23] fix(flow-chat): enable actions for windowed history turns - Carry absolute Turn ordinals and status through the virtual message projection - Allow editing and rollback for Turns outside the canonical tail - Resolve mutation indexes after full-history hydration using stable Turn IDs - Invalidate cached projections when catalog or partial-history metadata changes - Add regression coverage for sparse history windows and catalog repairs --- .../modern/UserMessageItem.test.tsx | 194 +++++++++++++++++- .../components/modern/UserMessageItem.tsx | 36 ++-- .../components/modern/VirtualItemRenderer.tsx | 9 +- .../store/modernFlowChatStore.test.ts | 93 +++++++++ .../flow_chat/store/modernFlowChatStore.ts | 34 ++- .../utils/flowChatTurnOrdinal.test.ts | 2 + .../flow_chat/utils/flowChatTurnOrdinal.ts | 45 ++++ 7 files changed, 389 insertions(+), 24 deletions(-) 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 b437d21919..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,6 +6,7 @@ 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; @@ -18,6 +19,61 @@ const snapshotApiMock = vi.hoisted(() => ({ 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: { @@ -84,6 +140,23 @@ vi.mock('@/component-library', () => ({ confirmDanger: componentLibraryMock.confirmDanger, })); +vi.mock('../../services/UserMessageEditService', () => ({ + describeUserMessageEditImpact: editServiceMock.describeUserMessageEditImpact, + editAndRerunUserMessage: editServiceMock.editAndRerunUserMessage, +})); + +vi.mock('./UserMessageEditComposer', () => ({ + UserMessageEditComposer: ({ onSubmit }: { onSubmit: () => void }) => ( + + ), +})); + describe('UserMessageItem steering tag', () => { let dom: JSDOM; let container: HTMLDivElement; @@ -98,6 +171,8 @@ describe('UserMessageItem steering tag', () => { flowChatStoreMock.ensureSessionFullHistory.mockResolvedValue(true); componentLibraryMock.confirmDanger.mockResolvedValue(true); snapshotApiMock.rollbackToTurn.mockResolvedValue([]); + editServiceMock.editAndRerunUserMessage.mockResolvedValue(undefined); + useMessageEditStore.getState().cancelEdit(); dom = new JSDOM('
', { pretendToBeVisual: true, }); @@ -119,6 +194,7 @@ describe('UserMessageItem steering tag', () => { act(() => { root.unmount(); }); + useMessageEditStore.getState().cancelEdit(); vi.unstubAllGlobals(); }); @@ -416,7 +492,7 @@ describe('UserMessageItem steering tag', () => { expect(container.querySelector('.user-message-item__edit-btn')).toBeNull(); }); - it('keeps edit and rollback available for on-demand hydration in 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', @@ -522,4 +598,120 @@ describe('UserMessageItem steering tag', () => { 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 b992868c46..b10f096677 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,19 +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 catalogTurnOrdinal = currentSession?.turnCatalog?.entries.find( - entry => entry.turnId === turnId, - )?.ordinal; - const partialTurnOffset = currentSession?.isPartial === true - ? Math.max( - 0, - (currentSession.totalTurnCount ?? currentSession.dialogTurns.length) - - currentSession.dialogTurns.length, - ) - : 0; - const actionTurnIndex = catalogTurnOrdinal ?? partialTurnOffset + turnIndex; + 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', @@ -158,14 +157,14 @@ export const UserMessageItem = React.memo( !steeringStatus && canShowRollbackAction && !!resolvedSessionId && - turnIndex >= 0 && + actionTurnIndex >= 0 && !isRemoteSession && !isRollingBack && !isEditSubmitting; const canEditBase = allowUserMessageEdit && !!resolvedSessionId && - turnIndex >= 0 && + actionTurnIndex >= 0 && !isRemoteSession && !isThreadGoalSystemMessage && !isSystemTriggered && @@ -178,7 +177,7 @@ export const UserMessageItem = React.memo( ? t('message.cannotEdit') : steeringStatus ? t('message.cannotEdit') - : !resolvedSessionId || turnIndex < 0 + : !resolvedSessionId || actionTurnIndex < 0 ? t('message.editDisabledHistoryNotReady') : t('message.cannotEdit'); const rollbackTooltip = canRollback @@ -331,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 @@ -422,7 +421,6 @@ export const UserMessageItem = React.memo( setEditSubmitting, t, turnId, - turnIndex, ]); // Toggle expanded state. @@ -513,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/store/modernFlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts index 35e3d7cb90..5565bfc870 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts @@ -136,6 +136,99 @@ describe('sessionToVirtualItems explore grouping', () => { expect(items.map(item => item.type)).toEqual(['user-message', 'explore-group']); }); + it('projects the absolute Turn index for a sparse history-window message', () => { + const session = makeSession({ + sessionId: 'history-window-session', + isPartial: true, + loadedTurnCount: 1, + totalTurnCount: 20, + dialogTurns: [{ + id: 'turn-5', + sessionId: 'history-window-session', + backendTurnIndex: 40, + userMessage: { + id: 'user-5', + content: 'Older window prompt', + timestamp: 900, + }, + modelRounds: [], + status: 'error', + startTime: 900, + }], + turnCatalog: { + schemaVersion: 1, + sessionId: 'history-window-session', + revision: 'catalog-1', + totalTurnCount: 20, + complete: false, + entries: [{ + ordinal: 4, + storageTurnIndex: 40, + preview: 'Older window prompt', + previewTruncated: false, + }], + }, + }); + + const userMessage = sessionToVirtualItems(session).find(item => item.type === 'user-message'); + + expect(userMessage).toMatchObject({ + type: 'user-message', + turnId: 'turn-5', + absoluteTurnIndex: 5, + turnStatus: 'error', + }); + }); + + it('invalidates a cached Turn projection when catalog ordinals are repaired', () => { + const dialogTurns = [{ + id: 'turn-5', + sessionId: 'catalog-repair-session', + backendTurnIndex: 40, + userMessage: { + id: 'user-5', + content: 'Older window prompt', + timestamp: 900, + }, + modelRounds: [], + status: 'completed' as const, + startTime: 900, + }]; + const initialSession = makeSession({ + sessionId: 'catalog-repair-session', + isPartial: true, + loadedTurnCount: 1, + totalTurnCount: 20, + dialogTurns, + }); + const repairedSession = makeSession({ + ...initialSession, + dialogTurns, + turnCatalog: { + schemaVersion: 1, + sessionId: 'catalog-repair-session', + revision: 'catalog-2', + totalTurnCount: 20, + complete: false, + entries: [{ + ordinal: 4, + storageTurnIndex: 40, + turnId: 'turn-5', + preview: 'Older window prompt', + previewTruncated: false, + }], + }, + }); + + const initialUserMessage = sessionToVirtualItems(initialSession) + .find(item => item.type === 'user-message'); + const repairedUserMessage = sessionToVirtualItems(repairedSession) + .find(item => item.type === 'user-message'); + + expect(initialUserMessage).toMatchObject({ absoluteTurnIndex: 41 }); + expect(repairedUserMessage).toMatchObject({ absoluteTurnIndex: 5 }); + }); + it('keeps trailing assistant text visible after collapsible tool history', () => { const round = makeRound({ id: 'round-with-trailing-answer', diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts index 9ef4366f5a..589ff3dd40 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts @@ -20,6 +20,7 @@ import { getTurnCompletionNotice, type TurnCompletionNotice, } from '../utils/turnCompletionNotice'; +import { createAbsoluteSessionTurnIndexResolver } from '../utils/flowChatTurnOrdinal'; /** * Explore group statistics (merged computed stats) @@ -55,7 +56,13 @@ export interface ExploreGroupData { * Used for virtual scrolling, flattens DialogTurn into renderable items */ export type VirtualItem = - | { type: 'user-message'; data: DialogTurn['userMessage']; turnId: string } + | { + type: 'user-message'; + data: DialogTurn['userMessage']; + turnId: string; + absoluteTurnIndex?: number; + turnStatus?: DialogTurn['status']; + } | { type: 'user-steering-message'; data: NonNullable; @@ -255,10 +262,13 @@ function isStableTurnProjection(turn: DialogTurn): boolean { let cachedSession: Session | null = null; let cachedDialogTurnsRef: DialogTurn[] | null = null; +let cachedTurnCatalogRef: Session['turnCatalog'] | undefined; +let cachedIsPartial: boolean | undefined; +let cachedTotalTurnCount: number | undefined; let cachedVirtualItems: VirtualItem[] = []; let cachedTurnItems = new WeakMap< DialogTurn, - { items: VirtualItem[]; hasNewerDialogTurn: boolean } + { items: VirtualItem[]; hasNewerDialogTurn: boolean; absoluteTurnIndex: number } >(); /** @@ -275,6 +285,9 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { if (cachedSession !== null) { cachedSession = null; cachedDialogTurnsRef = null; + cachedTurnCatalogRef = undefined; + cachedIsPartial = undefined; + cachedTotalTurnCount = undefined; cachedVirtualItems = []; cachedTurnItems = new WeakMap(); } @@ -283,22 +296,31 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { if ( cachedSession?.sessionId === session.sessionId && - cachedDialogTurnsRef === session.dialogTurns + cachedDialogTurnsRef === session.dialogTurns && + cachedTurnCatalogRef === session.turnCatalog && + cachedIsPartial === session.isPartial && + cachedTotalTurnCount === session.totalTurnCount ) { return cachedVirtualItems; } cachedSession = session; cachedDialogTurnsRef = session.dialogTurns; + cachedTurnCatalogRef = session.turnCatalog; + cachedIsPartial = session.isPartial; + cachedTotalTurnCount = session.totalTurnCount; const items: VirtualItem[] = []; + const resolveAbsoluteTurnIndex = createAbsoluteSessionTurnIndexResolver(session); session.dialogTurns.forEach((turn, turnIndex) => { const hasNewerDialogTurn = turnIndex < session.dialogTurns.length - 1; + const absoluteTurnIndex = resolveAbsoluteTurnIndex(turnIndex); const cachedItems = cachedTurnItems.get(turn); if ( cachedItems && cachedItems.hasNewerDialogTurn === hasNewerDialogTurn && + cachedItems.absoluteTurnIndex === absoluteTurnIndex && isStableTurnProjection(turn) ) { items.push(...cachedItems.items); @@ -311,6 +333,8 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { type: 'user-message', data: turn.userMessage, turnId: turn.id, + absoluteTurnIndex, + turnStatus: turn.status, }); } @@ -541,6 +565,7 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { cachedTurnItems.set(turn, { items: items.slice(turnItemStart), hasNewerDialogTurn, + absoluteTurnIndex, }); } }); @@ -595,6 +620,9 @@ export const useModernFlowChatStore = create()( clear: () => { cachedSession = null; cachedDialogTurnsRef = null; + cachedTurnCatalogRef = undefined; + cachedIsPartial = undefined; + cachedTotalTurnCount = undefined; cachedVirtualItems = []; cachedTurnItems = new WeakMap(); diff --git a/src/web-ui/src/flow_chat/utils/flowChatTurnOrdinal.test.ts b/src/web-ui/src/flow_chat/utils/flowChatTurnOrdinal.test.ts index 0dcf033bed..5bacc28429 100644 --- a/src/web-ui/src/flow_chat/utils/flowChatTurnOrdinal.test.ts +++ b/src/web-ui/src/flow_chat/utils/flowChatTurnOrdinal.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { Session } from '../types/flow-chat'; import { absoluteSessionTurnIndexForId, + createAbsoluteSessionTurnIndexResolver, loadedSessionTurnIdForAbsoluteIndex, } from './flowChatTurnOrdinal'; @@ -62,5 +63,6 @@ describe('FlowChat absolute Turn ordinal helpers', () => { expect(absoluteSessionTurnIndexForId(session, 'turn-98')).toBe(98); expect(loadedSessionTurnIdForAbsoluteIndex(session, 98)).toBe('turn-98'); + expect(createAbsoluteSessionTurnIndexResolver(session)(0)).toBe(98); }); }); diff --git a/src/web-ui/src/flow_chat/utils/flowChatTurnOrdinal.ts b/src/web-ui/src/flow_chat/utils/flowChatTurnOrdinal.ts index 4cc779c673..4f104287a0 100644 --- a/src/web-ui/src/flow_chat/utils/flowChatTurnOrdinal.ts +++ b/src/web-ui/src/flow_chat/utils/flowChatTurnOrdinal.ts @@ -40,6 +40,51 @@ export function absoluteSessionTurnIndexForLocalIndex( return localIndex + 1; } +export function createAbsoluteSessionTurnIndexResolver( + session: Session, +): (localIndex: number) => number { + const catalog = sessionTurnCatalog(session); + const catalogOrdinalByTurnId = new Map(); + const catalogOrdinalByStorageIndex = new Map(); + for (const entry of catalog?.entries ?? []) { + if (entry.turnId) { + catalogOrdinalByTurnId.set(entry.turnId, entry.ordinal); + } + catalogOrdinalByStorageIndex.set(entry.storageTurnIndex, entry.ordinal); + } + const partialTurnOffset = session.isPartial === true + ? Math.max( + 0, + Math.max( + session.totalTurnCount ?? 0, + catalog?.totalTurnCount ?? 0, + session.dialogTurns.length, + ) - session.dialogTurns.length, + ) + : 0; + + return (localIndex: number): number => { + const turn = session.dialogTurns[localIndex]; + if (!turn) { + return localIndex + 1; + } + + const catalogOrdinal = catalogOrdinalByTurnId.get(turn.id) + ?? ( + typeof turn.backendTurnIndex === 'number' + ? catalogOrdinalByStorageIndex.get(turn.backendTurnIndex) + : undefined + ); + if (catalogOrdinal !== undefined) { + return catalogOrdinal + 1; + } + if (typeof turn.backendTurnIndex === 'number') { + return turn.backendTurnIndex + 1; + } + return partialTurnOffset + localIndex + 1; + }; +} + export function absoluteSessionTurnIndexForId( session: Session, turnId: string, From 982550b53027e034958a1f969125f65c57819727 Mon Sep 17 00:00:00 2001 From: wsp Date: Sat, 1 Aug 2026 17:12:45 +0800 Subject: [PATCH 11/23] fix(flow-chat): persist incremental Turn catalog repairs - Base catalog revisions on the structural Turn storage sequence - Persist metadata repaired during initial tail and bounded-window loading - Avoid synthetic stale retries when only Turn IDs or previews are enriched - Preserve staged-revert suffixes and skip unsafe padded-index persistence - Add regression coverage for legacy sessions and missing Turn files --- .../core/src/agentic/persistence/manager.rs | 324 +++++++++++++++--- .../services-core/src/session/types.rs | 2 + 2 files changed, 284 insertions(+), 42 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/persistence/manager.rs b/src/crates/assembly/core/src/agentic/persistence/manager.rs index 989bcabef7..8fc2bbab2a 100644 --- a/src/crates/assembly/core/src/agentic/persistence/manager.rs +++ b/src/crates/assembly/core/src/agentic/persistence/manager.rs @@ -161,6 +161,12 @@ 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 @@ -214,24 +220,9 @@ fn turn_catalog_revision(entries: &[SessionTurnCatalogEntry]) -> String { for entry in entries { hasher.update((entry.ordinal as u64).to_le_bytes()); hasher.update((entry.storage_turn_index as u64).to_le_bytes()); - if let Some(turn_id) = entry.turn_id.as_deref() { - hasher.update([1]); - hasher.update((turn_id.len() as u64).to_le_bytes()); - hasher.update(turn_id.as_bytes()); - } else { - hasher.update([0]); - } - if let Some(preview) = entry.preview.as_deref() { - hasher.update([1]); - hasher.update((preview.len() as u64).to_le_bytes()); - hasher.update(preview.as_bytes()); - } else { - hasher.update([0]); - } - hasher.update([u8::from(entry.preview_truncated)]); } let digest = hasher.finalize(); - format!("v1-{}", hex::encode(&digest[..8])) + format!("v2-{}", hex::encode(&digest[..8])) } fn build_turn_catalog( @@ -2573,10 +2564,9 @@ impl PersistenceManager { /// Build the lightweight navigation catalog for a Session view. /// - /// The read path never mutates Session storage. Missing or stale legacy - /// entries are returned as index-only placeholders. Aligned sidecars are - /// repaired incrementally as those Turns are saved; a full restore can - /// still complete the entire projection. + /// 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, @@ -2585,38 +2575,68 @@ impl PersistenceManager { 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 = match self + let (physical_indices, can_persist_physical_projection) = match self .list_indexed_turn_paths(workspace_path, session_id) .await { - Ok(paths) => complete_turn_catalog_indices( - paths + Ok(paths) => { + let persisted_indices = paths .into_iter() .map(|(index, _)| index) - .chain(loaded_turns.iter().map(|turn| turn.turn_index)), - visible_total_turn_count, - ), + .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, + ( + complete_turn_catalog_indices( + loaded_turns.iter().map(|turn| turn.turn_index), + visible_total_turn_count, + ), + false, ) } }; - self.build_session_turn_catalog_projection( - workspace_path, - session_id, - physical_indices, - loaded_turns, - visible_total_turn_count, - ) - .await + 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( @@ -2627,14 +2647,36 @@ impl PersistenceManager { 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 - .into_iter() + .iter() + .cloned() .map(|entry| (entry.storage_turn_index, entry)) .collect::>() }) @@ -2661,10 +2703,16 @@ impl PersistenceManager { let physical_catalog = build_turn_catalog(session_id, physical_entries); let visible_entries = physical_catalog .entries - .into_iter() + .iter() .take(visible_total_turn_count) + .cloned() .collect::>(); - Ok(build_turn_catalog(session_id, visible_entries)) + 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 @@ -2684,9 +2732,14 @@ impl PersistenceManager { .load_session_revert_state(&request.workspace_path, &request.session_id) .await? .map(|state| state.boundary_turn); - let indexed_paths = self + let physical_indexed_paths = self .list_indexed_turn_paths(&request.workspace_path, &request.session_id) - .await? + .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::>(); @@ -2774,6 +2827,30 @@ impl PersistenceManager { } } + 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 @@ -5012,6 +5089,12 @@ mod tests { .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"); @@ -5020,6 +5103,12 @@ mod tests { .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) @@ -5032,6 +5121,157 @@ mod tests { 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] diff --git a/src/crates/services/services-core/src/session/types.rs b/src/crates/services/services-core/src/session/types.rs index a87dccd871..0d4029e001 100644 --- a/src/crates/services/services-core/src/session/types.rs +++ b/src/crates/services/services-core/src/session/types.rs @@ -394,6 +394,8 @@ pub struct SessionTurnCatalogEntry { 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, From 50577848f409c9fe04acb3637ff73fe990fd7fd7 Mon Sep 17 00:00:00 2001 From: wsp Date: Sat, 1 Aug 2026 17:40:30 +0800 Subject: [PATCH 12/23] fix(flow-chat): release failed history window anchors - Track element-anchor ownership with transaction-specific leases - Release anchors when adjacent history requests fail or are not handled - Prevent stale request completions from releasing newer viewport transactions - Add regression coverage for failed requests and overlapping anchor leases - Document the adjacent-window anchor cleanup contract --- .../modern/FLOWCHAT_SCROLL_STABILITY.md | 3 + .../FlowChatViewportCoordinator.test.ts | 30 +++++++++ .../modern/FlowChatViewportCoordinator.ts | 41 +++++++++++-- ...rtualMessageList.session-boundary.test.tsx | 61 +++++++++++++++++++ .../components/modern/VirtualMessageList.tsx | 37 +++++++++-- 5 files changed, 162 insertions(+), 10 deletions(-) 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 0cf607d46f..3ab3c00fbe 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 @@ -443,6 +443,9 @@ these ownership rules intact: 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 for that request. 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, 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..4be09cff69 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'; 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..c0c1c756d7 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 { 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 869f6ea489..c4e0a3161d 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 @@ -592,6 +592,67 @@ describe('VirtualMessageList session boundary', () => { expect(container.querySelector('[data-history-paging-sentinel="loading"]')).not.toBeNull(); }); + 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'); + + 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; 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 38ba852903..889b41d1b9 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -3297,9 +3297,9 @@ const VirtualMessageListSession = forwardRef; + try { + boundaryIntentResult = onHistoryWindowBoundaryIntent(direction); + } catch { + if (anchorLease !== null) { + viewportCoordinatorRef.current.releaseElementPreservationLease( + anchorLease, + 'history-window-boundary-intent-threw', + ); + } + return false; + } + void Promise.resolve(boundaryIntentResult).then( + handled => { + if (handled === false && anchorLease !== null) { + viewportCoordinatorRef.current.releaseElementPreservationLease( + anchorLease, + 'history-window-boundary-not-handled', + ); + } + }, + () => { + if (anchorLease !== null) { + viewportCoordinatorRef.current.releaseElementPreservationLease( + anchorLease, + 'history-window-boundary-failed', + ); + } + }, + ); return true; }, [ captureHistoryPrependAnchor, From 2199e5ae478e1e93444bd9f4bc79ca7b3f85e4a5 Mon Sep 17 00:00:00 2001 From: wsp Date: Sat, 1 Aug 2026 20:23:40 +0800 Subject: [PATCH 13/23] fix(flowchat): stabilize windowed history scrolling - Defer history presentation commits until user scroll input becomes quiet - Cancel stale commits across session and presentation ownership changes - Preserve and refresh prepend anchors while Virtuoso updates the rendered window - Remove the hidden paging sentinel gap from history-window presentations - Add regression coverage and document the updated viewport ownership contract --- .../modern/FLOWCHAT_SCROLL_STABILITY.md | 19 +- ...rnFlowChatContainer.history-state.test.tsx | 36 ++- .../modern/ModernFlowChatContainer.tsx | 34 ++ ...rtualMessageList.session-boundary.test.tsx | 140 ++++++-- .../components/modern/VirtualMessageList.tsx | 300 ++++++++++++++---- 5 files changed, 425 insertions(+), 104 deletions(-) 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 3ab3c00fbe..6565f84666 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 @@ -435,17 +435,24 @@ these ownership rules intact: full-history consumer calls `ensureSessionFullHistory`. - 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 captures the existing - element anchor, loads the adjacent ordinal window, and changes to one - contiguous history-window presentation. It must not expose a later cached - range across an unloaded gap. +- 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. - 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 for that request. A stale completion must never release - a newer navigation or layout-preservation transaction. + 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, 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 1d0297cf39..75c9f41b24 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 @@ -1724,17 +1724,45 @@ describe('ModernFlowChatContainer historical empty state', () => { }, 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 () => { - const handled = await ( + boundaryIntent = ( virtualListPropsMock.latest?.onHistoryWindowBoundaryIntent as - | ((direction: 'before' | 'after') => Promise) + | (( + direction: 'before' | 'after', + options?: { + prepareViewportForPresentationCommit?: () => ( + boolean | void | Promise + ); + cancelViewportPresentationCommit?: () => void; + }, + ) => Promise) | undefined - )?.('before'); - expect(handled).toBe(true); + )?.('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(true); }); expect(loadSpy).toHaveBeenCalledWith('session-1', 7, { 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 febbd4fd0d..4b817e6627 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,7 @@ import { useSessionModeStore } from '@/app/stores/sessionModeStore'; import { VirtualMessageList, type FlowChatTurnPinRequestStatus, + type HistoryWindowBoundaryIntentOptions, type VirtualMessageListRef, } from './VirtualMessageList'; import { @@ -291,6 +292,7 @@ export const ModernFlowChatContainer: React.FC = ( before: null, after: null, }); + const historyPresentationOwnerGenerationRef = useRef(0); const virtualItems = useMemo(() => { if (!activeSession || historyPresentation?.sessionId !== activeSession.sessionId) { return canonicalVirtualItems; @@ -406,6 +408,7 @@ export const ModernFlowChatContainer: React.FC = ( useFlowChatCopyDialog(); const clearHistoryPresentationForSession = useCallback((sessionId: string) => { + historyPresentationOwnerGenerationRef.current += 1; flowChatStore.restoreSessionTailPresentation(sessionId); historyPresentationRef.current = null; setHistoryPresentation(null); @@ -428,6 +431,7 @@ export const ModernFlowChatContainer: React.FC = ( useLayoutEffect(() => { const sessionId = activeSession?.sessionId; + historyPresentationOwnerGenerationRef.current += 1; historyPresentationRef.current = null; setHistoryPresentation(null); setHistoryBoundaryState(IDLE_HISTORY_BOUNDARY_STATE); @@ -1303,6 +1307,7 @@ export const ModernFlowChatContainer: React.FC = ( presentation: SessionHistoryPresentation, options?: { completedBoundary?: SessionHistoryWindowDirection }, ) => { + historyPresentationOwnerGenerationRef.current += 1; setHistoryPresentation(previous => { const next: FlowChatHistoryPresentationState = { ...presentation, @@ -1542,6 +1547,7 @@ export const ModernFlowChatContainer: React.FC = ( const handleHistoryWindowBoundaryIntent = useCallback(( direction: SessionHistoryWindowDirection, + options?: HistoryWindowBoundaryIntentOptions, ): Promise => { const existingRequest = historyBoundaryRequestsRef.current[direction]; if (existingRequest) { @@ -1551,6 +1557,7 @@ export const ModernFlowChatContainer: React.FC = ( const request = (async () => { const presentation = historyPresentationRef.current; const sessionId = activeSessionIdRef.current; + const presentationOwnerGeneration = historyPresentationOwnerGenerationRef.current; if (!sessionId || (presentation && presentation.sessionId !== sessionId)) { return false; } @@ -1588,6 +1595,7 @@ export const ModernFlowChatContainer: React.FC = ( } setHistoryBoundaryState(previous => ({ ...previous, [direction]: 'loading' })); + let viewportPreparationStarted = false; try { const result = await flowChatStore.loadSessionTurnWindow(sessionId, targetOrdinal, { source: 'prefetch', @@ -1612,16 +1620,42 @@ export const ModernFlowChatContainer: React.FC = ( return false; } + 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 activeSessionIsCurrent; + } + const nextPresentation = presentation ? flowChatStore.extendSessionHistoryWindow(sessionId, direction) : flowChatStore.activateSessionHistoryWindowFromTail(sessionId, targetOrdinal); if (!nextPresentation) { + if (viewportPreparationStarted) { + options?.cancelViewportPresentationCommit?.(); + } setHistoryBoundaryState(previous => ({ ...previous, [direction]: 'error' })); return false; } applyHistoryPresentation(sessionId, nextPresentation, { completedBoundary: direction }); return true; } catch (error) { + if (viewportPreparationStarted) { + options?.cancelViewportPresentationCommit?.(); + } if (activeSessionIdRef.current === sessionId) { setHistoryBoundaryState(previous => ({ ...previous, [direction]: 'error' })); } 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 c4e0a3161d..6608051419 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 @@ -515,8 +515,23 @@ describe('VirtualMessageList session boundary', () => { )).toBe('false'); }); - it('prefetches an adjacent history window from user intent and preserves the visible element anchor', () => { - const onBoundaryIntent = vi.fn(); + 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; @@ -561,35 +576,85 @@ describe('VirtualMessageList session boundary', () => { return createRect({ top, bottom: top + 20, height: 20 }); }; - act(() => { - scroller.dispatchEvent(new WheelEvent('wheel', { deltaY: -20, bubbles: true })); - }); - expect(onBoundaryIntent).toHaveBeenCalledWith('before'); + vi.useFakeTimers(); + try { + act(() => { + scroller.dispatchEvent(new WheelEvent('wheel', { deltaY: -20, bubbles: true })); + }); + expect(onBoundaryIntent).toHaveBeenCalledWith('before', expect.objectContaining({ + prepareViewportForPresentationCommit: expect.any(Function), + })); - const extendedItems = ['turn-1', 'turn-2', ...initialItems.map(item => item.turnId)].map(createItem); - anchorDocumentTop = 120; - act(() => { - root.render( - , - ); - }); - flushAnimationFrame(); - flushAnimationFrame(); + scroller.scrollTop = 80; + let preparationSettled = false; + const preparation = Promise.resolve(prepareViewportForPresentationCommit?.()).then(result => { + preparationSettled = true; + return result; + }); - expect(scroller.scrollTop).toBe(100); - expect(container.querySelector('[data-history-paging-sentinel="loading"]')).not.toBeNull(); + 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 () => { @@ -644,7 +709,9 @@ describe('VirtualMessageList session boundary', () => { await act(async () => { await Promise.resolve(); }); - expect(onBoundaryIntent).toHaveBeenCalledWith('before'); + expect(onBoundaryIntent).toHaveBeenCalledWith('before', expect.objectContaining({ + prepareViewportForPresentationCommit: expect.any(Function), + })); scroller.scrollTop = 80; anchorDocumentTop = 60; @@ -2844,7 +2911,14 @@ describe('VirtualMessageList session boundary', () => { }); it('requests an adjacent Turn window for catalog-backed tail history', async () => { - const onHistoryWindowBoundaryIntent = vi.fn(async () => true); + 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', @@ -2899,7 +2973,9 @@ describe('VirtualMessageList session boundary', () => { await Promise.resolve(); }); - expect(onHistoryWindowBoundaryIntent).toHaveBeenCalledWith('before'); + expect(onHistoryWindowBoundaryIntent).toHaveBeenCalledWith('before', expect.objectContaining({ + prepareViewportForPresentationCommit: expect.any(Function), + })); expect(flowStoreMocks.revealPreviousSessionHistoryWindow).not.toHaveBeenCalled(); expect(flowStoreMocks.releaseSessionHistoryCompletionAfterInitialPaint).not.toHaveBeenCalled(); }); 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 889b41d1b9..74c85569c1 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -133,6 +133,7 @@ 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; +const HISTORY_PRESENTATION_COMMIT_SCROLL_QUIET_MS = 320; const IDLE_HISTORY_WINDOW_BOUNDARY_STATE: Record< SessionHistoryWindowDirection, 'idle' | 'loading' | 'error' @@ -259,11 +260,17 @@ export interface VirtualMessageListProps { historyBoundaryState?: Record; onHistoryWindowBoundaryIntent?: ( direction: SessionHistoryWindowDirection, + options?: HistoryWindowBoundaryIntentOptions, ) => boolean | void | Promise; onRequestJumpToLatest?: () => void; onUserScrollIntent?: () => void; } +export interface HistoryWindowBoundaryIntentOptions { + prepareViewportForPresentationCommit?: () => boolean | void | Promise; + cancelViewportPresentationCommit?: () => void; +} + interface PendingCollapseIntentState { active: boolean; anchorScrollTop: number; @@ -281,6 +288,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, @@ -510,13 +530,7 @@ const VirtualMessageListSession = forwardRef(null); - const pendingHistoryPrependAnchorRef = useRef<{ - turnId: string; - offsetFromScrollerTop: number; - beforeItemCount: number; - handoffRestored: boolean; - prependRestored: boolean; - } | null>(null); + const pendingHistoryPrependAnchorRef = useRef(null); const historyPagingActiveRef = useRef(false); const historyPagingRevealScheduledRef = useRef(false); const pendingHistoryPagingRevealRef = useRef<{ @@ -525,6 +539,11 @@ const VirtualMessageListSession = forwardRef(null); const historyPagingRetryTimerRef = useRef(null); const catalogHistoryWindowRequestRef = useRef | null>(null); + const lastUserScrollIntentAtMsRef = useRef(Number.NEGATIVE_INFINITY); + const historyPresentationCommitQuietGenerationRef = useRef(0); + const historyPresentationCommitQuietWaitersRef = useRef( + new Set(), + ); const pendingStaticTurnPinRef = useRef(null); const pendingStaticLatestScrollBehaviorRef = useRef<('auto' | 'smooth') | null>(null); const staticHistoryBottomReturnIntentUntilMsRef = useRef(0); @@ -753,7 +772,88 @@ 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 (flowChatDiagnostics.isEnabled()) { flowChatDiagnostics.trace({ hypothesis: 'A', @@ -780,7 +880,7 @@ const VirtualMessageListSession = forwardRef { const previousGeometry = previousScrollerGeometryRef.current; @@ -3282,7 +3382,8 @@ const VirtualMessageListSession = forwardRef { + 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, @@ -3308,44 +3437,25 @@ const VirtualMessageListSession = forwardRef; try { - boundaryIntentResult = onHistoryWindowBoundaryIntent(direction); + void Promise.resolve(onHistoryWindowBoundaryIntent(direction, { + prepareViewportForPresentationCommit, + cancelViewportPresentationCommit, + })).catch(() => {}); } catch { - if (anchorLease !== null) { - viewportCoordinatorRef.current.releaseElementPreservationLease( - anchorLease, - 'history-window-boundary-intent-threw', - ); - } return false; } - void Promise.resolve(boundaryIntentResult).then( - handled => { - if (handled === false && anchorLease !== null) { - viewportCoordinatorRef.current.releaseElementPreservationLease( - anchorLease, - 'history-window-boundary-not-handled', - ); - } - }, - () => { - if (anchorLease !== null) { - viewportCoordinatorRef.current.releaseElementPreservationLease( - anchorLease, - 'history-window-boundary-failed', - ); - } - }, - ); return true; }, [ captureHistoryPrependAnchor, + clearPreviousHistoryBoundaryStatus, historyBoundaryState, historyWindow, onHistoryWindowBoundaryIntent, presentationMode, presentationRevision, + virtualItems.length, + waitForHistoryPresentationCommitQuiet, ]); const revealPreviousHistoryWindowForUserIntent = useCallback((reason: string) => { @@ -3359,8 +3469,13 @@ const VirtualMessageListSession = forwardRef { + if ( + preparedAnchorState + && pendingHistoryPrependAnchorRef.current === preparedAnchorState + ) { + pendingHistoryPrependAnchorRef.current = null; + } + historyPagingActiveRef.current = false; + setHistoryPagingActive(false); + setHistoryPagingLoading(false); + setHistoryPagingAnchorTurnId(null); + clearPreviousHistoryBoundaryStatus(); + }; + 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 => { if (handled || activeSessionIdRef.current !== sessionId) { return handled; @@ -3435,6 +3568,21 @@ const VirtualMessageListSession = forwardRef { @@ -3598,6 +3747,7 @@ const VirtualMessageListSession = forwardRef { previousMeasuredHeightRef.current = null; previousScrollTopRef.current = 0; + cancelHistoryPresentationCommitQuietWaiters(); viewportCoordinatorRef.current.release('session-reset'); clearTurnPinRequest(); cancelLatestEndAnchorStabilization(); @@ -3630,6 +3780,7 @@ const VirtualMessageListSession = forwardRef { return () => { + cancelHistoryPresentationCommitQuietWaiters(); clearRetainedCollapseSettlement(); cancelLatestEndAnchorStabilization(); cancelStaticInitialHistoryBottomGuard(); @@ -3672,6 +3824,7 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX) { markStaticHistoryBottomReturnIntent(); @@ -4074,6 +4236,7 @@ const VirtualMessageListSession = forwardRef { touchScrollIntentStartYRef.current = null; + restartHistoryPresentationCommitQuietWaiters(); }; const handleKeyDown = (event: KeyboardEvent) => { @@ -4172,6 +4335,7 @@ const VirtualMessageListSession = forwardRef { scrollbarPointerInteractionActiveRef.current = false; + restartHistoryPresentationCommitQuietWaiters(); }; scrollerElement.addEventListener('wheel', handleWheel, { passive: true }); @@ -4404,6 +4568,7 @@ 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; @@ -5067,6 +5238,7 @@ 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') { From 14c2b6cf82e0e3c7850c317ade15880fb1e2eeb8 Mon Sep 17 00:00:00 2001 From: wsp Date: Sun, 2 Aug 2026 01:14:13 +0800 Subject: [PATCH 14/23] fix(flow-chat): unify history navigation on Virtuoso - separate cached history residency from viewport and follow-output state - reactivate LRU-cached history windows without redundant backend loads - normalize turn-rail navigation and preserve explicit jump-to-latest behavior - route initial history navigation and pagination through Virtuoso - replace the legacy static scroller with a non-interactive snapshot handoff - update scroll-stability documentation and regression coverage --- .../modern/FLOWCHAT_SCROLL_STABILITY.md | 83 +- ...rnFlowChatContainer.history-state.test.tsx | 230 +++- .../modern/ModernFlowChatContainer.tsx | 287 +++- .../modern/VirtualMessageList.layout.test.ts | 84 -- .../components/modern/VirtualMessageList.scss | 20 +- ...rtualMessageList.session-boundary.test.tsx | 175 ++- .../components/modern/VirtualMessageList.tsx | 1184 +++-------------- .../modern/virtualMessageListLayout.ts | 30 - .../src/flow_chat/store/FlowChatStore.test.ts | 13 + .../src/flow_chat/store/FlowChatStore.ts | 51 + 10 files changed, 856 insertions(+), 1301 deletions(-) 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 6565f84666..524fcef15a 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 @@ -273,9 +273,10 @@ 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 +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,33 +365,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 - -The initial-history path renders a bounded static window plus estimated leading -and trailing spacers before handing the current presentation to Virtuoso. A -catalog-backed partial session keeps only its restored tail as the default -presentation; it does not hydrate the complete transcript after first paint. -The left-side turn rail may target a turn outside that tail. The container first -loads and materializes a bounded Turn window around the target and then issues -the requested scroll. - -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: - -- 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. +## C. Initial-History Snapshot Handoff + +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: + +- 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 + +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 @@ -409,6 +404,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. @@ -433,6 +436,15 @@ 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 canonical tail, but it must not keep the UI in history-window mode, + suppress live-tail anchoring, or imply that follow-output is active. +- Explicit jump-to-latest clears the Store's `activeRange` and restores the + canonical tail viewport while retaining the most recent component + presentation 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 @@ -458,10 +470,13 @@ these ownership rules intact: 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 remain hidden while the user reads a history - window. An explicit `send-message` Turn-pin request first restores the tail - presentation, then lets the existing sticky-latest pin materialize the newly - submitted Turn. +- 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 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 75c9f41b24..071ae72909 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 @@ -1017,10 +1017,230 @@ describe('ModernFlowChatContainer historical empty state', () => { clickTurnRailItem(container, 'turn-99'); expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-99', { - behavior: 'smooth', + behavior: 'auto', + pinMode: 'transient', + }); + + }); + + 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', + }); + 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', }); + 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('retries turn-rail selection without advancing visible-turn state until the virtual list accepts it', async () => { @@ -1055,7 +1275,7 @@ describe('ModernFlowChatContainer historical empty state', () => { clickTurnRailItem(container, 'turn-1'); expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-1', { - behavior: 'smooth', + behavior: 'auto', pinMode: 'transient', }); expect(headerPropsMock.latest).toMatchObject({ @@ -1126,7 +1346,7 @@ describe('ModernFlowChatContainer historical empty state', () => { clickTurnRailItem(container, 'turn-1'); expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-1', { - behavior: 'smooth', + behavior: 'auto', pinMode: 'transient', }); expect(headerPropsMock.latest).toMatchObject({ @@ -1185,7 +1405,7 @@ describe('ModernFlowChatContainer historical empty state', () => { clickTurnRailItem(container, 'turn-1'); expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-1', { - behavior: 'smooth', + behavior: 'auto', pinMode: 'transient', }); const pendingCallCount = virtualListMock.pinTurnToTopWithStatus.mock.calls.length; @@ -1265,7 +1485,7 @@ describe('ModernFlowChatContainer historical empty state', () => { 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', }); 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 4b817e6627..c24bd91f54 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -130,6 +130,19 @@ interface FlowChatHistoryPresentationState extends SessionHistoryPresentation { 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; @@ -284,24 +297,60 @@ export const ModernFlowChatContainer: React.FC = ( const canonicalVirtualItems = useVirtualItems(); const activeSession = useActiveSession(); const [historyPresentation, setHistoryPresentation] = useState(null); + const [viewportIntent, setViewportIntent] = 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 isShowingHistoryPresentation = Boolean( + activeHistoryPresentation + && activeViewportIntent?.kind === 'turn' + && activeViewportIntent.source === 'history-range' + ); + const historyPresentationTurns = useMemo(() => { + if (!activeSession || !activeHistoryPresentation) { + return []; + } + + 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 ? turns : activeHistoryPresentation.turns; + }, [activeHistoryPresentation, activeSession]); const virtualItems = useMemo(() => { - if (!activeSession || historyPresentation?.sessionId !== activeSession.sessionId) { + if (!activeSession || !isShowingHistoryPresentation) { return canonicalVirtualItems; } return sessionToVirtualItems({ ...activeSession, - dialogTurns: historyPresentation.turns, + dialogTurns: historyPresentationTurns, }); - }, [activeSession, canonicalVirtualItems, historyPresentation]); + }, [activeSession, canonicalVirtualItems, historyPresentationTurns, isShowingHistoryPresentation]); const { requests: permissionRequests, activeBatch: activePermissionBatch, @@ -336,7 +385,7 @@ export const ModernFlowChatContainer: React.FC = ( const visibleTurnInfoRef = useRef(visibleTurnInfo); const turnSummariesRef = useRef([]); const turnRailTurnIdsRef = useRef>(new Set()); - const requestTurnPinRef = useRef<((turnId: string, behavior?: ScrollBehavior) => FlowChatTurnPinRequestStatus) | null>(null); + const requestTurnNavigationPinRef = useRef<((turnId: string) => FlowChatTurnPinRequestStatus) | null>(null); const searchFullHistorySessionIdRef = useRef(null); const virtualListRef = useRef(null); const chatScopeRef = useRef(null); @@ -407,23 +456,32 @@ export const ModernFlowChatContainer: React.FC = ( useFlowChatSync(); useFlowChatCopyDialog(); - const clearHistoryPresentationForSession = useCallback((sessionId: string) => { + const switchToLiveTailForSession = useCallback(( + sessionId: string, + options?: { discardRecentHistory?: boolean }, + ) => { historyPresentationOwnerGenerationRef.current += 1; flowChatStore.restoreSessionTailPresentation(sessionId); - historyPresentationRef.current = null; - setHistoryPresentation(null); + if (options?.discardRecentHistory) { + historyPresentationRef.current = null; + setHistoryPresentation(null); + } + updateViewportIntent({ kind: 'live-tail', sessionId }); setHistoryBoundaryState(IDLE_HISTORY_BOUNDARY_STATE); setQueuedTurnNavigation(null); - }, []); + }, [updateViewportIntent]); const handleBeforeTurnPinRequest = useCallback((request: FlowChatPinTurnToTopRequest) => { + const currentViewportIntent = viewportIntentRef.current; if ( request.source === 'send-message' - && historyPresentationRef.current?.sessionId === request.sessionId + && currentViewportIntent?.sessionId === request.sessionId + && currentViewportIntent.kind === 'turn' + && currentViewportIntent.source === 'history-range' ) { - clearHistoryPresentationForSession(request.sessionId); + switchToLiveTailForSession(request.sessionId); } - }, [clearHistoryPresentationForSession]); + }, [switchToLiveTailForSession]); useEffect(() => { historyPresentationRef.current = historyPresentation; @@ -434,12 +492,13 @@ export const ModernFlowChatContainer: React.FC = ( historyPresentationOwnerGenerationRef.current += 1; historyPresentationRef.current = null; setHistoryPresentation(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]); + }, [activeSession?.sessionId, updateViewportIntent]); useEffect(() => { const handleHistorySessionOpenIntent = (event: Event) => { @@ -627,12 +686,11 @@ export const ModernFlowChatContainer: React.FC = ( } return result; }, [activeSession?.dialogTurns]); - const activeHistoryPresentation = historyPresentation?.sessionId === activeSession?.sessionId - ? historyPresentation - : null; const renderedTurns = useMemo( - () => activeHistoryPresentation?.turns ?? activeSession?.dialogTurns ?? [], - [activeHistoryPresentation, activeSession?.dialogTurns], + () => isShowingHistoryPresentation + ? historyPresentationTurns + : activeSession?.dialogTurns ?? [], + [activeSession?.dialogTurns, historyPresentationTurns, isShowingHistoryPresentation], ); const renderedTurnSummaries = useMemo(() => { const result: FlowChatTurnSummary[] = []; @@ -659,7 +717,7 @@ export const ModernFlowChatContainer: React.FC = ( ? Math.max(0, sessionTotalTurnCount - turnSummaries.length) : 0; const absoluteRenderedTurnSummaries = useMemo(() => { - if (activeHistoryPresentation) { + if (isShowingHistoryPresentation && activeHistoryPresentation) { return renderedTurnSummaries.map((turn, index) => ({ ...turn, turnIndex: activeHistoryPresentation.range.startOrdinal + index + 1, @@ -678,6 +736,7 @@ export const ModernFlowChatContainer: React.FC = ( absoluteTurnIndexOffset, activeSession?.isPartial, activeHistoryPresentation, + isShowingHistoryPresentation, renderedTurnSummaries, ]); const absoluteRenderedTurnSummaryById = useMemo(() => { @@ -928,21 +987,15 @@ export const ModernFlowChatContainer: React.FC = ( return effectiveVisibleTurnInfo?.userMessage ?? ''; }, [effectiveVisibleTurnInfo?.turnId, effectiveVisibleTurnInfo?.userMessage, renderedTurns, 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'; - + const requestTurnNavigationPin = useCallback((turnId: string): FlowChatTurnPinRequestStatus => { return virtualListRef.current?.pinTurnToTopWithStatus(turnId, { - behavior, - pinMode, + behavior: 'auto', + pinMode: 'transient', }) ?? 'rejected'; - }, [activeSession?.dialogTurns, turnSummaries]); + }, []); useEffect(() => { - requestTurnPinRef.current = requestTurnPin; - }, [requestTurnPin]); + requestTurnNavigationPinRef.current = requestTurnNavigationPin; + }, [requestTurnNavigationPin]); const handleVirtualListUserScrollIntent = useCallback(() => { setQueuedTurnNavigation(null); }, []); @@ -986,7 +1039,7 @@ export const ModernFlowChatContainer: React.FC = ( return; } - const pinStatus = requestTurnPinRef.current?.(queuedTurnId, 'auto') ?? 'rejected'; + const pinStatus = requestTurnNavigationPinRef.current?.(queuedTurnId) ?? 'rejected'; if (pinStatus === 'settled' || pinStatus === 'pending') { setQueuedTurnNavigation(null); return; @@ -1033,7 +1086,7 @@ export const ModernFlowChatContainer: React.FC = ( : null; if ( !sessionId - || historyPresentationRef.current?.sessionId === sessionId + || isShowingHistoryPresentation || !latestTurnId || autoPinnedTurnKeyRef.current === latestTurnKey ) { @@ -1180,6 +1233,7 @@ export const ModernFlowChatContainer: React.FC = ( activeSession?.remoteConnectionId, activeSession?.remoteSshHost, hasPendingHistoryCompletion, + isShowingHistoryPresentation, latestTurnId, latestTurnUsesFollowOutput, latestTurnUsesStickyPin, @@ -1305,7 +1359,13 @@ export const ModernFlowChatContainer: React.FC = ( const applyHistoryPresentation = useCallback(( sessionId: string, presentation: SessionHistoryPresentation, - options?: { completedBoundary?: SessionHistoryWindowDirection }, + options?: { + completedBoundary?: SessionHistoryWindowDirection; + viewportTarget?: { + ordinal: number; + turnId: string | null; + }; + }, ) => { historyPresentationOwnerGenerationRef.current += 1; setHistoryPresentation(previous => { @@ -1317,6 +1377,15 @@ export const ModernFlowChatContainer: React.FC = ( 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 => ({ @@ -1326,15 +1395,20 @@ export const ModernFlowChatContainer: React.FC = ( } else { setHistoryBoundaryState(IDLE_HISTORY_BOUNDARY_STATE); } - }, []); + }, [updateViewportIntent]); - const restoreTailPresentation = useCallback((options?: { followLatest?: boolean }) => { + const restoreTailPresentation = useCallback((options?: { + followLatest?: boolean; + discardRecentHistory?: boolean; + }) => { const sessionId = activeSession?.sessionId; if (!sessionId) { return false; } - clearHistoryPresentationForSession(sessionId); + switchToLiveTailForSession(sessionId, { + discardRecentHistory: options?.discardRecentHistory, + }); if (options?.followLatest) { requestAnimationFrame(() => { @@ -1344,7 +1418,11 @@ export const ModernFlowChatContainer: React.FC = ( }); } return true; - }, [activeSession?.sessionId, clearHistoryPresentationForSession]); + }, [activeSession?.sessionId, switchToLiveTailForSession]); + + const jumpToLiveTail = useCallback(() => { + return restoreTailPresentation({ followLatest: true }); + }, [restoreTailPresentation]); const handleSearchChange = useCallback((query: string) => { setSearchQuery(query); @@ -1361,37 +1439,34 @@ export const ModernFlowChatContainer: React.FC = ( searchFullHistorySessionIdRef.current = sessionId; void flowChatStore.ensureSessionFullHistory(sessionId, 'flowchat-search').then(ready => { if (ready && activeSessionIdRef.current === sessionId) { - restoreTailPresentation(); + restoreTailPresentation({ discardRecentHistory: true }); } else if (activeSessionIdRef.current === sessionId) { searchFullHistorySessionIdRef.current = null; } }); }, [activeSession?.isPartial, activeSession?.sessionId, restoreTailPresentation, setSearchQuery]); - const handleJumpToTurn = useCallback(async (target: FlowChatTurnRailItem | string) => { + 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 canonicalLatestTurnId = turnSummaries[turnSummaries.length - 1]?.turnId ?? null; - const isLatestOrdinal = targetItem.ordinal === Math.max(0, sessionTotalTurnCount - 1); - if ( - isLatestOrdinal - && canonicalLatestTurnId - && historyPresentationRef.current?.sessionId === sessionId - ) { - return restoreTailPresentation({ followLatest: true }); - } - const renderedTargetId = targetItem.turnId; const targetIsRendered = Boolean( renderedTargetId && renderedTurnSummaries.some(turn => turn.turnId === renderedTargetId), ); if (renderedTargetId && targetIsRendered) { - const pinStatus = requestTurnPin(renderedTargetId); + updateViewportIntent({ + kind: 'turn', + sessionId, + ordinal: targetItem.ordinal, + turnId: renderedTargetId, + source: isShowingHistoryPresentation ? 'history-range' : 'canonical-tail', + }); + const pinStatus = requestTurnNavigationPin(renderedTargetId); if (pinStatus === 'settled' || pinStatus === 'pending') { setQueuedTurnNavigation(null); return true; @@ -1403,6 +1478,45 @@ export const ModernFlowChatContainer: React.FC = ( return true; } + 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', + }) ?? '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, { @@ -1441,7 +1555,12 @@ export const ModernFlowChatContainer: React.FC = ( return false; } - applyHistoryPresentation(sessionId, presentation); + applyHistoryPresentation(sessionId, presentation, { + viewportTarget: { + ordinal: result.targetOrdinal, + turnId: targetTurnId, + }, + }); setQueuedTurnNavigation(null); return true; } @@ -1452,7 +1571,14 @@ export const ModernFlowChatContainer: React.FC = ( 'turn-rail-navigation', ); if (historyReady && activeSessionIdRef.current === sessionId) { - restoreTailPresentation(); + restoreTailPresentation({ discardRecentHistory: true }); + updateViewportIntent({ + kind: 'turn', + sessionId, + ordinal: targetItem.ordinal, + turnId: targetItem.turnId, + source: 'canonical-tail', + }); setQueuedTurnNavigation({ ordinal: targetItem.ordinal, turnId: targetItem.turnId, @@ -1465,12 +1591,12 @@ export const ModernFlowChatContainer: React.FC = ( }, [ activeSession?.sessionId, applyHistoryPresentation, + isShowingHistoryPresentation, renderedTurnSummaries, - requestTurnPin, + requestTurnNavigationPin, restoreTailPresentation, - sessionTotalTurnCount, turnRailItems, - turnSummaries, + updateViewportIntent, ]); const handleNavigateToFocusTurn = useCallback(async (request: FlowChatFocusItemRequest) => { @@ -1484,7 +1610,7 @@ export const ModernFlowChatContainer: React.FC = ( ? turnRailItems.find(turn => turn.turnId === requestedTurnId) : undefined; if (targetById) { - return handleJumpToTurn(targetById); + return navigateToTurn(targetById); } if (requestedTurnId) { @@ -1503,7 +1629,14 @@ export const ModernFlowChatContainer: React.FC = ( return false; } - restoreTailPresentation(); + restoreTailPresentation({ discardRecentHistory: true }); + updateViewportIntent({ + kind: 'turn', + sessionId, + ordinal: hydratedTurnIndex, + turnId: requestedTurnId, + source: 'canonical-tail', + }); setQueuedTurnNavigation({ ordinal: hydratedTurnIndex, turnId: requestedTurnId, @@ -1518,12 +1651,13 @@ export const ModernFlowChatContainer: React.FC = ( return false; } const targetByOrdinal = turnRailItems.find(turn => turn.ordinal === requestedOrdinal); - return targetByOrdinal ? handleJumpToTurn(targetByOrdinal) : false; + return targetByOrdinal ? navigateToTurn(targetByOrdinal) : false; }, [ activeSession?.sessionId, - handleJumpToTurn, + navigateToTurn, restoreTailPresentation, turnRailItems, + updateViewportIntent, ]); useFlowChatNavigation({ @@ -1555,7 +1689,11 @@ export const ModernFlowChatContainer: React.FC = ( } const request = (async () => { - const presentation = historyPresentationRef.current; + 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)) { @@ -1650,7 +1788,17 @@ export const ModernFlowChatContainer: React.FC = ( setHistoryBoundaryState(previous => ({ ...previous, [direction]: 'error' })); return false; } - applyHistoryPresentation(sessionId, nextPresentation, { completedBoundary: direction }); + applyHistoryPresentation(sessionId, nextPresentation, { + completedBoundary: direction, + ...(!presentation ? { + viewportTarget: { + ordinal: targetOrdinal, + turnId: nextPresentation.turns[ + targetOrdinal - nextPresentation.range.startOrdinal + ]?.id ?? null, + }, + } : {}), + }); return true; } catch (error) { if (viewportPreparationStarted) { @@ -2069,7 +2217,7 @@ export const ModernFlowChatContainer: React.FC = ( sessionId={activeSession?.sessionId} onJumpToCurrentTurn={() => { const turnId = effectiveVisibleTurnInfo?.turnId; - if (turnId) handleJumpToTurn(turnId); + if (turnId) navigateToTurn(turnId); }} searchQuery={searchQuery} onSearchChange={handleSearchChange} @@ -2123,7 +2271,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={historyPresentation ? 'history-window' : 'tail'} + data-presentation-mode={isShowingHistoryPresentation ? 'history-window' : 'tail'} + data-viewport-intent={activeViewportIntent?.kind ?? 'live-tail'} data-latest-turn-id={latestTurnId ?? ''} data-history-initial-content-ready={ historyInitialContentKey === null || historyInitialContentReadyKey === historyInitialContentKey @@ -2162,14 +2311,12 @@ export const ModernFlowChatContainer: React.FC = ( { - restoreTailPresentation({ followLatest: true }); - }} + onRequestJumpToLatest={jumpToLiveTail} onUserScrollIntent={handleVirtualListUserScrollIntent} /> @@ -2179,7 +2326,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/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 d0e3ed6699..406b53277a 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss @@ -118,26 +118,8 @@ overflow-anchor: none; } - &__static-scroller { + &__projection-handoff-items { width: 100%; - height: 100%; - position: relative; - z-index: 1; - overflow-y: auto; - overflow-x: hidden; - overflow-anchor: none; - scrollbar-gutter: stable; - } - - &__static-items { - width: 100%; - } - - &__initial-history-spacer { - width: 100%; - flex: 0 0 auto; - pointer-events: none; - overflow-anchor: none; } &__projection-handoff-overlay { 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 6608051419..439dc6c59f 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 @@ -1710,7 +1710,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); @@ -1957,7 +1957,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'; @@ -1970,11 +1970,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(() => { @@ -1982,19 +1985,65 @@ 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 let a canceled pending sticky pin RAF restore provisional footer space', () => { @@ -2236,7 +2285,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'; @@ -2252,17 +2301,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({ @@ -2270,22 +2329,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(); @@ -2355,7 +2410,7 @@ describe('VirtualMessageList session boundary', () => { root.render(); }); - expect(scroller.scrollTop).toBe(4_200); + expect(scroller.scrollTop).toBe(4_000); let didPin = false; act(() => { @@ -2364,7 +2419,7 @@ describe('VirtualMessageList session boundary', () => { expect(didPin).toBe(true); const pinnedScrollTop = scroller.scrollTop; - expect(pinnedScrollTop).toBeLessThan(4_200); + expect(pinnedScrollTop).toBe(4_000); expect(rafCallbacks.length).toBeGreaterThan(0); for (let frame = 0; frame < 4; frame += 1) { @@ -2377,7 +2432,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}`); @@ -2394,6 +2449,7 @@ describe('VirtualMessageList session boundary', () => { createItem(turnId), createModelItem(turnId), ]); + virtuosoMocks.renderedRange = { start: 12, end: 16 }; act(() => { root.render(); @@ -2438,11 +2494,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( @@ -2451,14 +2507,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, { @@ -2471,6 +2526,7 @@ describe('VirtualMessageList session boundary', () => { createItem(turnId), createModelItem(turnId), ]); + virtuosoMocks.renderedRange = { start: 12, end: 16 }; act(() => { root.render(); @@ -2512,7 +2568,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(); @@ -2531,7 +2591,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. @@ -2541,11 +2600,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); @@ -2607,7 +2666,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', @@ -2668,8 +2727,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', () => { @@ -2736,7 +2794,7 @@ describe('VirtualMessageList session boundary', () => { 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'], { @@ -2756,22 +2814,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(); @@ -2800,12 +2856,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, @@ -2825,18 +2880,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(() => { @@ -2846,17 +2898,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', () => { 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 74c85569c1..1dc2ae9a53 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -50,9 +50,6 @@ import { estimateVirtualMessageItemHeight, getLeadingVirtualItemIndexDelta, getVirtualMessageDefaultItemHeight, - INITIAL_HISTORY_RENDER_MIN_ESTIMATED_HEIGHT_PX, - INITIAL_HISTORY_RENDER_MIN_TURN_COUNT, - mapInitialHistoryExpansionScrollTop, selectInitialHistoryRenderWindow, } from './virtualMessageListLayout'; import { @@ -111,17 +108,14 @@ 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 SESSION_OPEN_HANDOFF_ITEM_BUDGET = 24; @@ -139,15 +133,6 @@ const IDLE_HISTORY_WINDOW_BOUNDARY_STATE: Record< 'idle' | 'loading' | 'error' > = { before: 'idle', after: 'idle' }; -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, - ); -} - type FlowChatVirtuosoContext = { footerRef: React.RefCallback; previousHistoryBoundaryStatusNode: React.ReactNode; @@ -342,7 +327,7 @@ interface PendingTurnPinState { attempts: number; } -interface PendingStaticTurnPinState { +interface PreparedHistoryTurnPinState { turnId: string; behavior: ScrollBehavior; pinMode: FlowChatPinTurnToTopMode; @@ -453,6 +438,14 @@ const VirtualMessageListSession = forwardRef(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; @@ -507,6 +499,7 @@ const VirtualMessageListSession = forwardRef(null); 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); @@ -521,22 +514,9 @@ 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 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 catalogHistoryWindowRequestRef = useRef | null>(null); const lastUserScrollIntentAtMsRef = useRef(Number.NEGATIVE_INFINITY); @@ -544,10 +524,7 @@ const VirtualMessageListSession = forwardRef(), ); - const pendingStaticTurnPinRef = useRef(null); - const pendingStaticLatestScrollBehaviorRef = useRef<('auto' | 'smooth') | null>(null); - const staticHistoryBottomReturnIntentUntilMsRef = useRef(0); - const initialHistoryRenderWindowCheckFrameRef = useRef(null); + const preparedHistoryTurnPinRef = useRef(null); const measureFrameRef = useRef(null); const visibleTurnMeasureFrameRef = useRef(null); const pinReservationReconcileFrameRef = useRef(null); @@ -556,8 +533,6 @@ const VirtualMessageListSession = forwardRef(null); const latestEndAnchorStabilizationFrameRef = useRef(null); const searchNavigationRequestIdRef = useRef(0); - const staticInitialHistoryBottomGuardFrameRef = useRef(null); - const staticInitialHistoryBottomGuardUntilMsRef = useRef(0); const latestEndAnchorRequestRef = useRef(null); const resolveLatestEndAnchorStabilizationRef = useRef<((reason: LatestEndAnchorResolveReason) => boolean) | null>(null); const resizeObserverRef = useRef(null); @@ -705,59 +680,6 @@ const VirtualMessageListSession = forwardRef { - 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]); - const recordScrollerGeometryIfLayoutStable = useCallback((scroller: HTMLElement) => { const previousGeometry = previousScrollerGeometryRef.current; if ( @@ -854,6 +776,9 @@ const VirtualMessageListSession = forwardRef { lastUserScrollIntentAtMsRef.current = performance.now(); restartHistoryPresentationCommitQuietWaiters(); + if (historyProjectionHandoffRef.current) { + clearHistoryProjectionHandoffRef.current(reason); + } if (flowChatDiagnostics.isEnabled()) { flowChatDiagnostics.trace({ hypothesis: 'A', @@ -880,7 +805,11 @@ const VirtualMessageListSession = forwardRef { const previousGeometry = previousScrollerGeometryRef.current; @@ -929,7 +858,6 @@ 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, - ]); - const updateBottomReservationState = useCallback(( updater: BottomReservationState | ((prev: BottomReservationState) => BottomReservationState), ) => { @@ -1052,6 +897,7 @@ const VirtualMessageListSession = forwardRef { turnPinRequestGenerationRef.current += 1; activeTurnPinRequestRef.current = null; + preparedHistoryTurnPinRef.current = null; transientTurnPinStabilizationRef.current = null; if (turnPinStabilizationFrameRef.current !== null) { @@ -2036,16 +1882,6 @@ const VirtualMessageListSession = forwardRef(latestTurnId); const previousSessionIdForFollowRef = useRef(activeSession?.sessionId); @@ -2312,6 +2148,7 @@ const VirtualMessageListSession = forwardRef { if (historyProjectionHandoffReleaseFrameRef.current !== null) { @@ -2355,6 +2192,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, @@ -2461,7 +2314,7 @@ const VirtualMessageListSession = forwardRef { - const scroller = scrollerElementRef.current; - const targetElement = getRenderedUserMessageElement(turnId); - if (!scroller || !targetElement) { - return false; - } - - 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; - } - - if (scrollStaticTurnToTop(pending.turnId, pending.behavior)) { - pendingStaticTurnPinRef.current = null; - } - }, [ - scrollStaticTurnToTop, - staticAnchorWindowTurnId, - virtualItems, - ]); - const tryResolvePendingTurnPin = useCallback((request: PendingTurnPinState) => { if (!isTurnPinRequestCurrent(request)) { return false; @@ -3489,14 +3298,6 @@ 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, - ]); - const shouldRevealPreviousHistoryWindowForUserIntent = useCallback((options?: { force?: boolean }) => { if (options?.force === true) { return true; @@ -3751,18 +3514,14 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX) { - markStaticHistoryBottomReturnIntent(); - } else if (intentCheckScrollDelta < -COMPENSATION_EPSILON_PX) { - clearStaticHistoryBottomReturnIntent(); - } - } if ( flowChatDiagnostics.isEnabled() && Math.abs(intentCheckScrollDelta) > COMPENSATION_EPSILON_PX @@ -4138,7 +3885,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'); @@ -4213,17 +3956,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'); @@ -4257,13 +3997,10 @@ const VirtualMessageListSession = forwardRef { const pendingTurnId = pendingTurnPin?.turnId ?? - pendingStaticTurnPinRef.current?.turnId ?? - staticAnchorWindowTurnId; + preparedHistoryTurnPinRef.current?.turnId; if (pendingTurnId) { const pendingTargetIndex = virtualItems.findIndex(item => ( item.turnId === pendingTurnId && item.type === 'user-message' @@ -5188,7 +4917,6 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX) { scroller.scrollTo({ top: effectiveBottomTop, behavior: 'auto' }); - recordStaticInitialHistoryBottomState(scroller); } setIsAtBottom(true); return; @@ -5506,28 +5227,11 @@ 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 => { @@ -5546,38 +5250,14 @@ const VirtualMessageListSession = forwardRef { - if (useStaticInitialHistoryList || !virtuosoRef.current || pendingTurnPin) { + if (!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, }); }, [ pendingTurnPin, - releaseStaticHistoryAnchorWindow, requestTurnPinToTop, scrollerElement, - useStaticInitialHistoryList, + userMessageItems, virtualItems.length, ]); @@ -6027,6 +5702,7 @@ const VirtualMessageListSession = forwardRef { if (!virtuosoRef.current) return; @@ -6046,13 +5727,22 @@ const VirtualMessageListSession = forwardRef { searchNavigationRequestIdRef.current += 1; @@ -6082,7 +5772,10 @@ const VirtualMessageListSession = forwardRef ( - currentTurnId === targetTurnId ? currentTurnId : targetTurnId - )); - } if (attempts < SEARCH_NAVIGATION_MAX_ATTEMPTS) { requestAnimationFrame(resolveExactTextPosition); } @@ -6261,11 +5942,11 @@ const VirtualMessageListSession = forwardRef { @@ -6345,19 +6025,24 @@ const VirtualMessageListSession = forwardRef { const scroller = scrollerElementRef.current; if (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, + recordScrollerGeometry, + snapshotMeasuredContentHeight, + ]); const scrollToTurnEndAndClearPin = useCallback((turnId: string) => { const scroller = scrollerElementRef.current; @@ -6366,7 +6051,6 @@ 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 = { @@ -6518,23 +6112,28 @@ 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 (presentationMode === 'history-window' && onRequestJumpToLatest) { @@ -6697,324 +6296,74 @@ 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 = ( @@ -7027,7 +6376,6 @@ const VirtualMessageListSession = forwardRef { @@ -7096,7 +6443,8 @@ const VirtualMessageListSession = forwardRef= 6 && virtualItems .slice(-16) .every(item => @@ -7115,116 +6463,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 @@ -7329,108 +6572,55 @@ const VirtualMessageListSession = forwardRef - {useStaticInitialHistoryList ? ( -
-
- {previousHistoryWindowSentinelNode} - {historyPagingSentinelNode} - {previousHistoryBoundaryStatusNode} - {omittedInitialHistoryEstimatedHeightPx > 0 ? ( -