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) {