From d3eec23d6a95dafa405d583895f4daf40f73a044 Mon Sep 17 00:00:00 2001 From: limityan Date: Sat, 30 May 2026 11:29:06 +0800 Subject: [PATCH] perf(flow-chat): render recent history before full restore Load the latest restored turns first, hydrate older history in the background, and keep large completed model rounds anchored on their newest groups. Also adds a static preload hint for debug WebView cold start and fixes duplicate todo keys in restored plan displays. --- src/apps/desktop/src/api/agentic_api.rs | 44 +++- .../src/agentic/coordination/coordinator.rs | 22 ++ .../core/src/agentic/persistence/manager.rs | 202 +++++++++++++++--- .../src/agentic/session/session_manager.rs | 53 ++++- src/web-ui/index.html | 41 +++- .../components/modern/ModelRoundItem.tsx | 31 ++- .../modelRoundProgressiveRender.test.ts | 37 ++++ .../modern/modelRoundProgressiveRender.ts | 22 ++ .../src/flow_chat/store/FlowChatStore.test.ts | 115 ++++++++++ .../src/flow_chat/store/FlowChatStore.ts | 181 ++++++++++++++++ .../tool-cards/CreatePlanDisplay.tsx | 9 +- .../api/service-api/AgentAPI.ts | 5 + 12 files changed, 713 insertions(+), 49 deletions(-) diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index a4bfb841f..51d866ae8 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -224,6 +224,9 @@ pub struct RestoreSessionViewResponse { pub session: SessionResponse, pub turns: Vec, pub context_restore_state: String, + pub is_partial: bool, + pub loaded_turn_count: usize, + pub total_turn_count: usize, } #[derive(Debug, Default)] @@ -518,6 +521,8 @@ pub struct RestoreSessionRequest { pub include_internal: bool, #[serde(default)] pub trace_id: Option, + #[serde(default)] + pub tail_turn_count: Option, } #[derive(Debug, Deserialize)] @@ -1321,16 +1326,42 @@ pub async fn restore_session_view( path_started_at.elapsed().as_millis() ); - let (session, mut turns) = if request.include_internal { + let tail_turn_count = request.tail_turn_count.filter(|count| *count > 0); + let (session, mut turns, total_turn_count) = if let Some(tail_turn_count) = tail_turn_count { + let tail_turn_count = tail_turn_count.min(16); + if request.include_internal { + coordinator + .restore_internal_session_view_tail( + &effective_path, + &request.session_id, + tail_turn_count, + ) + .await + } else { + coordinator + .restore_session_view_tail(&effective_path, &request.session_id, tail_turn_count) + .await + } + } else if request.include_internal { coordinator .restore_internal_session_view(&effective_path, &request.session_id) .await + .map(|(session, turns)| { + let total_turn_count = turns.len(); + (session, turns, total_turn_count) + }) } else { coordinator .restore_session_view(&effective_path, &request.session_id) .await + .map(|(session, turns)| { + let total_turn_count = turns.len(); + (session, turns, total_turn_count) + }) } .map_err(|e| format!("Failed to restore session view: {}", e))?; + let loaded_turn_count = turns.len(); + let is_partial = loaded_turn_count < total_turn_count; if log::log_enabled!(log::Level::Debug) { let payload_stats = restore_turn_payload_stats(&turns); @@ -1338,10 +1369,12 @@ pub async fn restore_session_view( || payload_stats.result_for_assistant_chars >= 1024 * 1024 { debug!( - "restore_session_view payload diagnostics: trace_id={}, session_id={}, turn_count={}, tool_result_count={}, raw_result_string_chars={}, result_for_assistant_chars={}, largest_raw_result_chars={}, largest_raw_result_path={}, top_raw_results={}", + "restore_session_view payload diagnostics: trace_id={}, session_id={}, turn_count={}, total_turn_count={}, is_partial={}, tool_result_count={}, raw_result_string_chars={}, result_for_assistant_chars={}, largest_raw_result_chars={}, largest_raw_result_path={}, top_raw_results={}", trace_id, request.session_id, turns.len(), + total_turn_count, + is_partial, payload_stats.tool_result_count, payload_stats.raw_result_string_chars, payload_stats.result_for_assistant_chars, @@ -1355,10 +1388,12 @@ 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={}, context_restore_state=pending, duration_ms={}", + "restore_session_view completed: trace_id={}, session_id={}, turn_count={}, total_turn_count={}, is_partial={}, context_restore_state=pending, duration_ms={}", trace_id, request.session_id, turns.len(), + total_turn_count, + is_partial, started_at.elapsed().as_millis() ); @@ -1366,6 +1401,9 @@ pub async fn restore_session_view( session: session_to_response(session), turns, context_restore_state: "pending".to_string(), + is_partial, + loaded_turn_count, + total_turn_count, }) } diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index f39b75b1d..825e39d62 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -3240,6 +3240,17 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await } + pub async fn restore_session_view_tail( + &self, + workspace_path: &Path, + session_id: &str, + tail_turn_count: usize, + ) -> BitFunResult<(Session, Vec, usize)> { + self.session_manager + .restore_session_view_tail(workspace_path, session_id, tail_turn_count) + .await + } + pub async fn restore_internal_session_view( &self, workspace_path: &Path, @@ -3250,6 +3261,17 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await } + pub async fn restore_internal_session_view_tail( + &self, + workspace_path: &Path, + session_id: &str, + tail_turn_count: usize, + ) -> BitFunResult<(Session, Vec, usize)> { + self.session_manager + .restore_internal_session_view_tail(workspace_path, session_id, tail_turn_count) + .await + } + /// List all sessions pub async fn list_sessions(&self, workspace_path: &Path) -> BitFunResult> { self.session_manager.list_sessions(workspace_path).await diff --git a/src/crates/core/src/agentic/persistence/manager.rs b/src/crates/core/src/agentic/persistence/manager.rs index 5973f950f..270a14d97 100644 --- a/src/crates/core/src/agentic/persistence/manager.rs +++ b/src/crates/core/src/agentic/persistence/manager.rs @@ -2186,23 +2186,11 @@ impl PersistenceManager { Ok(session) } - /// Load session and return the persisted turns read while rebuilding the session header. - pub async fn load_session_with_turns( - &self, - workspace_path: &Path, - session_id: &str, - ) -> BitFunResult<(Session, Vec)> { - let metadata = self - .load_session_metadata(workspace_path, session_id) - .await? - .ok_or_else(|| { - BitFunError::NotFound(format!("Session metadata not found: {}", session_id)) - })?; - let stored_state = self - .load_stored_session_state(workspace_path, session_id) - .await?; - let turns = self.load_session_turns(workspace_path, session_id).await?; - + fn build_session_from_persisted_parts( + metadata: SessionMetadata, + stored_state: Option, + turns: &[DialogTurnData], + ) -> Session { let mut config = stored_state .as_ref() .map(|value| value.config.clone()) @@ -2230,9 +2218,9 @@ impl PersistenceManager { .unwrap_or(SessionState::Idle); let created_at = Self::unix_ms_to_system_time(metadata.created_at); let last_activity_at = Self::unix_ms_to_system_time(metadata.last_active_at); - let dialog_turn_ids = turns.iter().map(|turn| turn.turn_id.clone()).collect(); - let session = Session { + + Session { session_id: metadata.session_id.clone(), session_name: metadata.session_name.clone(), agent_type: metadata.agent_type.clone(), @@ -2247,7 +2235,8 @@ impl PersistenceManager { created_by: metadata.created_by.clone(), kind: metadata.session_kind, snapshot_session_id: stored_state - .and_then(|value| value.snapshot_session_id) + .as_ref() + .and_then(|value| value.snapshot_session_id.clone()) .or(metadata.snapshot_session_id.clone()), dialog_turn_ids, state: runtime_state, @@ -2256,11 +2245,57 @@ impl PersistenceManager { created_at, updated_at: last_activity_at, last_activity_at, - }; + } + } + + /// Load session and return the persisted turns read while rebuilding the session header. + pub async fn load_session_with_turns( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<(Session, Vec)> { + let metadata = self + .load_session_metadata(workspace_path, session_id) + .await? + .ok_or_else(|| { + BitFunError::NotFound(format!("Session metadata not found: {}", session_id)) + })?; + let stored_state = self + .load_stored_session_state(workspace_path, session_id) + .await?; + let turns = self.load_session_turns(workspace_path, session_id).await?; + let session = Self::build_session_from_persisted_parts(metadata, stored_state, &turns); Ok((session, turns)) } + pub async fn load_session_with_tail_turns( + &self, + workspace_path: &Path, + session_id: &str, + tail_turn_count: usize, + ) -> BitFunResult<(Session, Vec, usize)> { + let metadata = self + .load_session_metadata(workspace_path, session_id) + .await? + .ok_or_else(|| { + BitFunError::NotFound(format!("Session metadata not found: {}", session_id)) + })?; + let stored_state = self + .load_stored_session_state(workspace_path, session_id) + .await?; + let indexed_paths = self + .list_indexed_turn_paths(workspace_path, session_id) + .await?; + let total_turn_count = indexed_paths.len(); + let start = indexed_paths.len().saturating_sub(tail_turn_count); + let selected_paths = indexed_paths.into_iter().skip(start).collect::>(); + let turns = self.read_turn_paths(selected_paths).await?; + let session = Self::build_session_from_persisted_parts(metadata, stored_state, &turns); + + Ok((session, turns, total_turn_count)) + } + /// Save session state pub async fn save_session_state( &self, @@ -2527,18 +2562,16 @@ impl PersistenceManager { .map(|file| file.turn)) } - pub async fn load_session_turns( + async fn list_indexed_turn_paths( &self, workspace_path: &Path, session_id: &str, - ) -> BitFunResult> { - let started_at = Instant::now(); + ) -> BitFunResult> { let turns_dir = self.turns_dir(workspace_path, session_id); if !turns_dir.exists() { return Ok(Vec::new()); } - let scan_started_at = Instant::now(); let mut indexed_paths = Vec::new(); let mut entries = fs::read_dir(&turns_dir) .await @@ -2566,11 +2599,14 @@ impl PersistenceManager { } indexed_paths.sort_by_key(|(index, _)| *index); - let scan_duration = scan_started_at.elapsed(); + Ok(indexed_paths) + } - let read_started_at = Instant::now(); + async fn read_turn_paths( + &self, + indexed_paths: Vec<(usize, PathBuf)>, + ) -> BitFunResult> { let mut turns = Vec::with_capacity(indexed_paths.len()); - let turn_file_count = indexed_paths.len(); for (_, path) in indexed_paths { if let Some(file) = self .read_json_optional::(&path) @@ -2579,6 +2615,24 @@ impl PersistenceManager { turns.push(file.turn); } } + Ok(turns) + } + + pub async fn load_session_turns( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult> { + let started_at = Instant::now(); + let scan_started_at = Instant::now(); + let indexed_paths = self + .list_indexed_turn_paths(workspace_path, session_id) + .await?; + let scan_duration = scan_started_at.elapsed(); + + let read_started_at = Instant::now(); + let turn_file_count = indexed_paths.len(); + let turns = self.read_turn_paths(indexed_paths).await?; let read_duration = read_started_at.elapsed(); let total_duration = started_at.elapsed(); if total_duration >= Duration::from_millis(80) || turn_file_count >= 50 { @@ -2596,6 +2650,46 @@ impl PersistenceManager { Ok(turns) } + pub async fn load_session_tail_turns( + &self, + workspace_path: &Path, + session_id: &str, + count: usize, + ) -> BitFunResult> { + if count == 0 { + return Ok(Vec::new()); + } + + let started_at = Instant::now(); + let scan_started_at = Instant::now(); + let indexed_paths = self + .list_indexed_turn_paths(workspace_path, session_id) + .await?; + let scan_duration = scan_started_at.elapsed(); + let turn_file_count = indexed_paths.len(); + let start = indexed_paths.len().saturating_sub(count); + let selected_paths = indexed_paths.into_iter().skip(start).collect::>(); + + let read_started_at = Instant::now(); + let turns = self.read_turn_paths(selected_paths).await?; + let read_duration = read_started_at.elapsed(); + let total_duration = started_at.elapsed(); + if total_duration >= Duration::from_millis(40) || turn_file_count >= 50 { + debug!( + "Loaded session tail turns: session_id={} turn_count={} requested_count={} turn_file_count={} scan_duration_ms={} read_duration_ms={} total_duration_ms={}", + session_id, + turns.len(), + count, + turn_file_count, + scan_duration.as_millis(), + read_duration.as_millis(), + total_duration.as_millis() + ); + } + + Ok(turns) + } + pub async fn delete_dialog_turns_from( &self, workspace_path: &Path, @@ -3088,6 +3182,58 @@ mod tests { assert!(transcript.contains("hello transcript")); } + #[tokio::test] + async fn load_session_tail_turns_returns_latest_turns_in_chronological_order() { + let workspace = TestWorkspace::new(); + let manager = + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"); + let session_id = Uuid::new_v4().to_string(); + let metadata = SessionMetadata::new( + session_id.clone(), + "Tail turns test".to_string(), + "agent".to_string(), + "model".to_string(), + ); + manager + .save_session_metadata(workspace.path(), &metadata) + .await + .expect("metadata should save"); + + for index in 0..5 { + let user_message = UserMessageData { + id: format!("user-{index}"), + content: format!("prompt {index}"), + timestamp: index as u64, + metadata: None, + }; + let mut turn = DialogTurnData::new( + format!("turn-{index}"), + index, + session_id.clone(), + user_message, + ); + turn.mark_completed(); + manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + } + + let tail = manager + .load_session_tail_turns(workspace.path(), &session_id, 2) + .await + .expect("tail turns should load"); + + let turn_indices = tail.iter().map(|turn| turn.turn_index).collect::>(); + let prompts = tail + .iter() + .map(|turn| turn.user_message.content.as_str()) + .collect::>(); + + assert_eq!(turn_indices, vec![3, 4]); + assert_eq!(prompts, vec!["prompt 3", "prompt 4"]); + } + #[tokio::test] async fn load_session_with_turns_returns_session_and_persisted_turns() { let workspace = TestWorkspace::new(); diff --git a/src/crates/core/src/agentic/session/session_manager.rs b/src/crates/core/src/agentic/session/session_manager.rs index 865b39ca3..a16c0fe2a 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/core/src/agentic/session/session_manager.rs @@ -1843,8 +1843,9 @@ impl SessionManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult<(Session, Vec)> { - self.restore_session_view_internal(workspace_path, session_id, false) + self.restore_session_view_internal(workspace_path, session_id, false, None) .await + .map(|(session, turns, _)| (session, turns)) } pub async fn restore_internal_session_view( @@ -1852,7 +1853,28 @@ impl SessionManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult<(Session, Vec)> { - self.restore_session_view_internal(workspace_path, session_id, true) + self.restore_session_view_internal(workspace_path, session_id, true, None) + .await + .map(|(session, turns, _)| (session, turns)) + } + + pub async fn restore_session_view_tail( + &self, + workspace_path: &Path, + session_id: &str, + tail_turn_count: usize, + ) -> BitFunResult<(Session, Vec, usize)> { + self.restore_session_view_internal(workspace_path, session_id, false, Some(tail_turn_count)) + .await + } + + pub async fn restore_internal_session_view_tail( + &self, + workspace_path: &Path, + session_id: &str, + tail_turn_count: usize, + ) -> BitFunResult<(Session, Vec, usize)> { + self.restore_session_view_internal(workspace_path, session_id, true, Some(tail_turn_count)) .await } @@ -1861,7 +1883,8 @@ impl SessionManager { workspace_path: &Path, session_id: &str, include_internal: bool, - ) -> BitFunResult<(Session, Vec)> { + tail_turn_count: Option, + ) -> BitFunResult<(Session, Vec, usize)> { let restore_started_at = Instant::now(); let storage_path_started_at = Instant::now(); let session_storage_path = { @@ -1899,14 +1922,26 @@ impl SessionManager { ); let session_started_at = Instant::now(); - let (mut session, persisted_turns) = self - .persistence_manager - .load_session_with_turns(&session_storage_path, session_id) - .await?; + let (mut session, persisted_turns, total_turn_count) = if let Some(tail_turn_count) = + tail_turn_count + { + self.persistence_manager + .load_session_with_tail_turns(&session_storage_path, session_id, tail_turn_count) + .await? + } else { + let (session, turns) = self + .persistence_manager + .load_session_with_turns(&session_storage_path, session_id) + .await?; + let total_turn_count = turns.len(); + (session, turns, total_turn_count) + }; debug!( - "Session view restore phase completed: session_id={}, phase=load_session_with_turns, turn_count={}, duration_ms={}", + "Session view restore phase completed: session_id={}, phase=load_session_with_turns, turn_count={}, total_turn_count={}, tail_turn_count={:?}, duration_ms={}", session_id, persisted_turns.len(), + total_turn_count, + tail_turn_count, elapsed_ms_u64(session_started_at) ); @@ -1941,7 +1976,7 @@ impl SessionManager { elapsed_ms_u64(restore_started_at) ); - Ok((session, persisted_turns)) + Ok((session, persisted_turns, total_turn_count)) } /// Restore session and return the persisted turns read during restore. diff --git a/src/web-ui/index.html b/src/web-ui/index.html index 0fc5fff9e..82fef0517 100644 --- a/src/web-ui/index.html +++ b/src/web-ui/index.html @@ -57,11 +57,50 @@ width: 100%; height: 100%; } + + .bitfun-preload { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: rgba(143, 143, 153, 0.92); + font-family: "Noto Sans SC", "Inter", "Segoe UI", system-ui, sans-serif; + font-size: 13px; + } + + .bitfun-preload__content { + display: inline-flex; + align-items: center; + gap: 10px; + } + + .bitfun-preload__spinner { + width: 14px; + height: 14px; + border: 2px solid rgba(143, 143, 153, 0.24); + border-top-color: rgba(143, 143, 153, 0.9); + border-radius: 50%; + animation: bitfun-preload-spin 0.8s linear infinite; + } + + @keyframes bitfun-preload-spin { + to { + transform: rotate(360deg); + } + } -
+
+
+
+ + Loading workspace... +
+
+
diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx index c0fc4dd29..5196fe74e 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx @@ -26,6 +26,8 @@ import { getInitialModelRoundGroupRenderCount, getNextModelRoundGroupRenderCount, getSynchronizedModelRoundGroupRenderCount, + getVisibleModelRoundGroupEndIndex, + getVisibleModelRoundGroupStartIndex, } from './modelRoundProgressiveRender'; import { Tooltip } from '@/component-library'; import { createLogger } from '@/shared/utils/logger'; @@ -235,11 +237,22 @@ export const ModelRoundItem = React.memo( return () => window.clearTimeout(timeoutId); }, [groupedItems.length, renderedGroupCount, round.id, round.isStreaming]); + const visibleGroupStartIndex = getVisibleModelRoundGroupStartIndex({ + renderedCount: renderedGroupCount, + groupCount: groupedItems.length, + isStreaming: round.isStreaming, + }); + const visibleGroupEndIndex = getVisibleModelRoundGroupEndIndex({ + renderedCount: renderedGroupCount, + groupCount: groupedItems.length, + startIndex: visibleGroupStartIndex, + }); const visibleGroupedItems = useMemo( - () => groupedItems.slice(0, renderedGroupCount), - [groupedItems, renderedGroupCount], + () => groupedItems.slice(visibleGroupStartIndex, visibleGroupEndIndex), + [groupedItems, visibleGroupEndIndex, visibleGroupStartIndex], ); - const hasDeferredGroups = renderedGroupCount < groupedItems.length; + const hasDeferredEarlierGroups = visibleGroupStartIndex > 0; + const hasDeferredLaterGroups = visibleGroupEndIndex < groupedItems.length; const extractDialogTurnContent = useCallback(() => { const flowChatStore = FlowChatStore.getInstance(); @@ -332,8 +345,14 @@ export const ModelRoundItem = React.memo(
+ {hasDeferredEarlierGroups && ( +
+ {t('modelRound.loadingMoreHistory', { defaultValue: 'Loading more history...' })} +
+ )} + {visibleGroupedItems.map((group, groupIndex) => { - const isLastGroup = !hasDeferredGroups && groupIndex === groupedItems.length - 1; + const isLastGroup = visibleGroupStartIndex + groupIndex === groupedItems.length - 1; const isLast = isLastRound && isLastGroup; switch (group.type) { case 'explore': @@ -377,12 +396,12 @@ export const ModelRoundItem = React.memo( } })} - {hasDeferredGroups && ( + {hasDeferredLaterGroups && (
{t('modelRound.loadingMoreHistory', { defaultValue: 'Loading more history...' })}
)} - + {isTurnComplete && isLastRound && hasContent && !round.isStreaming && (
diff --git a/src/web-ui/src/flow_chat/components/modern/modelRoundProgressiveRender.test.ts b/src/web-ui/src/flow_chat/components/modern/modelRoundProgressiveRender.test.ts index 638eae25a..9c0e6ec04 100644 --- a/src/web-ui/src/flow_chat/components/modern/modelRoundProgressiveRender.test.ts +++ b/src/web-ui/src/flow_chat/components/modern/modelRoundProgressiveRender.test.ts @@ -6,6 +6,8 @@ import { getInitialModelRoundGroupRenderCount, getNextModelRoundGroupRenderCount, getSynchronizedModelRoundGroupRenderCount, + getVisibleModelRoundGroupEndIndex, + getVisibleModelRoundGroupStartIndex, } from './modelRoundProgressiveRender'; describe('modelRoundProgressiveRender', () => { @@ -43,4 +45,39 @@ describe('modelRoundProgressiveRender', () => { isStreaming: false, })).toBe(MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT + 120); }); + + it('starts completed partial rendering from the newest groups', () => { + expect(getVisibleModelRoundGroupStartIndex({ + renderedCount: MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT, + groupCount: MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT + 25, + isStreaming: false, + })).toBe(25); + }); + + it('keeps streaming rendering anchored at the beginning', () => { + expect(getVisibleModelRoundGroupStartIndex({ + renderedCount: MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT, + groupCount: MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT + 25, + isStreaming: true, + })).toBe(0); + expect(getVisibleModelRoundGroupEndIndex({ + renderedCount: MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT, + groupCount: MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT + 25, + startIndex: 0, + })).toBe(MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT); + }); + + it('limits completed tail rendering to the requested visible count', () => { + const startIndex = getVisibleModelRoundGroupStartIndex({ + renderedCount: MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT, + groupCount: MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT + 25, + isStreaming: false, + }); + + expect(getVisibleModelRoundGroupEndIndex({ + renderedCount: MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT, + groupCount: MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT + 25, + startIndex, + })).toBe(MODEL_ROUND_INITIAL_GROUP_RENDER_LIMIT + 25); + }); }); diff --git a/src/web-ui/src/flow_chat/components/modern/modelRoundProgressiveRender.ts b/src/web-ui/src/flow_chat/components/modern/modelRoundProgressiveRender.ts index 0e3d6e42e..dadad1e4e 100644 --- a/src/web-ui/src/flow_chat/components/modern/modelRoundProgressiveRender.ts +++ b/src/web-ui/src/flow_chat/components/modern/modelRoundProgressiveRender.ts @@ -35,3 +35,25 @@ export function getSynchronizedModelRoundGroupRenderCount(params: { return Math.min(groupCount, Math.max(currentCount, initialCount)); } + +export function getVisibleModelRoundGroupStartIndex(params: { + renderedCount: number; + groupCount: number; + isStreaming: boolean; +}): number { + const { renderedCount, groupCount, isStreaming } = params; + if (isStreaming) { + return 0; + } + + return Math.max(0, groupCount - Math.min(renderedCount, groupCount)); +} + +export function getVisibleModelRoundGroupEndIndex(params: { + renderedCount: number; + groupCount: number; + startIndex: number; +}): number { + const { renderedCount, groupCount, startIndex } = params; + return Math.min(groupCount, startIndex + Math.min(renderedCount, groupCount)); +} 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 b570a98be..4031c83a7 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -68,6 +68,15 @@ const resetStore = () => { } }); metadataPageRequests?.clear(); + const fullHistoryHydrationRequests = (flowChatStore as any).fullHistoryHydrationRequests as + | Map }> + | undefined; + fullHistoryHydrationRequests?.forEach(request => { + if (request.timer) { + clearTimeout(request.timer); + } + }); + fullHistoryHydrationRequests?.clear(); ((flowChatStore as any).unsupportedRestoreCommands as Set | undefined)?.clear(); flowChatStore.setState((): FlowChatState => ({ sessions: new Map(), @@ -764,6 +773,112 @@ describe('FlowChatStore historical session hydration state', () => { expect(toolItem?.toolResult?.resultForAssistant).toBeUndefined(); }); + it('renders tail-restored turns before completing partial history in background', async () => { + vi.useFakeTimers(); + const olderTurn = { + turnId: 'turn-1', + turnIndex: 0, + sessionId: 'history-1', + timestamp: 1, + userMessage: { id: 'user-1', content: 'older prompt', timestamp: 1 }, + modelRounds: [], + startTime: 1, + status: 'completed', + }; + const latestTurn = { + turnId: 'turn-2', + turnIndex: 1, + sessionId: 'history-1', + timestamp: 2, + userMessage: { id: 'user-2', content: 'latest prompt', timestamp: 2 }, + modelRounds: [], + startTime: 2, + status: 'completed', + }; + apiMocks.restoreSessionView + .mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 2, + createdAt: 1, + }, + turns: [latestTurn], + contextRestoreState: 'pending', + isPartial: true, + loadedTurnCount: 1, + totalTurnCount: 2, + }) + .mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 2, + createdAt: 1, + }, + turns: [olderTurn, latestTurn], + contextRestoreState: 'pending', + isPartial: false, + loadedTurnCount: 2, + totalTurnCount: 2, + }); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + })], + ]), + activeSessionId: 'history-1', + })); + + try { + await flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun'); + + expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(1); + expect(apiMocks.restoreSessionView).toHaveBeenNthCalledWith( + 1, + 'history-1', + 'D:/workspace/BitFun', + undefined, + undefined, + expect.any(String), + undefined, + 3, + ); + expect( + flowChatStore.getState().sessions.get('history-1')?.dialogTurns.map(turn => turn.userMessage.content) + ).toEqual(['latest prompt']); + flowChatStore.setSessionContextRestoreState('history-1', 'ready'); + + await vi.runOnlyPendingTimersAsync(); + await flushAsyncWork(); + + expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(2); + expect(apiMocks.restoreSessionView).toHaveBeenNthCalledWith( + 2, + 'history-1', + 'D:/workspace/BitFun', + undefined, + undefined, + expect.stringContaining('full'), + undefined, + undefined, + ); + expect( + flowChatStore.getState().sessions.get('history-1')?.dialogTurns.map(turn => turn.userMessage.content) + ).toEqual(['older prompt', 'latest prompt']); + expect(flowChatStore.getState().sessions.get('history-1')?.contextRestoreState).toBe('ready'); + } finally { + vi.useRealTimers(); + } + }); + it('falls back to restoreSessionWithTurns when view restore is unavailable', async () => { (apiMocks as any).restoreSessionView = undefined; const restoredTurn = { diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index d453c13a8..5d7bc1940 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -66,6 +66,8 @@ const VALID_AGENT_TYPES = new Set([ 'DeepResearch', ]); const METADATA_LIST_RECENT_DEDUPE_TTL_MS = 1000; +const HISTORICAL_SESSION_INITIAL_TAIL_TURN_COUNT = 3; +const HISTORICAL_SESSION_FULL_HISTORY_DELAY_MS = 150; interface MetadataListRequest { promise: Promise; @@ -79,6 +81,25 @@ interface MetadataPageRequest { cleanupTimer?: ReturnType; } +interface FullHistoryHydrationRequest { + promise: Promise; + timer?: ReturnType; +} + +interface CompleteSessionHistoryLoadRequest { + sessionId: string; + workspacePath: string; + remoteConnectionId?: string; + remoteSshHost?: string; + includeInternal?: boolean; + initialSessionTraceId: string; + expectedDialogTurnIds: string[]; +} + +function areStringArraysEqual(left: string[], right: string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + function isUnsupportedTauriCommandError(error: unknown, command: string): boolean { const anyError = error as any; const originalError = anyError?.context?.originalError; @@ -130,6 +151,7 @@ export class FlowChatStore { private silentMode = false; private metadataListRequests = new Map(); private metadataPageRequests = new Map(); + private fullHistoryHydrationRequests = new Map(); private unsupportedRestoreCommands = new Set(); private onPersistUnreadCompletion?: (sessionId: string, value: 'completed' | 'error' | 'interrupted' | undefined) => void; @@ -205,6 +227,142 @@ export class FlowChatStore { ]); } + private getFullHistoryHydrationKey( + sessionId: string, + workspacePath: string, + remoteConnectionId?: string, + remoteSshHost?: string, + includeInternal?: boolean, + ): string { + return JSON.stringify([ + sessionId, + workspacePath, + remoteConnectionId || '', + remoteSshHost || '', + includeInternal === true, + ]); + } + + private scheduleCompleteSessionHistoryLoad(request: CompleteSessionHistoryLoadRequest): void { + const requestKey = this.getFullHistoryHydrationKey( + request.sessionId, + request.workspacePath, + request.remoteConnectionId, + request.remoteSshHost, + request.includeInternal, + ); + if (this.fullHistoryHydrationRequests.has(requestKey)) { + return; + } + + const remote = isRemoteTraceContext(request.remoteConnectionId, request.remoteSshHost); + startupTrace.markPhase('historical_session_full_hydrate_scheduled', { + remote, + sessionTraceId: request.initialSessionTraceId, + loadedTurnCount: request.expectedDialogTurnIds.length, + }); + + let timer: ReturnType | undefined; + const promise = new Promise(resolve => { + timer = setTimeout(() => { + void this.completeSessionHistoryLoad(request) + .catch(error => { + startupTrace.markPhase('historical_session_full_hydrate_failed', { + remote, + sessionTraceId: `${request.initialSessionTraceId}-full`, + }); + log.warn('Failed to complete partial session history restore', { + sessionId: request.sessionId, + error, + }); + }) + .finally(resolve); + }, HISTORICAL_SESSION_FULL_HISTORY_DELAY_MS); + }).finally(() => { + const currentRequest = this.fullHistoryHydrationRequests.get(requestKey); + if (currentRequest?.promise === promise) { + this.fullHistoryHydrationRequests.delete(requestKey); + } + }); + + this.fullHistoryHydrationRequests.set(requestKey, { promise, timer }); + } + + private async completeSessionHistoryLoad( + request: CompleteSessionHistoryLoadRequest + ): Promise { + const fullTraceId = `${request.initialSessionTraceId}-full`; + const startedAt = nowMs(); + const remote = isRemoteTraceContext(request.remoteConnectionId, request.remoteSshHost); + startupTrace.markPhase('historical_session_full_hydrate_start', { + remote, + sessionTraceId: fullTraceId, + }); + + const { agentAPI } = await import('@/infrastructure/api'); + const restored = await agentAPI.restoreSessionView( + request.sessionId, + request.workspacePath, + request.remoteConnectionId, + request.remoteSshHost, + fullTraceId, + request.includeInternal, + undefined, + ); + + const convertStartedAt = nowMs(); + const dialogTurns = this.convertToDialogTurns(restored.turns); + const restoredLastUserDialogMode = + restored.session.lastUserDialogAgentType || this.deriveLastUserDialogMode(dialogTurns); + const contextRestoreState: SessionContextRestoreState = + restored.contextRestoreState === 'ready' ? 'ready' : 'pending'; + startupTrace.markPhase('historical_session_full_hydrate_convert_end', { + remote, + sessionTraceId: fullTraceId, + turnCount: dialogTurns.length, + durationMs: elapsedMs(convertStartedAt), + }); + + let applied = false; + this.setState(prev => { + const session = prev.sessions.get(request.sessionId); + if (!session || session.historyState !== 'ready') { + return prev; + } + + const currentDialogTurnIds = session.dialogTurns.map(turn => turn.id); + if (!areStringArraysEqual(currentDialogTurnIds, request.expectedDialogTurnIds)) { + return prev; + } + + const newSessions = new Map(prev.sessions); + newSessions.set(request.sessionId, { + ...session, + dialogTurns, + contextRestoreState: + session.contextRestoreState === 'ready' ? 'ready' : contextRestoreState, + mode: restored.session.agentType || session.mode, + lastUserDialogMode: restoredLastUserDialogMode, + lastSubmittedMode: + restored.session.lastSubmittedAgentType ?? session.lastSubmittedMode, + }); + applied = true; + + return { + ...prev, + sessions: newSessions, + }; + }); + + startupTrace.markPhase('historical_session_full_hydrate_end', { + remote, + sessionTraceId: fullTraceId, + turnCount: dialogTurns.length, + applied, + durationMs: elapsedMs(startedAt), + }); + } + public setState(updater: (prevState: FlowChatState) => FlowChatState): void { const newState = updater(this.state); this.state = newState; @@ -2670,6 +2828,9 @@ export class FlowChatStore { let turns: DialogTurnData[] | undefined; let restoredSessionInfo: AgentSessionInfo | undefined; let contextRestoreState: SessionContextRestoreState = 'ready'; + let restoredHistoryPartial = false; + let restoredLoadedTurnCount: number | undefined; + let restoredTotalTurnCount: number | undefined; if (!isAcpSession) { const restoreStartedAt = nowMs(); startupTrace.markPhase('historical_session_restore_start', { remote, sessionTraceId }); @@ -2741,11 +2902,15 @@ export class FlowChatStore { remoteSshHost, sessionTraceId, options?.includeInternal, + HISTORICAL_SESSION_INITIAL_TAIL_TURN_COUNT, ); restoredSessionInfo = restored.session; turns = restored.turns; contextRestoreState = restored.contextRestoreState === 'ready' ? 'ready' : 'pending'; + restoredHistoryPartial = restored.isPartial === true; + restoredLoadedTurnCount = restored.loadedTurnCount; + restoredTotalTurnCount = restored.totalTurnCount; } catch (error) { if (!isUnsupportedTauriCommandError(error, 'restore_session_view')) { throw error; @@ -2767,6 +2932,9 @@ export class FlowChatStore { remote, sessionTraceId, turnCount: Array.isArray(turns) ? turns.length : 0, + loadedTurnCount: restoredLoadedTurnCount, + totalTurnCount: restoredTotalTurnCount, + isPartial: restoredHistoryPartial, contextRestoreState, durationMs: elapsedMs(restoreStartedAt), }); @@ -2863,8 +3031,21 @@ export class FlowChatStore { remote, sessionTraceId, turnCount: dialogTurns.length, + totalTurnCount: restoredTotalTurnCount, + isPartial: restoredHistoryPartial, durationMs: elapsedMs(traceStartedAt), }); + if (restoredHistoryPartial) { + this.scheduleCompleteSessionHistoryLoad({ + sessionId, + workspacePath, + remoteConnectionId, + remoteSshHost, + includeInternal: options?.includeInternal, + initialSessionTraceId: sessionTraceId, + expectedDialogTurnIds: dialogTurns.map(turn => turn.id), + }); + } } catch (error) { this.setState(prev => { const session = prev.sessions.get(sessionId); diff --git a/src/web-ui/src/flow_chat/tool-cards/CreatePlanDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/CreatePlanDisplay.tsx index ba3028f87..1d6e98b57 100644 --- a/src/web-ui/src/flow_chat/tool-cards/CreatePlanDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/CreatePlanDisplay.tsx @@ -19,6 +19,7 @@ import { Tooltip } from '@/component-library'; import { createLogger } from '@/shared/utils/logger'; import { useToolCardHeightContract } from './useToolCardHeightContract'; import { basenamePath, dirnameAbsolutePath } from '@/shared/utils/pathUtils'; +import { createTodoRenderItems } from './todoRenderItems'; import './CreatePlanDisplay.scss'; const log = createLogger('PlanDisplay'); @@ -103,6 +104,10 @@ export const PlanDisplay: React.FC = ({ }, [planFilePath, initialName, initialOverview, initialTodos]); const planData = refreshedData || initialPlanData; + const todoRenderItems = useMemo( + () => createTodoRenderItems(planData?.todos ?? []), + [planData?.todos], + ); // Subscribe to shared build state service for cross-component sync. useEffect(() => { @@ -351,9 +356,9 @@ ${JSON.stringify(simpleTodos, null, 2)} {planData.todos && planData.todos.length > 0 && isTodosExpanded && (
- {planData.todos.map((todo, index) => ( + {todoRenderItems.map(({ todo, key }) => (
{todo.status === 'completed' && ( diff --git a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts index d631cccc1..99f5a0f56 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -107,6 +107,9 @@ export interface RestoreSessionViewResponse { session: SessionInfo; turns: DialogTurnData[]; contextRestoreState: 'ready' | 'pending'; + isPartial?: boolean; + loadedTurnCount?: number; + totalTurnCount?: number; } export interface EnsureAssistantBootstrapRequest { @@ -499,6 +502,7 @@ export class AgentAPI { remoteSshHost?: string, traceId?: string, includeInternal?: boolean, + tailTurnCount?: number, ): Promise { try { return await api.invoke('restore_session_view', { @@ -509,6 +513,7 @@ export class AgentAPI { remoteSshHost, traceId, includeInternal, + ...(tailTurnCount !== undefined ? { tailTurnCount } : {}), }, }); } catch (error) {