diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index 4d2eee30ef..14c60c4406 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -821,9 +821,10 @@ async fn clear_active_workspace_context( state: &State<'_, AppState>, app: &AppHandle, startup_trace: Option<&DesktopStartupTrace>, + reset_menubar: bool, ) { #[cfg(not(target_os = "macos"))] - let _ = app; + let _ = (app, reset_menubar); let step_started = Instant::now(); let previous_workspace_id = state.workspace_id.write().await.take(); @@ -880,7 +881,7 @@ async fn clear_active_workspace_context( } #[cfg(target_os = "macos")] - { + if reset_menubar { let step_started = Instant::now(); let language = state .config_service @@ -914,7 +915,7 @@ async fn apply_active_workspace_context( let _ = app; let step_started = Instant::now(); - clear_active_workspace_context(state, app, startup_trace).await; + clear_active_workspace_context(state, app, startup_trace, false).await; if let Some(trace) = startup_trace { trace.record_elapsed_step( "tauri_command", @@ -1024,7 +1025,7 @@ async fn initialize_global_state_impl( ); } else { let step_started = Instant::now(); - clear_active_workspace_context(state, app, Some(trace)).await; + clear_active_workspace_context(state, app, Some(trace), true).await; trace.record_elapsed_step( "tauri_command", "initialize_global_state.clear_active_workspace_context", @@ -1756,7 +1757,7 @@ pub async fn delete_assistant_workspace( if let Some(current_workspace) = state.workspace_service.get_current_workspace().await { apply_active_workspace_context(&state, &app, ¤t_workspace, None).await; } else { - clear_active_workspace_context(&state, &app, None).await; + clear_active_workspace_context(&state, &app, None, true).await; } if let Err(e) = state @@ -1967,7 +1968,7 @@ pub async fn close_workspace( if let Some(workspace_info) = state.workspace_service.get_current_workspace().await { apply_active_workspace_context(&state, &app, &workspace_info, None).await; } else { - clear_active_workspace_context(&state, &app, None).await; + clear_active_workspace_context(&state, &app, None, true).await; } info!("Workspace closed: workspace_id={}", request.workspace_id); @@ -2297,7 +2298,7 @@ async fn cleanup_invalid_workspaces_impl( if let Some(workspace_info) = state.workspace_service.get_current_workspace().await { apply_active_workspace_context(state, app, &workspace_info, None).await; } else { - clear_active_workspace_context(state, app, None).await; + clear_active_workspace_context(state, app, None, true).await; } startup_trace.record_elapsed_step( "tauri_command", diff --git a/src/apps/desktop/src/api/event_coalescer.rs b/src/apps/desktop/src/api/event_coalescer.rs index a280cb8c27..5a0db90854 100644 --- a/src/apps/desktop/src/api/event_coalescer.rs +++ b/src/apps/desktop/src/api/event_coalescer.rs @@ -25,7 +25,7 @@ //! merged thinking event before the merged text event. use openbitfun_core_types::ReasoningContentKind; -use openbitfun_events::AgenticEvent; +use openbitfun_events::{AgenticEvent, ToolEventData, ToolEventIdentity}; use std::collections::HashMap; use std::time::Duration; @@ -118,15 +118,37 @@ pub fn update_rate_ema(previous: f64, flushed_chars: usize, elapsed: Duration) - /// session behaves exactly like the previous fixed 50ms window. pub const INITIAL_RATE_EMA_CPS: f64 = WINDOW_REF_CPS; -/// Stable merge key for one streaming text/thinking stream. -type ChunkStreamKey = ( - String, - String, - String, - String, - bool, - Option, -); +/// Stable merge key for one streaming text, thinking, or high-frequency tool stream. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +enum CoalescedStreamKey { + Text { + session_id: String, + turn_id: String, + round_id: String, + attempt_token: String, + }, + Thinking { + session_id: String, + turn_id: String, + round_id: String, + attempt_token: String, + reasoning_kind: Option, + }, + ToolParams { + session_id: String, + turn_id: String, + round_id: String, + attempt_token: String, + tool_id: String, + }, + ToolProgress { + session_id: String, + turn_id: String, + round_id: String, + attempt_token: String, + tool_id: String, + }, +} fn resolve_attempt_token(attempt_id: &Option, attempt_index: Option) -> String { if let Some(id) = attempt_id { @@ -159,6 +181,25 @@ enum PendingChunk { reasoning_kind: Option, is_end: bool, }, + ToolParams { + session_id: String, + turn_id: String, + round_id: String, + attempt_id: Option, + attempt_index: Option, + identity: ToolEventIdentity, + params: String, + }, + ToolProgress { + session_id: String, + turn_id: String, + round_id: String, + attempt_id: Option, + attempt_index: Option, + identity: ToolEventIdentity, + message: String, + percentage: f32, + }, } impl PendingChunk { @@ -198,18 +239,55 @@ impl PendingChunk { reasoning_kind, is_end, }, + PendingChunk::ToolParams { + session_id, + turn_id, + round_id, + attempt_id, + attempt_index, + identity, + params, + } => AgenticEvent::ToolEvent { + session_id, + turn_id, + round_id, + attempt_id, + attempt_index, + tool_event: ToolEventData::ParamsPartial { identity, params }, + }, + PendingChunk::ToolProgress { + session_id, + turn_id, + round_id, + attempt_id, + attempt_index, + identity, + message, + percentage, + } => AgenticEvent::ToolEvent { + session_id, + turn_id, + round_id, + attempt_id, + attempt_index, + tool_event: ToolEventData::Progress { + identity, + message, + percentage, + }, + }, } } } -/// Coalesces streamed text/thinking chunks within a short time window. +/// Coalesces streamed text/thinking/tool-partial chunks within a short time window. pub struct TextChunkCoalescer { - pending: HashMap, + pending: HashMap, /// First-arrival order of the buffered stream keys. Kept in sync with /// `pending` (a key is pushed exactly when its entry is inserted) so that /// `flush` reproduces the producer's FIFO sequence instead of reordering /// streams by key. - order: Vec, + order: Vec, /// Total content characters buffered since the last flush. Used by the /// caller to measure the stream rate for the adaptive window. buffered_chars: usize, @@ -256,14 +334,12 @@ impl TextChunkCoalescer { attempt_index, text, } => { - let key = ( - session_id.clone(), - turn_id.clone(), - round_id.clone(), - resolve_attempt_token(&attempt_id, attempt_index), - false, - None, - ); + let key = CoalescedStreamKey::Text { + session_id: session_id.clone(), + turn_id: turn_id.clone(), + round_id: round_id.clone(), + attempt_token: resolve_attempt_token(&attempt_id, attempt_index), + }; match self.pending.get_mut(&key) { Some(PendingChunk::Text { text: pending, .. }) => { pending.push_str(&text); @@ -299,14 +375,13 @@ impl TextChunkCoalescer { reasoning_kind, is_end, } => { - let key = ( - session_id.clone(), - turn_id.clone(), - round_id.clone(), - resolve_attempt_token(&attempt_id, attempt_index), - true, + let key = CoalescedStreamKey::Thinking { + session_id: session_id.clone(), + turn_id: turn_id.clone(), + round_id: round_id.clone(), + attempt_token: resolve_attempt_token(&attempt_id, attempt_index), reasoning_kind, - ); + }; match self.pending.get_mut(&key) { Some(PendingChunk::Thinking { content: pending, @@ -339,6 +414,109 @@ impl TextChunkCoalescer { } } } + AgenticEvent::ToolEvent { + session_id, + turn_id, + round_id, + attempt_id, + attempt_index, + tool_event: ToolEventData::ParamsPartial { identity, params }, + } => { + let key = CoalescedStreamKey::ToolParams { + session_id: session_id.clone(), + turn_id: turn_id.clone(), + round_id: round_id.clone(), + attempt_token: resolve_attempt_token(&attempt_id, attempt_index), + tool_id: identity.tool_id.clone(), + }; + match self.pending.get_mut(&key) { + Some(PendingChunk::ToolParams { + identity: pending_identity, + params: pending_params, + .. + }) => { + pending_params.push_str(¶ms); + pending_identity.tool_name = identity.tool_name; + if identity.effective_tool_name.is_some() { + pending_identity.effective_tool_name = identity.effective_tool_name; + } + self.buffered_chars += params.chars().count(); + Vec::new() + } + _ => { + let len = params.chars().count(); + self.pending.insert( + key.clone(), + PendingChunk::ToolParams { + session_id, + turn_id, + round_id, + attempt_id, + attempt_index, + identity, + params, + }, + ); + self.order.push(key); + self.buffered_chars += len; + Vec::new() + } + } + } + AgenticEvent::ToolEvent { + session_id, + turn_id, + round_id, + attempt_id, + attempt_index, + tool_event: + ToolEventData::Progress { + identity, + message, + percentage, + }, + } => { + let key = CoalescedStreamKey::ToolProgress { + session_id: session_id.clone(), + turn_id: turn_id.clone(), + round_id: round_id.clone(), + attempt_token: resolve_attempt_token(&attempt_id, attempt_index), + tool_id: identity.tool_id.clone(), + }; + match self.pending.get_mut(&key) { + Some(PendingChunk::ToolProgress { + identity: pending_identity, + message: pending_message, + percentage: pending_percentage, + .. + }) => { + pending_identity.tool_name = identity.tool_name; + if identity.effective_tool_name.is_some() { + pending_identity.effective_tool_name = identity.effective_tool_name; + } + *pending_message = message; + *pending_percentage = percentage; + Vec::new() + } + _ => { + self.pending.insert( + key.clone(), + PendingChunk::ToolProgress { + session_id, + turn_id, + round_id, + attempt_id, + attempt_index, + identity, + message, + percentage, + }, + ); + self.order.push(key); + Vec::new() + } + } + } other => { let mut events = self.flush(); events.push(other); @@ -776,4 +954,142 @@ mod tests { // 80 chars over 2000 ms -> 40 chars/sec; reset should discard the old 160. assert!((reset - 40.0).abs() < 1e-9); } + + #[test] + fn merges_tool_params_partial_without_flushing_concurrent_text() { + let mut coalescer = TextChunkCoalescer::new(); + assert!(coalescer + .push(text_chunk("s1", "t1", "r1", None, None, "hello ")) + .is_empty()); + assert!(coalescer + .push(AgenticEvent::ToolEvent { + session_id: "s2".to_string(), + turn_id: "t2".to_string(), + round_id: "r2".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::ParamsPartial { + identity: ToolEventIdentity::direct("tool-1", "CallDeferredTool"), + params: "{\"path\":".to_string(), + }, + }) + .is_empty()); + assert!(coalescer + .push(text_chunk("s1", "t1", "r1", None, None, "world")) + .is_empty()); + assert!(coalescer + .push(AgenticEvent::ToolEvent { + session_id: "s2".to_string(), + turn_id: "t2".to_string(), + round_id: "r2".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::ParamsPartial { + identity: ToolEventIdentity::resolved("tool-1", "CallDeferredTool", "Write"), + params: "\"a.rs\"}".to_string(), + }, + }) + .is_empty()); + + let events = coalescer.flush(); + assert_eq!(events.len(), 2); + assert!( + matches!(&events[0], AgenticEvent::TextChunk { session_id, text, .. } if session_id == "s1" && text == "hello world") + ); + match &events[1] { + AgenticEvent::ToolEvent { + session_id, + tool_event: ToolEventData::ParamsPartial { identity, params }, + .. + } => { + assert_eq!(session_id, "s2"); + assert_eq!(identity.tool_id, "tool-1"); + assert_eq!(identity.effective_tool_name.as_deref(), Some("Write")); + assert_eq!(params, "{\"path\":\"a.rs\"}"); + } + other => panic!("expected merged ParamsPartial, got {other:?}"), + } + } + + #[test] + fn replaces_tool_progress_and_flushes_before_tool_started() { + let mut coalescer = TextChunkCoalescer::new(); + assert!(coalescer + .push(AgenticEvent::ToolEvent { + session_id: "s".to_string(), + turn_id: "t".to_string(), + round_id: "r".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::ParamsPartial { + identity: ToolEventIdentity::direct("tool-1", "Bash"), + params: "{\"cmd\":\"ls\"}".to_string(), + }, + }) + .is_empty()); + assert!(coalescer + .push(AgenticEvent::ToolEvent { + session_id: "s".to_string(), + turn_id: "t".to_string(), + round_id: "r".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::Progress { + identity: ToolEventIdentity::direct("tool-2", "Bash"), + message: "step 1".to_string(), + percentage: 25.0, + }, + }) + .is_empty()); + assert!(coalescer + .push(AgenticEvent::ToolEvent { + session_id: "s".to_string(), + turn_id: "t".to_string(), + round_id: "r".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::Progress { + identity: ToolEventIdentity::direct("tool-2", "Bash"), + message: "step 2".to_string(), + percentage: 75.0, + }, + }) + .is_empty()); + + let events = coalescer.push(AgenticEvent::ToolEvent { + session_id: "s".to_string(), + turn_id: "t".to_string(), + round_id: "r".to_string(), + attempt_id: None, + attempt_index: None, + tool_event: ToolEventData::Started { + identity: ToolEventIdentity::direct("tool-1", "Bash"), + params: serde_json::json!({ "cmd": "ls" }), + timeout_seconds: None, + }, + }); + + assert_eq!(events.len(), 3); + assert!(matches!( + &events[0], + AgenticEvent::ToolEvent { + tool_event: ToolEventData::ParamsPartial { params, .. }, + .. + } if params == "{\"cmd\":\"ls\"}" + )); + assert!(matches!( + &events[1], + AgenticEvent::ToolEvent { + tool_event: ToolEventData::Progress { message, percentage, .. }, + .. + } if message == "step 2" && (*percentage - 75.0).abs() < f32::EPSILON + )); + assert!(matches!( + &events[2], + AgenticEvent::ToolEvent { + tool_event: ToolEventData::Started { .. }, + .. + } + )); + } } diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index fbe03d1f6d..eff4146d2e 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -67,7 +67,7 @@ use openbitfun_core::service::session_projection_store::{ }; use openbitfun_core::util::{elapsed_ms, TimingCollector}; use openbitfun_events::AgenticEvent; -use openbitfun_transport::{TauriTransportAdapter, TransportAdapter}; +use openbitfun_transport::TauriTransportAdapter; use serde::Deserialize; use std::path::PathBuf; use std::sync::{ @@ -2446,83 +2446,93 @@ fn configure_workspace_search_daemon_env() -> Option { path } -/// Deliver one event to the WebView and, when peer controllers are attached, -/// fan it out to paired devices. Text chunks arrive here already coalesced by -/// `TextChunkCoalescer`. -async fn deliver_event_to_webview( +/// Deliver an ordered batch of events to the WebView and, when peer controllers +/// are attached, fan each projected event out to paired devices in FIFO order. +/// Streamed chunks arrive here already coalesced by `TextChunkCoalescer`. +async fn deliver_events_to_webview( transport: &TauriTransportAdapter, - event: AgenticEvent, + events: Vec, session_event_journal: &SessionEventJournal, ) { - openbitfun_core::service::remote_connect::notify_session_catalog_event(&event); - let cursor = session_event_journal.record(&event); - let Some(mut projected) = openbitfun_events::project_agentic_frontend_event(event) else { - log::warn!("Unhandled AgenticEvent type in desktop delivery"); + if events.is_empty() { return; - }; - if let Some(cursor) = cursor { - attach_session_event_cursor(&mut projected.payload, cursor); } - for hub in api::remote_connect_api::host_stream_hubs().await { - if let Some(session_id) = projected - .payload - .get("sessionId") - .or_else(|| projected.payload.get("session_id")) - .and_then(serde_json::Value::as_str) - { - let name = projected.event_name.as_str(); - let policy = - openbitfun_core::service::remote_connect::session_records::session_event_publication( - name, - &projected.payload, - ); - if policy.synchronize_records { - if let Err(error) = async { - if let Some(turn) = projected - .payload - .get("turnId") - .or_else(|| projected.payload.get("settledTurnId")) - .and_then(serde_json::Value::as_str) + let hubs = api::remote_connect_api::host_stream_hubs().await; + let mut out_batch = Vec::with_capacity(events.len()); + + for event in events { + openbitfun_core::service::remote_connect::notify_session_catalog_event(&event); + let cursor = session_event_journal.record(&event); + let Some(mut projected) = openbitfun_events::project_agentic_frontend_event(event) else { + log::warn!("Unhandled AgenticEvent type in desktop delivery"); + continue; + }; + if let Some(cursor) = cursor { + attach_session_event_cursor(&mut projected.payload, cursor); + } + + for hub in &hubs { + if let Some(session_id) = projected + .payload + .get("sessionId") + .or_else(|| projected.payload.get("session_id")) + .and_then(serde_json::Value::as_str) + { + let name = projected.event_name.as_str(); + let policy = openbitfun_core::service::remote_connect::session_records::session_event_publication( + name, + &projected.payload, + ); + if policy.synchronize_records { + if let Err(error) = async { + if let Some(turn) = projected + .payload + .get("turnId") + .or_else(|| projected.payload.get("settledTurnId")) + .and_then(serde_json::Value::as_str) + { + openbitfun_core::service::remote_connect::synchronize_session_record_turn( + hub, session_id, turn, + ) + .await + } else if name == "agentic://session-history-changed" { + openbitfun_core::service::remote_connect::synchronize_session_records( + hub, session_id, + ) + .await + } else { + Ok(()) + } + } + .await { - openbitfun_core::service::remote_connect::synchronize_session_record_turn( - &hub, session_id, turn, - ) - .await - } else if name == "agentic://session-history-changed" { - openbitfun_core::service::remote_connect::synchronize_session_records( - &hub, session_id, + log::error!("Unable to synchronize host session records: {error}"); + } + } + if policy.persist_control { + if let Err(error) = hub + .append( + session_id.to_owned(), + projected.event_name.clone(), + projected.payload.clone(), ) .await - } else { - Ok(()) + { + log::error!("Unable to publish session control event: {error}"); } } - .await - { - log::error!("Unable to synchronize host session records: {error}"); - } - } - if policy.persist_control { - if let Err(error) = hub - .append( - session_id.to_owned(), - projected.event_name.clone(), - projected.payload.clone(), - ) - .await - { - log::error!("Unable to publish session control event: {error}"); - } } } + + out_batch.push(openbitfun_transport::TauriBatchedEvent { + event: projected.event_name, + payload: projected.payload, + }); } - if let Err(e) = transport - .emit_generic(&projected.event_name, projected.payload.clone()) - .await - { - log::error!("Failed to emit event: {:?}", e); + if let Err(e) = transport.emit_batch(out_batch) { + log::error!("Failed to emit event batch: {:?}", e); } // Session events reach controllers through host streams they read on @@ -2552,24 +2562,25 @@ fn update_rate_after_flush( /// Flush all buffered chunks as merged events and feed the flushed content /// volume back into the rate estimate that sizes the next window. async fn flush_coalesced( - deliver: &mut D, + deliver_batch: &mut D, coalescer: &mut crate::api::event_coalescer::TextChunkCoalescer, rate_ema: &mut f64, arm_time: tokio::time::Instant, last_flush_time: &mut Option, ) where - D: FnMut(AgenticEvent) -> F, + D: FnMut(Vec) -> F, F: std::future::Future, { let flushed_chars = coalescer.buffered_chars(); update_rate_after_flush(rate_ema, flushed_chars, arm_time, last_flush_time); - for event in coalescer.flush() { - deliver(event).await; + let flushed = coalescer.flush(); + if !flushed.is_empty() { + deliver_batch(flushed).await; } } /// Drive the agentic event queue: route raw events to internal subscribers, -/// coalesce streamed text chunks, and deliver merged events through `deliver`. +/// coalesce streamed chunks, and deliver ordered event batches through `deliver_batch`. /// /// Scheduling contract: /// - The coalescing window is armed as soon as the first chunk is buffered @@ -2580,12 +2591,12 @@ async fn flush_coalesced( /// expired deadline is also honored inside the drain: the buffered text is /// flushed in place before processing continues. Text therefore waits at /// most one window regardless of queue pressure. -async fn event_loop_driver( +async fn event_loop_batch_driver( event_queue: Arc, event_router: Arc, - mut deliver: D, + mut deliver_batch: D, ) where - D: FnMut(AgenticEvent) -> F, + D: FnMut(Vec) -> F, F: std::future::Future, { use crate::api::event_coalescer::{ @@ -2624,6 +2635,8 @@ async fn event_loop_driver( break; } + let mut ready_events: Vec = Vec::new(); + for envelope in batch { // Route to internal subscribers (e.g. RemoteSessionStateTracker) // sequentially so that text chunks are appended in order. @@ -2642,9 +2655,7 @@ async fn event_loop_driver( let pushed = coalescer.push(envelope.event); let did_flush = !pushed.is_empty(); - for event in pushed { - deliver(event).await; - } + ready_events.extend(pushed); if did_flush && pre_flush_chars > 0 { if let Some(arm) = arm_time { @@ -2691,14 +2702,18 @@ async fn event_loop_driver( .expect("arm_time must be set when a deadline is armed"); flush_deadline = None; flush_arm_time = None; - flush_coalesced( - &mut deliver, - &mut coalescer, + let flushed_chars = coalescer.buffered_chars(); + update_rate_after_flush( &mut rate_ema, + flushed_chars, arm_time, &mut last_flush_time, - ) - .await; + ); + ready_events.extend(coalescer.flush()); + } + + if !ready_events.is_empty() { + deliver_batch(ready_events).await; } } } @@ -2708,7 +2723,7 @@ async fn event_loop_driver( flush_deadline = None; flush_arm_time = None; flush_coalesced( - &mut deliver, + &mut deliver_batch, &mut coalescer, &mut rate_ema, arm_time, @@ -2720,6 +2735,31 @@ async fn event_loop_driver( } } +#[cfg(test)] +async fn event_loop_driver( + event_queue: Arc, + event_router: Arc, + deliver: D, +) where + D: FnMut(AgenticEvent) -> F, + F: std::future::Future, +{ + let deliver = Arc::new(tokio::sync::Mutex::new(deliver)); + event_loop_batch_driver(event_queue, event_router, move |events| { + let deliver = deliver.clone(); + async move { + for event in events { + let fut = { + let mut guard = deliver.lock().await; + (*guard)(event) + }; + fut.await; + } + } + }) + .await; +} + fn start_event_loop_with_transport( event_queue: Arc, event_router: Arc, @@ -2727,11 +2767,11 @@ fn start_event_loop_with_transport( session_event_journal: Arc, ) { tokio::spawn(async move { - event_loop_driver(event_queue, event_router, |event| { + event_loop_batch_driver(event_queue, event_router, |events| { let transport = transport.clone(); let session_event_journal = session_event_journal.clone(); async move { - deliver_event_to_webview(&transport, event, &session_event_journal).await; + deliver_events_to_webview(&transport, events, &session_event_journal).await; } }) .await; @@ -3131,6 +3171,69 @@ mod event_loop_driver_tests { driver.abort(); producer.abort(); } + + #[tokio::test(start_paused = true)] + async fn batch_driver_delivers_concurrent_sessions_in_one_ordered_batch() { + let queue = Arc::new(EventQueue::new(EventQueueConfig { + max_queue_size: 100, + batch_size: 16, + })); + let router = Arc::new(EventRouter::new()); + let batches: Arc>>> = + Arc::new(tokio::sync::Mutex::new(Vec::new())); + + for (session_id, text) in [ + ("s1", "alpha "), + ("s2", "beta "), + ("s1", "one"), + ("s2", "two"), + ] { + queue + .enqueue( + AgenticEvent::TextChunk { + session_id: session_id.to_string(), + turn_id: "t".to_string(), + round_id: "r".to_string(), + attempt_id: None, + attempt_index: None, + text: text.to_string(), + }, + None, + ) + .await + .expect("enqueue should succeed"); + } + + let driver_queue = queue.clone(); + let driver_batches = batches.clone(); + let driver = tokio::spawn(async move { + event_loop_batch_driver(driver_queue, router, |events| { + let batches = driver_batches.clone(); + async move { + batches.lock().await.push(events); + } + }) + .await; + }); + + for _ in 0..80 { + tokio::time::advance(Duration::from_millis(1)).await; + tokio::task::yield_now().await; + tokio::task::yield_now().await; + } + + let recorded = batches.lock().await; + assert_eq!(recorded.len(), 1, "expected a single batched flush"); + assert_eq!(recorded[0].len(), 2, "expected 2 merged session streams"); + assert!( + matches!(&recorded[0][0], AgenticEvent::TextChunk { session_id, text, .. } if session_id == "s1" && text == "alpha one") + ); + assert!( + matches!(&recorded[0][1], AgenticEvent::TextChunk { session_id, text, .. } if session_id == "s2" && text == "beta two") + ); + + driver.abort(); + } } /// Opt-in native regression entry; requires a disposable fixture launched by diff --git a/src/apps/desktop/src/macos_menubar.rs b/src/apps/desktop/src/macos_menubar.rs index 710ea84d1e..be4c17103c 100644 --- a/src/apps/desktop/src/macos_menubar.rs +++ b/src/apps/desktop/src/macos_menubar.rs @@ -99,6 +99,10 @@ fn labels_for_language(language: &str) -> MenubarLabels { } } +#[cfg(target_os = "macos")] +static LAST_APPLIED_MENUBAR: std::sync::Mutex> = + std::sync::Mutex::new(None); + #[cfg(target_os = "macos")] pub fn set_macos_menubar_with_mode( app: &tauri::AppHandle, @@ -106,8 +110,16 @@ pub fn set_macos_menubar_with_mode( mode: MenubarMode, edit_mode: EditMenuMode, ) -> tauri::Result<()> { - let labels = labels_for_language(language); let _ = mode; + if let Ok(guard) = LAST_APPLIED_MENUBAR.lock() { + if let Some((ref prev_lang, prev_edit_mode)) = *guard { + if prev_lang == language && prev_edit_mode == edit_mode { + return Ok(()); + } + } + } + + let labels = labels_for_language(language); let app_menu = SubmenuBuilder::new(app, "OpenBitFun") .text("openbitfun.about", labels.about_openbitfun) @@ -169,6 +181,9 @@ pub fn set_macos_menubar_with_mode( .build()?; app.set_menu(menu)?; + if let Ok(mut guard) = LAST_APPLIED_MENUBAR.lock() { + *guard = Some((language.to_string(), edit_mode)); + } Ok(()) } diff --git a/src/apps/desktop/src/miniapp_desktop/sdk.rs b/src/apps/desktop/src/miniapp_desktop/sdk.rs index 583c5bcc1f..8d93d0b6da 100644 --- a/src/apps/desktop/src/miniapp_desktop/sdk.rs +++ b/src/apps/desktop/src/miniapp_desktop/sdk.rs @@ -241,7 +241,7 @@ pub(crate) fn install_events(app: &tauri::AppHandle) { } } }); - for event_name in [ + const FORWARDED_AGENTIC_EVENTS: [&str; 10] = [ "dialog-turn-started", "model-round-started", "model-round-completed", @@ -252,38 +252,65 @@ pub(crate) fn install_events(app: &tauri::AppHandle) { "dialog-turn-cancelled", "token-usage-updated", "subagent-session-linked", - ] { + ]; + + fn forward_miniapp_agentic_event( + handle: &tauri::AppHandle, + event_name: &str, + mut payload: Value, + ) { + let Some(session) = payload["sessionId"].as_str().map(str::to_owned) else { + return; + }; + let parent = payload["parentSessionId"].as_str().map(str::to_owned); + payload["sourceEvent"] = json!(event_name); + let host = handle.state::(); + let mut owners = host.sessions.lock().unwrap_or_else(|p| p.into_inner()); + for lease in host + .shells + .lock() + .unwrap_or_else(|p| p.into_inner()) + .values_mut() + { + let sessions = owners.entry(lease.instance_id.clone()).or_default(); + if sessions.contains(&session) + || (event_name == "subagent-session-linked" + && parent + .as_ref() + .is_some_and(|parent| sessions.contains(parent))) + { + sessions.insert(session.clone()); + lease.push("agent:event", payload.clone()); + } + } + } + + for event_name in FORWARDED_AGENTIC_EVENTS { let handle = app.clone(); app.listen(format!("agentic://{event_name}"), move |event| { - let Ok(mut payload) = serde_json::from_str::(event.payload()) else { - return; - }; - let Some(session) = payload["sessionId"].as_str().map(str::to_owned) else { + let Ok(payload) = serde_json::from_str::(event.payload()) else { return; }; - let parent = payload["parentSessionId"].as_str().map(str::to_owned); - payload["sourceEvent"] = json!(event_name); - let host = handle.state::(); - let mut owners = host.sessions.lock().unwrap_or_else(|p| p.into_inner()); - for lease in host - .shells - .lock() - .unwrap_or_else(|p| p.into_inner()) - .values_mut() - { - let sessions = owners.entry(lease.instance_id.clone()).or_default(); - if sessions.contains(&session) - || (event_name == "subagent-session-linked" - && parent - .as_ref() - .is_some_and(|parent| sessions.contains(parent))) - { - sessions.insert(session.clone()); - lease.push("agent:event", payload.clone()); - } - } + forward_miniapp_agentic_event(&handle, event_name, payload); }); } + + let batch_handle = app.clone(); + app.listen(openbitfun_transport::TAURI_EVENT_BATCH_NAME, move |event| { + let Ok(entries) = + serde_json::from_str::>(event.payload()) + else { + return; + }; + for entry in entries { + let Some(short_name) = entry.event.strip_prefix("agentic://") else { + continue; + }; + if FORWARDED_AGENTIC_EVENTS.contains(&short_name) { + forward_miniapp_agentic_event(&batch_handle, short_name, entry.payload); + } + } + }); } pub(crate) fn install_worker_events(app: &tauri::AppHandle, app_id: &str) { diff --git a/src/crates/adapters/transport/src/adapters/mod.rs b/src/crates/adapters/transport/src/adapters/mod.rs index c570b4f180..61e06e1972 100644 --- a/src/crates/adapters/transport/src/adapters/mod.rs +++ b/src/crates/adapters/transport/src/adapters/mod.rs @@ -2,4 +2,4 @@ pub mod tauri; #[cfg(feature = "tauri-adapter")] -pub use tauri::TauriTransportAdapter; +pub use tauri::{TauriBatchedEvent, TauriTransportAdapter, TAURI_EVENT_BATCH_NAME}; diff --git a/src/crates/adapters/transport/src/adapters/tauri.rs b/src/crates/adapters/transport/src/adapters/tauri.rs index ffdce460e9..b7bb0928ac 100644 --- a/src/crates/adapters/transport/src/adapters/tauri.rs +++ b/src/crates/adapters/transport/src/adapters/tauri.rs @@ -8,21 +8,95 @@ use crate::traits::TransportAdapter; use async_trait::async_trait; use log::warn; use openbitfun_events::{project_agentic_frontend_event, AgenticEvent}; +#[cfg(feature = "tauri-adapter")] +use serde::{Deserialize, Serialize}; use std::fmt; #[cfg(feature = "tauri-adapter")] use tauri::{AppHandle, Emitter}; +#[cfg(feature = "tauri-adapter")] +pub const TAURI_EVENT_BATCH_NAME: &str = "agentic://event-batch"; + +#[cfg(feature = "tauri-adapter")] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TauriBatchedEvent { + pub event: String, + pub payload: serde_json::Value, +} + #[cfg(feature = "tauri-adapter")] pub struct TauriTransportAdapter { app_handle: AppHandle, } +#[cfg(feature = "tauri-adapter")] +fn is_batchable_streaming_event(item: &TauriBatchedEvent) -> bool { + if item.event == "agentic://text-chunk" { + return true; + } + if item.event != "agentic://tool-event" { + return false; + } + let Some(tool_event) = item.payload.get("toolEvent") else { + return false; + }; + let tool_name = tool_event + .get("effective_tool_name") + .and_then(|v| v.as_str()) + .or_else(|| tool_event.get("tool_name").and_then(|v| v.as_str())); + if tool_name == Some("AskUserQuestion") { + return false; + } + matches!( + tool_event.get("event_type").and_then(|v| v.as_str()), + Some("ParamsPartial" | "Progress" | "Streaming" | "StreamChunk" | "GenUIStream") + ) +} + #[cfg(feature = "tauri-adapter")] impl TauriTransportAdapter { pub fn new(app_handle: AppHandle) -> Self { Self { app_handle } } + + /// Deliver ordered frontend events while coalescing only contiguous runs of + /// high-frequency streaming chunk events onto `TAURI_EVENT_BATCH_NAME`. + /// Lifecycle and state-transition events always emit on their dedicated + /// channel in arrival order so standalone listeners receive them even when + /// `AgenticEventListener` is inactive. + pub fn emit_batch(&self, events: Vec) -> anyhow::Result<()> { + if events.is_empty() { + return Ok(()); + } + let mut streaming_run: Vec = Vec::new(); + let flush_run = |run: &mut Vec| -> anyhow::Result<()> { + match run.len() { + 0 => Ok(()), + 1 => { + let single = run.pop().expect("single item checked above"); + self.app_handle.emit(&single.event, single.payload)?; + Ok(()) + } + _ => { + let batch = std::mem::take(run); + self.app_handle.emit(TAURI_EVENT_BATCH_NAME, batch)?; + Ok(()) + } + } + }; + + for item in events { + if is_batchable_streaming_event(&item) { + streaming_run.push(item); + } else { + flush_run(&mut streaming_run)?; + self.app_handle.emit(&item.event, item.payload)?; + } + } + flush_run(&mut streaming_run)?; + Ok(()) + } } #[cfg(feature = "tauri-adapter")] @@ -56,3 +130,42 @@ impl TransportAdapter for TauriTransportAdapter { Ok(()) } } + +#[cfg(all(test, feature = "tauri-adapter"))] +mod tests { + use super::{is_batchable_streaming_event, TauriBatchedEvent}; + use serde_json::json; + + #[test] + fn only_high_frequency_streaming_events_are_batchable() { + assert!(is_batchable_streaming_event(&TauriBatchedEvent { + event: "agentic://text-chunk".to_string(), + payload: json!({ "sessionId": "s1", "text": "hi" }), + })); + assert!(is_batchable_streaming_event(&TauriBatchedEvent { + event: "agentic://tool-event".to_string(), + payload: json!({ + "sessionId": "s1", + "toolEvent": { "event_type": "ParamsPartial", "tool_name": "Edit" } + }), + })); + assert!(!is_batchable_streaming_event(&TauriBatchedEvent { + event: "agentic://tool-event".to_string(), + payload: json!({ + "sessionId": "s1", + "toolEvent": { "event_type": "ParamsPartial", "tool_name": "AskUserQuestion" } + }), + })); + assert!(!is_batchable_streaming_event(&TauriBatchedEvent { + event: "agentic://tool-event".to_string(), + payload: json!({ + "sessionId": "s1", + "toolEvent": { "event_type": "Completed", "tool_name": "Edit" } + }), + })); + assert!(!is_batchable_streaming_event(&TauriBatchedEvent { + event: "agentic://dialog-turn-completed".to_string(), + payload: json!({ "sessionId": "s1", "turnId": "t1" }), + })); + } +} diff --git a/src/crates/adapters/transport/src/lib.rs b/src/crates/adapters/transport/src/lib.rs index c9abff0db1..b6328dc83c 100644 --- a/src/crates/adapters/transport/src/lib.rs +++ b/src/crates/adapters/transport/src/lib.rs @@ -11,4 +11,4 @@ pub use json::{encode_json_with_limit, JsonCodecError}; pub use traits::TransportAdapter; #[cfg(feature = "tauri-adapter")] -pub use adapters::TauriTransportAdapter; +pub use adapters::{TauriBatchedEvent, TauriTransportAdapter, TAURI_EVENT_BATCH_NAME}; diff --git a/src/crates/assembly/core/src/service/workspace/service.rs b/src/crates/assembly/core/src/service/workspace/service.rs index e4d2243459..70c85d539f 100644 --- a/src/crates/assembly/core/src/service/workspace/service.rs +++ b/src/crates/assembly/core/src/service/workspace/service.rs @@ -255,6 +255,15 @@ impl WorkspaceService { } async fn ensure_workspace_runtime_best_effort(&self, workspace: &WorkspaceInfo, trigger: &str) { + Self::ensure_workspace_runtime_for_workspace(&self.runtime_service, workspace, trigger) + .await; + } + + async fn ensure_workspace_runtime_for_workspace( + runtime_service: &WorkspaceRuntimeService, + workspace: &WorkspaceInfo, + trigger: &str, + ) { let result = match workspace.workspace_kind { WorkspaceKind::Remote => { let Some(ssh_host) = workspace @@ -278,7 +287,7 @@ impl WorkspaceService { workspace.remote_ssh_connection_id(), &workspace.root_path.to_string_lossy(), ); - self.runtime_service + runtime_service .ensure_remote_workspace_runtime( &identity_host, &workspace.root_path.to_string_lossy(), @@ -290,7 +299,7 @@ impl WorkspaceService { return; } - self.runtime_service + runtime_service .ensure_local_workspace_runtime(&workspace.root_path) .await } @@ -1067,8 +1076,15 @@ impl WorkspaceService { if result.is_ok() { if let Some(workspace) = self.get_workspace(workspace_id).await { - self.ensure_workspace_runtime_best_effort(&workspace, "activated") + let runtime_service = self.runtime_service.clone(); + tokio::spawn(async move { + Self::ensure_workspace_runtime_for_workspace( + &runtime_service, + &workspace, + "activated", + ) .await; + }); } } diff --git a/src/web-ui/src/app/components/NavPanel/AskUserAnnouncer.tsx b/src/web-ui/src/app/components/NavPanel/AskUserAnnouncer.tsx index 81622afc10..2983c3989b 100644 --- a/src/web-ui/src/app/components/NavPanel/AskUserAnnouncer.tsx +++ b/src/web-ui/src/app/components/NavPanel/AskUserAnnouncer.tsx @@ -51,6 +51,9 @@ export default function AskUserAnnouncer() { const update = () => { const current = collectWaitingTitles(); const prev = prevWaitingRef.current; + if (prev.size === 0 && current.size === 0) { + return; + } const message = computeAnnouncementMessage(prev, current, tRef.current); if (message && liveRef.current) { diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index 196857bb08..a59d76e097 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -39,6 +39,7 @@ import { resolveSessionRelationship } from '@/flow_chat/utils/sessionMetadata'; import { isWorktreeIsolatedSession, sessionBelongsToWorkspaceNavRow, + sessionOwningWorkspaceId, } from '@/flow_chat/utils/sessionOrdering'; import { sessionWorktreeRootPath } from '@/flow_chat/utils/sessionWorktree'; import { @@ -460,14 +461,65 @@ const SessionsSection: React.FC = ({ const metadataLoadRequestIdRef = useRef(0); const initialMetadataLoadKeyRef = useRef(null); const sessionListRef = useRef(null); + const isVisibleRef = useRef(isVisible); + isVisibleRef.current = isVisible; + const scheduledJobsSessionIdRef = useRef(scheduledJobsSessionId); + scheduledJobsSessionIdRef.current = scheduledJobsSessionId; + const pendingFlowChatSyncRef = useRef(false); + + const workspaceScopesKey = useMemo( + () => workspaceScopes?.map(scope => scope.workspaceId).join('|') ?? '', + [workspaceScopes], + ); + const scopedWorkspaceIdsKey = workspaceScopes?.length ? `scopes:${workspaceScopesKey}` : `ws:${workspaceId ?? ''}`; + const hasMountedScopeSelectorRef = useRef(false); useEffect(() => { - const selector = createSessionsNavSelector(); + const scopedWorkspaceIds = scopedWorkspaceIdsKey.startsWith('scopes:') + ? scopedWorkspaceIdsKey.slice('scopes:'.length).split('|').filter(Boolean) + : scopedWorkspaceIdsKey.length > 'ws:'.length + ? [scopedWorkspaceIdsKey.slice('ws:'.length)] + : []; + const scopedWorkspaceIdSet = scopedWorkspaceIds.length > 0 ? new Set(scopedWorkspaceIds) : null; + const selector = createSessionsNavSelector({ + includeSession: session => { + if (scheduledJobsSessionIdRef.current && session.sessionId === scheduledJobsSessionIdRef.current) { + return true; + } + if (session.isTransient || session.sessionKind === 'subagent') { + return false; + } + const ownerId = sessionOwningWorkspaceId(session); + return scopedWorkspaceIdSet ? (ownerId ? scopedWorkspaceIdSet.has(ownerId) : false) : !ownerId; + }, + }); + if (hasMountedScopeSelectorRef.current) { + if (isVisibleRef.current) { + pendingFlowChatSyncRef.current = false; + setFlowChatState(flowChatStore.getState()); + } else { + pendingFlowChatSyncRef.current = true; + } + } else { + hasMountedScopeSelectorRef.current = true; + } const unsub = flowChatStore.subscribeSelector(selector, (() => { + if (!isVisibleRef.current) { + pendingFlowChatSyncRef.current = true; + return; + } + pendingFlowChatSyncRef.current = false; setFlowChatState(flowChatStore.getState()); })); return () => unsub(); - }, []); + }, [scopedWorkspaceIdsKey]); + + useEffect(() => { + if (isVisible && pendingFlowChatSyncRef.current) { + pendingFlowChatSyncRef.current = false; + setFlowChatState(flowChatStore.getState()); + } + }, [isVisible]); const backgroundSubagentActivityByParent = useMemo(() => { const itemsByParent = new Map(); @@ -511,11 +563,6 @@ const SessionsSection: React.FC = ({ return () => { metadataLoadRequestIdRef.current += 1; }; }, [initialMetadataKey]); - const workspaceScopesKey = useMemo( - () => workspaceScopes?.map(scope => scope.workspaceId).join('|') ?? '', - [workspaceScopes], - ); - useEffect(() => { if (!isVisible || !workspaceScopes?.length) { setAggregateLoadState({ isLoading: false, failedScopeCount: 0 }); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionsNavSelector.test.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionsNavSelector.test.ts index 0505797a2c..194a614dc9 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionsNavSelector.test.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionsNavSelector.test.ts @@ -42,4 +42,22 @@ describe('createSessionsNavSelector', () => { expect(select(state([{ ...first, title: 'Renamed', needsUserAttention: 'ask_user' }, second]))).toBe(3); expect(select(state([second, { ...first, title: 'Renamed', needsUserAttention: 'ask_user' }]))).toBe(4); }); + + it('scopes updates and selection changes to included workspace sessions', () => { + const selectWorkspaceA = createSessionsNavSelector({ + includeSession: item => item.workspaceId === 'ws-a', + }); + const a1 = session({ sessionId: 'a-1', workspaceId: 'ws-a' }); + const b1 = session({ sessionId: 'b-1', workspaceId: 'ws-b' }); + const b2 = session({ sessionId: 'b-2', workspaceId: 'ws-b' }); + + expect(selectWorkspaceA(state([a1, b1, b2], 'b-1'))).toBe(1); + // Switching activeSessionId or mutating titles inside ws-b does not notify ws-a. + expect(selectWorkspaceA(state([a1, { ...b1, title: 'Updated B1' }, b2], 'b-2'))).toBe(1); + // Selecting a-1 inside ws-a increments ws-a's revision. + expect(selectWorkspaceA(state([a1, b1, b2], 'a-1'))).toBe(2); + // Deselecting a-1 back to ws-b increments ws-a's revision once, then stabilizes. + expect(selectWorkspaceA(state([a1, b1, b2], 'b-1'))).toBe(3); + expect(selectWorkspaceA(state([a1, b1, b2], 'b-2'))).toBe(3); + }); }); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionsNavSelector.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionsNavSelector.ts index e0c0e27cf3..04c6c4667f 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionsNavSelector.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionsNavSelector.ts @@ -1,9 +1,18 @@ import type { FlowChatState, Session } from '../../../../../flow_chat/types/flow-chat'; +import { sessionOwningWorkspaceId } from '../../../../../flow_chat/utils/sessionOrdering'; type DispatchTargetKind = 'local' | 'ssh' | 'device'; +export interface SessionsNavSelectorOptions { + includeSession?: (session: Session) => boolean; +} + interface SessionNavSignature { + sessionRef: Session; + isSelected: boolean; sessionId: string; + owningWorkspaceId: string | undefined; + persistedStatus: Session['persistedStatus']; importedSource: string | undefined; isTransient: boolean | undefined; sessionKind: Session['sessionKind']; @@ -24,12 +33,16 @@ interface SessionNavSignature { dispatchJobState: Session['config']['dispatchJobState']; } -function readSessionNavSignature(session: Session): SessionNavSignature { +function readSessionNavSignature(session: Session, isSelected: boolean): SessionNavSignature { const latestTurn = session.dialogTurns[session.dialogTurns.length - 1]; const dispatchTarget = session.config.dispatchTarget; return { + sessionRef: session, + isSelected, sessionId: session.sessionId, + owningWorkspaceId: sessionOwningWorkspaceId(session), + persistedStatus: session.persistedStatus, importedSource: session.importedSource, isTransient: session.isTransient, sessionKind: session.sessionKind, @@ -57,6 +70,8 @@ function sameSessionNavSignature(previous: SessionNavSignature, session: Session const latestTurn = session.dialogTurns[session.dialogTurns.length - 1]; return previous.sessionId === session.sessionId + && previous.owningWorkspaceId === sessionOwningWorkspaceId(session) + && previous.persistedStatus === session.persistedStatus && previous.importedSource === session.importedSource && previous.isTransient === session.isTransient && previous.sessionKind === session.sessionKind @@ -84,28 +99,48 @@ function sameSessionNavSignature(previous: SessionNavSignature, session: Session * primitive revision as its result, so stream updates do not rebuild a large * joined string merely to discover that the navigation did not change. */ -export function createSessionsNavSelector(): (state: FlowChatState) => number { - let activeSessionId: string | null | undefined; +export function createSessionsNavSelector( + options?: SessionsNavSelectorOptions, +): (state: FlowChatState) => number { + const includeSession = options?.includeSession; let orderedSessionIds: string[] = []; const signatures = new Map(); let revision = 0; return (state: FlowChatState): number => { - let changed = activeSessionId !== state.activeSessionId - || orderedSessionIds.length !== state.sessions.size; - let orderChanged = orderedSessionIds.length !== state.sessions.size; + let changed = false; + let orderChanged = false; let index = 0; + let nextOrderedIds: string[] | null = null; for (const session of state.sessions.values()) { + if (includeSession && !includeSession(session)) { + continue; + } + if (orderedSessionIds[index] !== session.sessionId) { changed = true; orderChanged = true; } + if (orderChanged) { + if (!nextOrderedIds) { + nextOrderedIds = orderedSessionIds.slice(0, index); + } + nextOrderedIds.push(session.sessionId); + } + const isSelected = state.activeSessionId === session.sessionId; const previous = signatures.get(session.sessionId); - if (!previous || !sameSessionNavSignature(previous, session)) { - signatures.set(session.sessionId, readSessionNavSignature(session)); + if (previous && previous.sessionRef === session && previous.isSelected === isSelected) { + index += 1; + continue; + } + + if (!previous || previous.isSelected !== isSelected || !sameSessionNavSignature(previous, session)) { + signatures.set(session.sessionId, readSessionNavSignature(session, isSelected)); changed = true; + } else { + previous.sessionRef = session; } index += 1; } @@ -113,16 +148,25 @@ export function createSessionsNavSelector(): (state: FlowChatState) => number { if (index !== orderedSessionIds.length) { changed = true; orderChanged = true; + if (!nextOrderedIds) { + nextOrderedIds = orderedSessionIds.slice(0, index); + } } - if (changed) { - revision += 1; - activeSessionId = state.activeSessionId; - if (orderChanged) { - orderedSessionIds = Array.from(state.sessions.keys()); - for (const sessionId of signatures.keys()) { - if (!state.sessions.has(sessionId)) { - signatures.delete(sessionId); + if (changed || revision === 0) { + // Initial read on an empty session list still establishes revision 0 -> 0 unless non-empty; + // preserve exact behavior when empty vs populated. + if (changed) { + revision += 1; + } + if (orderChanged && nextOrderedIds) { + orderedSessionIds = nextOrderedIds; + if (signatures.size !== orderedSessionIds.length) { + const retainedIds = new Set(orderedSessionIds); + for (const sessionId of signatures.keys()) { + if (!retainedIds.has(sessionId)) { + signatures.delete(sessionId); + } } } } diff --git a/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts b/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts index 9f98a0e823..35ed0fbf86 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts @@ -1205,10 +1205,30 @@ function extractAgentPersistableState(state: CanvasStore): CanvasStoreState { }; } +function shallowCloneEditorGroup(group: EditorGroupState): EditorGroupState { + return { + ...group, + tabs: [...group.tabs], + }; +} + +function shallowCloneCanvasSnapshot(snapshot: CanvasStoreState): CanvasStoreState { + return { + primaryGroup: shallowCloneEditorGroup(snapshot.primaryGroup), + secondaryGroup: shallowCloneEditorGroup(snapshot.secondaryGroup), + tertiaryGroup: shallowCloneEditorGroup(snapshot.tertiaryGroup), + activeGroupId: snapshot.activeGroupId, + layout: { ...snapshot.layout }, + isMissionControlOpen: snapshot.isMissionControlOpen, + draggingTabId: null, + draggingFromGroupId: null, + closedTabs: [...snapshot.closedTabs], + maxClosedTabsHistory: snapshot.maxClosedTabsHistory, + }; +} + function rememberAgentSnapshot(key: string, snapshot: CanvasStoreState): void { - const clone = structuredClone(snapshot); - clone.draggingTabId = null; - clone.draggingFromGroupId = null; + const clone = shallowCloneCanvasSnapshot(snapshot); agentScopeSnapshots.set(key, clone); const idx = agentSnapshotLruOrder.indexOf(key); if (idx >= 0) agentSnapshotLruOrder.splice(idx, 1); @@ -1236,7 +1256,7 @@ export function openAgentCanvasTabForSession(sessionId: string, content: PanelCo const target = live.scopeKey === key ? useAgentCanvasStore : createCanvasStoreHook(); if (target !== useAgentCanvasStore) { const snapshot = agentScopeSnapshots.get(key); - if (snapshot) target.setState(structuredClone(snapshot)); + if (snapshot) target.setState(shallowCloneCanvasSnapshot(snapshot)); target.setState({ scopeKey: key }); } const state = target.getState(); @@ -1266,7 +1286,7 @@ export function updateAgentCanvasTabsForSession( if (target !== useAgentCanvasStore) { const snapshot = agentScopeSnapshots.get(key); if (!snapshot) return false; - target.setState(structuredClone(snapshot)); + target.setState(shallowCloneCanvasSnapshot(snapshot)); } const state = target.getState(); let updated = false; @@ -1328,8 +1348,8 @@ export function removeBtwSessionFromAgentCanvas(sessionId: string): void { for (const [scope, snapshot] of agentScopeSnapshots) { const groups = [snapshot.primaryGroup, snapshot.secondaryGroup, snapshot.tertiaryGroup]; if (!groups.some(group => group.tabs.some(ownsSession)) && !snapshot.closedTabs.some(record => ownsSession(record.tab))) continue; - // Restoring a scope can freeze shared groups through Immer; revise a copy. - const next = structuredClone(snapshot); + // Restoring a scope can freeze shared groups through Immer; revise a shallow copy. + const next = shallowCloneCanvasSnapshot(snapshot); for (const group of [next.primaryGroup, next.secondaryGroup, next.tertiaryGroup]) { group.tabs = group.tabs.filter(tab => !ownsSession(tab)); if (!group.tabs.some(tab => tab.id === group.activeTabId && !tab.isHidden)) { @@ -1383,7 +1403,7 @@ export function switchAgentCanvasScope(nextScopeId: string | null | undefined): if (from === to) return; const rawNext = agentScopeSnapshots.get(to); - const nextSnapshotClone = rawNext ? structuredClone(rawNext) : null; + const nextSnapshotClone = rawNext ? shallowCloneCanvasSnapshot(rawNext) : null; if (from !== undefined) { const current = extractAgentPersistableState(useAgentCanvasStore.getState() as CanvasStore); diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index c9f3f5105c..e3eb3efd85 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -270,11 +270,15 @@ const AppLayout: React.FC = ({ className = '' }) => { return () => { unlistenFns.forEach(fn => fn()); unlistenFns = []; }; }, [isMacOS, openWorkspace, handleNewProject, handleShowAbout, t]); + const currentWorkspaceRef = useRef(currentWorkspace); + currentWorkspaceRef.current = currentWorkspace; + // Initialize FlowChatManager React.useEffect(() => { let cancelled = false; const initializeFlowChat = async () => { - if (!currentWorkspace?.rootPath) return; + const targetWorkspace = currentWorkspaceRef.current; + if (!targetWorkspace?.rootPath) return; const explicitOpen = getDeviceOpenIntent(); // Remote session index and turns live under ~/.openbitfun/remote_ssh/... (local disk). @@ -288,12 +292,12 @@ const AppLayout: React.FC = ({ className = '' }) => { } const initializationPreferredMode = - currentWorkspace.workspaceKind === WorkspaceKind.Assistant + targetWorkspace.workspaceKind === WorkspaceKind.Assistant ? 'Claw' : explicitPreferredMode; const flowChatManager = FlowChatManager.getInstance(); - const hasHistoricalSessions = await flowChatManager.initialize(currentWorkspace, initializationPreferredMode); + const hasHistoricalSessions = await flowChatManager.initialize(targetWorkspace, initializationPreferredMode); if (cancelled) { return; } @@ -309,11 +313,11 @@ const AppLayout: React.FC = ({ className = '' }) => { } if (!hasHistoricalSessions) { const initialSessionMode = - currentWorkspace.workspaceKind === WorkspaceKind.Assistant + targetWorkspace.workspaceKind === WorkspaceKind.Assistant ? 'Claw' : explicitPreferredMode; sessionId = await flowChatManager.createChatDraft( - flowChatSessionConfigForWorkspace(currentWorkspace), + flowChatSessionConfigForWorkspace(targetWorkspace), initialSessionMode, ); if (cancelled) { @@ -391,7 +395,6 @@ const AppLayout: React.FC = ({ className = '' }) => { cancelled = true; }; }, [ - currentWorkspace, currentWorkspace?.id, currentWorkspace?.rootPath, currentWorkspace?.workspaceKind, diff --git a/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts b/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts index c65fb8ae0f..86896a3782 100644 --- a/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts +++ b/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts @@ -852,7 +852,10 @@ export function useMiniAppBridge( ), ); + const unlistenBatch = api.listen('agentic://event-batch', () => {}); + return () => { + unlistenBatch(); unlistenSubagentLink(); unlisteners.forEach((unlisten) => unlisten()); }; diff --git a/src/web-ui/src/app/services/sessionSceneLifecycle.ts b/src/web-ui/src/app/services/sessionSceneLifecycle.ts index 281b811b85..e1ab14944d 100644 --- a/src/web-ui/src/app/services/sessionSceneLifecycle.ts +++ b/src/web-ui/src/app/services/sessionSceneLifecycle.ts @@ -1,4 +1,7 @@ import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import type { FlowChatState, Session } from '@/flow_chat/types/flow-chat'; +import { sessionOwningWorkspaceId } from '@/flow_chat/utils/sessionOrdering'; +import { sessionProjectWorkspacePath } from '@/flow_chat/utils/sessionWorkspace'; import { startAutoSync } from '@/flow_chat/services/storeSync'; import { workspaceManager, type WorkspaceEvent } from '@/infrastructure/services/business/workspaceManager'; import { getActiveSurfaceId, isSurfaceChangedError } from '@/infrastructure/peer-device/deviceSurface'; @@ -12,10 +15,48 @@ import { selectedSessionTab, tabPane } from '../workbench/scenePaneModel'; const log = createLogger('SessionSceneLifecycle'); -/** - * Tabs own one resource reference per workspace; FlowChat owns all sessions. - * The shell coordinates navigation with the one active-session presentation. - */ +interface TrackedSessionSceneSignature { + present: boolean; + sessionRef?: Session; + owningWorkspaceId?: string; + projectWorkspacePath?: string; + remoteConnectionId?: string; + remoteSshHost?: string; +} + +function sameSessionSceneIdentity(previous: TrackedSessionSceneSignature, session: Session | undefined): boolean { + if (!session) { + return !previous.present; + } + if (!previous.present) { + return false; + } + if (previous.sessionRef === session) { + return true; + } + const matches = previous.owningWorkspaceId === sessionOwningWorkspaceId(session) + && previous.projectWorkspacePath === sessionProjectWorkspacePath(session) + && previous.remoteConnectionId === (session.remoteConnectionId || session.config?.remoteConnectionId) + && previous.remoteSshHost === (session.remoteSshHost || session.config?.remoteSshHost); + if (matches) { + previous.sessionRef = session; + } + return matches; +} + +function readSessionSceneIdentity(session: Session | undefined): TrackedSessionSceneSignature { + if (!session) { + return { present: false }; + } + return { + present: true, + sessionRef: session, + owningWorkspaceId: sessionOwningWorkspaceId(session), + projectWorkspacePath: sessionProjectWorkspacePath(session), + remoteConnectionId: session.remoteConnectionId || session.config?.remoteConnectionId, + remoteSshHost: session.remoteSshHost || session.config?.remoteSshHost, + }; +} export function startSessionSceneLifecycle(): () => void { const stopProjectionSync = startAutoSync(); const stopPaneLayoutSync = startSessionPaneLayoutSync(); @@ -74,6 +115,39 @@ export function startSessionSceneLifecycle(): () => void { let previousSurface = getActiveSurfaceId(); let previousWorkspaces = new Map(workspaceManager.getState().openedWorkspaces); let reconciling = false; + let trackedSurfaceId = previousSurface; + let trackedActiveSessionId: string | null = flowChatStore.getState().activeSessionId; + const trackedSessionSignatures = new Map(); + + const syncTrackedFlowChatSignature = (state: FlowChatState, surfaceId: string) => { + trackedSurfaceId = surfaceId; + trackedActiveSessionId = state.activeSessionId; + trackedSessionSignatures.clear(); + if (state.activeSessionId) { + trackedSessionSignatures.set( + state.activeSessionId, + readSessionSceneIdentity(state.sessions.get(state.activeSessionId)), + ); + } + for (const tab of useSceneStore.getState().openTabs) { + if (!tab.session || tab.session.surfaceId !== surfaceId) continue; + const tabSessionId = tab.session.sessionId; + if (!trackedSessionSignatures.has(tabSessionId)) { + trackedSessionSignatures.set(tabSessionId, readSessionSceneIdentity(state.sessions.get(tabSessionId))); + } + if (tab.sessionHistory) { + for (const historySessionId of tab.sessionHistory) { + if (!trackedSessionSignatures.has(historySessionId)) { + trackedSessionSignatures.set( + historySessionId, + readSessionSceneIdentity(state.sessions.get(historySessionId)), + ); + } + } + } + } + }; + const reconcile = (event?: WorkspaceEvent) => { if (reconciling) return; reconciling = true; @@ -129,12 +203,29 @@ export function startSessionSceneLifecycle(): () => void { } } useSceneStore.getState().reconcileSessionScenes(targets); + syncTrackedFlowChatSignature(flowChatStore.getState(), surfaceId); } finally { reconciling = false; } }; - const unsubscribeSessions = flowChatStore.subscribe(() => reconcile()); + const hasFlowChatSceneInputChanged = (state: FlowChatState): boolean => { + if (getActiveSurfaceId() !== trackedSurfaceId || state.activeSessionId !== trackedActiveSessionId) { + return true; + } + for (const [sessionId, previousSignature] of trackedSessionSignatures) { + if (!sameSessionSceneIdentity(previousSignature, state.sessions.get(sessionId))) { + return true; + } + } + return false; + }; + + const unsubscribeSessions = flowChatStore.subscribe(state => { + if (hasFlowChatSceneInputChanged(state)) { + reconcile(); + } + }); const unsubscribeScenes = useSceneStore.subscribe(() => reconcile()); const unsubscribeWorkspaces = workspaceManager.addEventListener(reconcile); // A held cross-device tab is released (or adopted) when its open settles. diff --git a/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx b/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx index e0a0c6a59f..ab7fc196b9 100644 --- a/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx +++ b/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx @@ -17,11 +17,30 @@ import { parseDeepResearchContent } from '../deep-research/deepResearchProtocol' import { hasSessionFileProvider, readImageThroughSession, downloadFileThroughSession } from '../session-drivers/sessionFileNavigation'; import { resolveSessionDriverId } from '../session-drivers/resolve'; import { markdownSections } from '@/infrastructure/markdown/markdownSections'; +import { prewarmMarkdownParse } from '@/infrastructure/markdown/markdownParseCache'; import './FlowTextBlock.scss'; // Idle timeout (ms) after content stops growing. const CONTENT_IDLE_TIMEOUT = 500; +function shallowEqualRecord( + a?: Record, + b?: Record, +): boolean { + if (a === b) return true; + if (!a || !b) return false; + const keysA = Object.keys(a); + const keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + for (let i = 0; i < keysA.length; i += 1) { + const key = keysA[i]; + if (!Object.prototype.hasOwnProperty.call(b, key) || a[key] !== b[key]) { + return false; + } + } + return true; +} + interface FlowTextBlockProps { textItem: FlowTextItem; className?: string; @@ -96,9 +115,23 @@ export const FlowTextBlock = React.memo(({ const isStreaming = textItem.isStreaming && (textItem.status === 'streaming' || textItem.status === 'running') && (!markdownSection || markdownSection.end === fullContent.length); + const trailingSectionLenRef = useRef(0); + const activeTailLength = trailingSectionLenRef.current; + const adaptiveMinPaintIntervalMs = activeTailLength >= 10_000 + ? 48 + : activeTailLength >= 4_000 + ? 32 + : undefined; + const adaptiveMaxCharsPerPaint = activeTailLength >= 10_000 + ? 96 + : activeTailLength >= 4_000 + ? 48 + : undefined; const { displayText: displayContent, isRevealing } = useTypewriter(content, isStreaming, { replayOnMount: replayStreamingOnMount, revealImmediately: Boolean(markdownSection && markdownSection.end < fullContent.length), + minPaintIntervalMs: adaptiveMinPaintIntervalMs, + maxCharsPerPaint: adaptiveMaxCharsPerPaint, }); useReportTypewriterReveal(textItem.id, isRevealing); // Keep streaming render mode until the typewriter finishes draining so the @@ -162,7 +195,16 @@ export const FlowTextBlock = React.memo(({ // Keep streaming chrome while either the model is actively emitting or the // typewriter is still revealing leftover characters. const isActivelyStreaming = (isStreaming && isContentGrowing) || isRevealing; - const markdownTraceContext = isStartupRenderTraceEnabled() ? traceContext : undefined; + const renderTraceOn = isStartupRenderTraceEnabled(); + const traceTurnId = traceContext?.turnId; + const traceRoundId = traceContext?.roundId; + const traceItemId = traceContext?.itemId; + const markdownTraceContext = useMemo( + () => (renderTraceOn && (traceTurnId || traceRoundId || traceItemId) + ? { turnId: traceTurnId, roundId: traceRoundId, itemId: traceItemId } + : undefined), + [renderTraceOn, traceTurnId, traceRoundId, traceItemId], + ); const parsedContent = useMemo( () => parseDeepResearchContent(displayContent), [displayContent], @@ -172,6 +214,40 @@ export const FlowTextBlock = React.memo(({ const sections = useMemo(() => markdownSection || parsedContent.hasProtocol || !textItem.isMarkdown ? [{ start: 0, end: displayContent.length }] : markdownSections(displayContent, { streaming: isVisuallyStreaming }), [displayContent, isVisuallyStreaming, markdownSection, parsedContent.hasProtocol, textItem.isMarkdown]); + const lastSection = sections[sections.length - 1]; + trailingSectionLenRef.current = lastSection ? lastSection.end - lastSection.start : 0; + + const prewarmedSectionCountRef = useRef(0); + const prewarmedItemIdRef = useRef(textItem.id); + const prewarmCancelsRef = useRef void>>([]); + useEffect(() => { + if (prewarmedItemIdRef.current !== textItem.id) { + prewarmedItemIdRef.current = textItem.id; + prewarmCancelsRef.current.forEach(cancel => cancel()); + prewarmCancelsRef.current = []; + prewarmedSectionCountRef.current = 0; + } + const sealedCount = Math.max(0, sections.length - 1); + if (sealedCount < prewarmedSectionCountRef.current) { + prewarmCancelsRef.current.forEach(cancel => cancel()); + prewarmCancelsRef.current = []; + prewarmedSectionCountRef.current = 0; + } + if (sealedCount <= prewarmedSectionCountRef.current) { + return; + } + for (let i = prewarmedSectionCountRef.current; i < sealedCount; i += 1) { + const sec = sections[i]; + if (sec) { + prewarmCancelsRef.current.push(prewarmMarkdownParse(displayContent.slice(sec.start, sec.end))); + } + } + prewarmedSectionCountRef.current = sealedCount; + }, [displayContent, sections, textItem.id]); + useEffect(() => () => { + prewarmCancelsRef.current.forEach(cancel => cancel()); + prewarmCancelsRef.current = []; + }, []); const renderMarkdown = (markdownContent: string, key?: React.Key, streaming = markdownStreaming, continuation = false) => ( (({ prev.status === next.status && prevProps.className === nextProps.className && prevProps.replayStreamingOnMount === nextProps.replayStreamingOnMount && - prevProps.traceContext === nextProps.traceContext && + shallowEqualRecord( + prevProps.traceContext as Record | undefined, + nextProps.traceContext as Record | undefined, + ) && prevProps.testId === nextProps.testId && prevProps.hidden === nextProps.hidden && prevProps.markdownSection?.start === nextProps.markdownSection?.start && prevProps.markdownSection?.end === nextProps.markdownSection?.end && - prevProps.testAttributes === nextProps.testAttributes + shallowEqualRecord( + prevProps.testAttributes as Record | undefined, + nextProps.testAttributes as Record | undefined, + ) ); }); diff --git a/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.tsx b/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.tsx index 4d3b6b2a97..f7bae57d0c 100644 --- a/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.tsx +++ b/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.tsx @@ -218,6 +218,27 @@ export const TaskDetailPanel: React.FC = ({ data }) => { const contentRef = useRef(null); // Track auto-scroll; disable when the user scrolls up. const shouldAutoScrollRef = useRef(true); + const leasedSubagentSessionId = taskSnapshot.subagentSessionId || projectionSubagentSessionId; + + useEffect(() => { + const store = FlowChatStore.getInstance(); + const releases: Array<() => void> = []; + if (sessionId && typeof store.retainSessionHistory === 'function') { + releases.push(store.retainSessionHistory(sessionId)); + } + if ( + leasedSubagentSessionId && + leasedSubagentSessionId !== sessionId && + typeof store.retainSessionHistory === 'function' + ) { + releases.push(store.retainSessionHistory(leasedSubagentSessionId)); + } + return () => { + for (const release of releases) { + release(); + } + }; + }, [leasedSubagentSessionId, sessionId]); // Collect only the state this detail panel cares about, and avoid re-rendering // when unrelated flow-chat updates arrive. diff --git a/src/web-ui/src/flow_chat/components/ThinkingDetailPanel.tsx b/src/web-ui/src/flow_chat/components/ThinkingDetailPanel.tsx index c7b0dba7da..25aa224bd7 100644 --- a/src/web-ui/src/flow_chat/components/ThinkingDetailPanel.tsx +++ b/src/web-ui/src/flow_chat/components/ThinkingDetailPanel.tsx @@ -1,4 +1,4 @@ -import { useMemo, useSyncExternalStore } from 'react'; +import { useEffect, useMemo, useSyncExternalStore } from 'react'; import { Button, ScrollArea } from '@openbitfun/ui'; import { useContainedTailFollow } from '@openbitfun/flow-chat-presentation/scroll'; import { useI18n } from '@/infrastructure/i18n'; @@ -31,6 +31,12 @@ function findThinkingItem(turns: Session['dialogTurns'], itemId: string): FlowTh export function ThinkingDetailPanel({ data }: { data: ThinkingDetailPanelData }) { const { t } = useI18n('flow-chat'); const surface = useSyncExternalStore(onSurfaceActivated, getActiveSurfaceScope, getActiveSurfaceScope); + useEffect(() => { + if (!data.sessionId || surface.surfaceId !== data.surfaceId) { + return undefined; + } + return flowChatStore.retainSessionHistory?.(data.sessionId); + }, [data.sessionId, data.surfaceId, surface.surfaceId, surface.epoch]); const source = useMemo(() => { let snapshot = data.thinkingItem; let previousTurns: Session['dialogTurns'] | undefined; diff --git a/src/web-ui/src/flow_chat/components/modern/FileEditGroupView.tsx b/src/web-ui/src/flow_chat/components/modern/FileEditGroupView.tsx index 702a745644..09b36cfcc6 100644 --- a/src/web-ui/src/flow_chat/components/modern/FileEditGroupView.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FileEditGroupView.tsx @@ -13,7 +13,7 @@ export const FileEditGroupView = React.forwardRef(function FileEditGroupView({ items, sessionId, fileRevision, ...props }, ref) { const { t, formatNumber } = useI18n('flow-chat'); - const { surfaceEpoch, snapshotsAvailable } = useSnapshotState(sessionId); + const { surfaceEpoch, snapshotsAvailable } = useSnapshotState(sessionId, { trackFiles: false }); const tools = useMemo(() => items.filter((item): item is FlowToolItem => item.type === 'tool'), [items]); const diffStats = useFileOperationDiffStats(tools, { sessionId, surfaceEpoch, snapshotsAvailable }); const total = sumFileOperationDiffStats(diffStats.values()); 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 67d857fd1e..15c6622fb7 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -1115,8 +1115,11 @@ export const ModernFlowChatContainer: React.FC = ( [activeSession?.dialogTurns, renderedHistoryPresentation], ); const [projectTurnSummaries] = useState(createFlowChatTurnSummaryProjector); - const { turnSummaries, renderedTurnSummaries, absoluteRenderedTurnSummaries } = projectTurnSummaries( - activeSession, renderedTurns, renderedHistoryPresentation?.range.startOrdinal, + const { turnSummaries, renderedTurnSummaries, absoluteRenderedTurnSummaries } = useMemo( + () => projectTurnSummaries( + activeSession, renderedTurns, renderedHistoryPresentation?.range.startOrdinal, + ), + [projectTurnSummaries, activeSession, renderedTurns, renderedHistoryPresentation?.range.startOrdinal], ); const activeTurnCatalog = activeSession?.turnCatalog; const turnCatalog = activeTurnCatalog?.sessionId === activeSession?.sessionId @@ -2533,11 +2536,12 @@ export const ModernFlowChatContainer: React.FC = ( }); }, [backgroundCommands, stoppingBackgroundCommandIds.size]); + const activeSessionRemoteSshHost = activeSession?.remoteSshHost; const handleOpenSessionTreeSession = useCallback((selection: SessionTreeSelection) => { if ( - !activeSession?.sessionId || + !activeSessionId || selection.isRoot || - selection.sessionId === activeSession.sessionId || + selection.sessionId === activeSessionId || !selection.parentSessionId ) { return; @@ -2546,18 +2550,24 @@ export const ModernFlowChatContainer: React.FC = ( openBtwSessionInAuxPane({ childSessionId: selection.sessionId, parentSessionId: selection.parentSessionId, - workspaceId: selection.workspaceId || activeSession.workspaceId || activeSession.config?.workspaceId, - workspacePath: selection.workspacePath || activeSession.workspacePath, + workspaceId: selection.workspaceId || activeSessionWorkspaceId, + workspacePath: selection.workspacePath || activeSessionWorkspacePath, sessionKind: 'subagent', sessionTitle: selection.displayTitle, agentType: selection.agentType, parentToolCallId: selection.parentToolCallId, subagentType: selection.subagentType, - remoteConnectionId: selection.remoteConnectionId || activeSession.remoteConnectionId, - remoteSshHost: selection.remoteSshHost || activeSession.remoteSshHost, + remoteConnectionId: selection.remoteConnectionId || activeSessionRemoteConnectionId, + remoteSshHost: selection.remoteSshHost || activeSessionRemoteSshHost, includeInternal: true, }); - }, [activeSession]); + }, [ + activeSessionId, + activeSessionWorkspaceId, + activeSessionWorkspacePath, + activeSessionRemoteConnectionId, + activeSessionRemoteSshHost, + ]); const handleCancelSessionTreeSession = useCallback(async (selection: SessionTreeSelection) => { try { diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-switch.test.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-switch.test.tsx index a486354d4b..d89abb11fb 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-switch.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-switch.test.tsx @@ -4,7 +4,7 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { activateSurface } from '@/infrastructure/peer-device/deviceSurface'; import { VirtualMessageList } from './VirtualMessageList'; -import { retainedTranscriptRowSizes } from '../../timeline/retainedViewMemory'; +import { MAX_RETAINED_SESSION_VIEWS, retainedTranscriptRowSizes } from '../../timeline/retainedViewMemory'; import { getVirtualItemStableKey } from './virtualItemIdentity'; import type { VirtualItem } from '../../store/modernFlowChatStore'; import type { Session } from '../../types/flow-chat'; @@ -221,7 +221,7 @@ describe('switching between long sessions (real controllers, supplied DOM geomet 380.5 + ((index * 7919) % 13) * 97.25)).flat(); return { items, session: { sessionId: id, config: {}, dialogTurns: [], isHistorical: false } as unknown as Session }; } - type SessionId = 'a' | 'b' | 'c' | 'd' | 'e'; + type SessionId = string; let sessions: Record>; const settled = () => host.querySelector('[data-open-viewport-settled="true"]') !== null; /** Open a session and run frames until the opening reveal settles; return what that cost. */ @@ -335,9 +335,11 @@ describe('switching between long sessions (real controllers, supplied DOM geomet it('keeps only the most recently left sessions', () => { const firstA = open('a'); - for (const id of ['b', 'c', 'd', 'e'] as const) open(id); - // a was left four sessions ago and has been evicted; d is still retained. - expect(retainedTranscriptRowSizes.size).toBe(3); + for (let index = 0; index <= MAX_RETAINED_SESSION_VIEWS; index += 1) { + open(index === MAX_RETAINED_SESSION_VIEWS - 1 ? 'd' : `other-${index}`); + } + // a was left MAX_RETAINED_SESSION_VIEWS + 1 sessions ago and has been evicted; d is still retained. + expect(retainedTranscriptRowSizes.size).toBe(MAX_RETAINED_SESSION_VIEWS); const evictedA = open('a'); expect(evictedA.corrections).toBeGreaterThan(0); expect(evictedA.corrections).toBeLessThanOrEqual(firstA.corrections); diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatSearch.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatSearch.ts index e22f1045be..0a69b52500 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatSearch.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatSearch.ts @@ -122,6 +122,8 @@ function countFoldedQueryOccurrences(foldedContent: string, foldedQuery: string) } const emptySearchMatches: SearchMatch[] = []; +const EMPTY_MATCHES_BY_VIRTUAL_INDEX: ReadonlyMap = new Map(); +const EMPTY_MATCH_INDICES: ReadonlySet = new Set(); type SearchMatchCache = WeakMap { if (!query.trim()) { - cache = new WeakMap(); + if (hasEntries) { + cache = new WeakMap(); + hasEntries = false; + } return emptySearchMatches; } + hasEntries = true; return buildFlowChatSearchMatches(items, query, cache); }; } @@ -196,6 +203,7 @@ export function useFlowChatSearch(virtualItems: VirtualItem[]): UseFlowChatSearc : 0; const matchesByVirtualIndex = useMemo(() => { + if (matches.length === 0) return EMPTY_MATCHES_BY_VIRTUAL_INDEX; const grouped = new Map(); for (const match of matches) { const row = grouped.get(match.virtualItemIndex); @@ -206,7 +214,7 @@ export function useFlowChatSearch(virtualItems: VirtualItem[]): UseFlowChatSearc }, [matches]); const matchIndices = useMemo>(() => ( - new Set(matchesByVirtualIndex.keys()) + matchesByVirtualIndex.size === 0 ? EMPTY_MATCH_INDICES : new Set(matchesByVirtualIndex.keys()) ), [matchesByVirtualIndex]); const currentMatchVirtualIndex = matches[resolvedCurrentMatchIndex]?.virtualItemIndex ?? -1; diff --git a/src/web-ui/src/flow_chat/deep-research/deepResearchProtocol.ts b/src/web-ui/src/flow_chat/deep-research/deepResearchProtocol.ts index 5261581c5b..fbf47b6fab 100644 --- a/src/web-ui/src/flow_chat/deep-research/deepResearchProtocol.ts +++ b/src/web-ui/src/flow_chat/deep-research/deepResearchProtocol.ts @@ -163,6 +163,12 @@ function trimProtocolBoundaryWhitespace(content: string): string { } export function parseDeepResearchContent(content: string): ParsedDeepResearchContent { + if (!content.includes('[[')) { + return { + hasProtocol: false, + segments: content ? [{ type: 'markdown', content }] : [], + }; + } const pending = splitPendingMarkerSuffix(content); const visibleContent = pending.content; const matches: Array<{ start: number; end: number; marker: DeepResearchProtocolMarker }> = []; diff --git a/src/web-ui/src/flow_chat/hooks/useResolvedModeSkills.ts b/src/web-ui/src/flow_chat/hooks/useResolvedModeSkills.ts index 7843123895..ef94d33778 100644 --- a/src/web-ui/src/flow_chat/hooks/useResolvedModeSkills.ts +++ b/src/web-ui/src/flow_chat/hooks/useResolvedModeSkills.ts @@ -6,6 +6,7 @@ import { createLogger } from '@/shared/utils/logger'; const log = createLogger('useResolvedModeSkills'); const EMPTY_SKILLS: ModeSkillInfo[] = []; +const MAX_CACHED_SKILL_SCOPES = 8; type Snapshot = { key: string; @@ -30,7 +31,7 @@ export function useResolvedModeSkills({ workspaceId?: string | null; }) { const key = JSON.stringify([surfaceEpoch, connectionId ?? null, workspaceId || null, modeId]); - const entryRef = useRef(null); + const entriesRef = useRef>(new Map()); const mountedRef = useRef(false); const [snapshot, setSnapshot] = useState(null); const [revision, setRevision] = useState(0); @@ -42,7 +43,7 @@ export function useResolvedModeSkills({ useEffect(() => { const invalidate = () => { - entryRef.current = null; + entriesRef.current.clear(); setSnapshot(null); setRevision(value => value + 1); }; @@ -51,11 +52,25 @@ export function useResolvedModeSkills({ }, []); useEffect(() => { - if (entryRef.current?.key !== key) { - entryRef.current = { key, skills: null, loading: false, failed: false }; + const entries = entriesRef.current; + let entry = entries.get(key); + if (!entry) { + entry = { key, skills: null, loading: false, failed: false }; + entries.set(key, entry); + while (entries.size > MAX_CACHED_SKILL_SCOPES) { + const oldestKey = entries.keys().next().value; + if (oldestKey === undefined || oldestKey === key) break; + entries.delete(oldestKey); + } + } else { + entries.delete(key); + entries.set(key, entry); } - const entry = entryRef.current; - if (!enabled || entry.loading) return; + if (!enabled) { + setSnapshot({ ...entry }); + return; + } + if (entry.loading) return; // Keep a single in-flight request when a picker closes/reopens. Each later // opening revalidates source files and mode policy, displaying the last result. @@ -77,12 +92,15 @@ export function useResolvedModeSkills({ }) .finally(() => { entry.loading = false; - if (mountedRef.current && entryRef.current === entry) setSnapshot({ ...entry }); + if (mountedRef.current && entriesRef.current.get(key) === entry) { + setSnapshot({ ...entry }); + } }); }, [enabled, key, modeId, workspaceId, revision]); - // Hide a previous host/workspace/mode synchronously, before effects run. - const current = snapshot?.key === key ? snapshot : null; + // Hide an unvisited host/workspace/mode synchronously, while immediately + // restoring warm cached skills for an already-visited scope. + const current = snapshot?.key === key ? snapshot : (entriesRef.current.get(key) ?? null); return { skills: current?.skills ?? EMPTY_SKILLS, diagnostics: current?.diagnostics ?? [], diff --git a/src/web-ui/src/flow_chat/hooks/useSessionGoalModeActive.ts b/src/web-ui/src/flow_chat/hooks/useSessionGoalModeActive.ts index 0ce6d3b924..a4e8aceec4 100644 --- a/src/web-ui/src/flow_chat/hooks/useSessionGoalModeActive.ts +++ b/src/web-ui/src/flow_chat/hooks/useSessionGoalModeActive.ts @@ -1,9 +1,12 @@ import { useSyncExternalStore } from 'react'; import { flowChatStore } from '../store/FlowChatStore'; +const subscribeToFlowChatStore = (callback: () => void): (() => void) => + flowChatStore.subscribe(() => callback()); + export function useSessionGoalModeActive(sessionId: string | undefined): boolean { return useSyncExternalStore( - (callback) => flowChatStore.subscribe(() => callback()), + subscribeToFlowChatStore, () => ( sessionId ? Boolean(flowChatStore.getState().sessions.get(sessionId)?.goalModeActive) diff --git a/src/web-ui/src/flow_chat/hooks/useSessionReviewActivity.ts b/src/web-ui/src/flow_chat/hooks/useSessionReviewActivity.ts index 2b383a7c19..7f429bd061 100644 --- a/src/web-ui/src/flow_chat/hooks/useSessionReviewActivity.ts +++ b/src/web-ui/src/flow_chat/hooks/useSessionReviewActivity.ts @@ -3,6 +3,7 @@ import { stateMachineManager } from '../state-machine'; import { flowChatStore } from '../store/FlowChatStore'; import { deriveSessionReviewActivity, + isSameSessionReviewActivity, type SessionReviewActivity, } from '../utils/sessionReviewActivity'; @@ -23,7 +24,8 @@ export function useSessionReviewActivity( useEffect(() => { const sync = () => { - setActivity(resolveCurrentActivity(parentSessionId)); + const next = resolveCurrentActivity(parentSessionId); + setActivity(prev => (isSameSessionReviewActivity(prev, next) ? prev : next)); }; sync(); diff --git a/src/web-ui/src/flow_chat/hooks/useTypewriter.ts b/src/web-ui/src/flow_chat/hooks/useTypewriter.ts index 9b05e31e4a..9d232f3e07 100644 --- a/src/web-ui/src/flow_chat/hooks/useTypewriter.ts +++ b/src/web-ui/src/flow_chat/hooks/useTypewriter.ts @@ -127,6 +127,10 @@ export interface TypewriterOptions { * which the user sees as the conversation refreshing itself. */ replayOnMount?: boolean; + /** Optional adaptive paint interval override (ms) during live streaming. */ + minPaintIntervalMs?: number; + /** Optional adaptive per-paint character cap override during live streaming. */ + maxCharsPerPaint?: number; } export interface TypewriterCharsPerSecInput { @@ -221,6 +225,10 @@ export function useTypewriter( const targetRef = useRef(targetText); const animateRef = useRef(animate); const revealImmediatelyRef = useRef(revealImmediately); + const minPaintIntervalMsRef = useRef(options.minPaintIntervalMs); + const maxCharsPerPaintRef = useRef(options.maxCharsPerPaint); + minPaintIntervalMsRef.current = options.minPaintIntervalMs; + maxCharsPerPaintRef.current = options.maxCharsPerPaint; const rafRef = useRef(null); const lastTickMsRef = useRef(null); const lastPaintMsRef = useRef(0); @@ -315,10 +323,10 @@ export function useTypewriter( const finishing = !animateRef.current; const minPaintInterval = finishing ? TYPEWRITER_FINISH_MIN_PAINT_INTERVAL_MS - : TYPEWRITER_MIN_PAINT_INTERVAL_MS; + : (minPaintIntervalMsRef.current ?? TYPEWRITER_MIN_PAINT_INTERVAL_MS); const maxCharsPerPaint = finishing ? TYPEWRITER_FINISH_MAX_CHARS_PER_PAINT - : TYPEWRITER_MAX_CHARS_PER_PAINT; + : (maxCharsPerPaintRef.current ?? TYPEWRITER_MAX_CHARS_PER_PAINT); const dtMs = lastTickMs === null ? minPaintInterval : Math.min(100, Math.max(0, nowMs - lastTickMs)); diff --git a/src/web-ui/src/flow_chat/selection/ConversationExcerptSources.tsx b/src/web-ui/src/flow_chat/selection/ConversationExcerptSources.tsx index f08304ce36..7b596c54be 100644 --- a/src/web-ui/src/flow_chat/selection/ConversationExcerptSources.tsx +++ b/src/web-ui/src/flow_chat/selection/ConversationExcerptSources.tsx @@ -1,6 +1,7 @@ import { useMemo, useSyncExternalStore, type ReactNode } from 'react'; import { getActiveSurfaceScope, surfaceScopedKey } from '@/infrastructure/peer-device/deviceSurface'; import { flowChatStore } from '../store/FlowChatStore'; +import type { FlowChatState } from '../types/flow-chat'; import { sessionComposerStore } from '../store/sessionComposerStore'; import { pendingQueueManager } from '../services/flow-chat-manager/PendingQueueModule'; import { createConversationExcerptInventory, EMPTY_SOURCE_INDEX } from './conversationExcerptInventory'; @@ -8,8 +9,32 @@ import { ConversationExcerptSourceContext } from './conversationExcerptSourceCon const inventory = createConversationExcerptInventory(); +function createSessionKeysSelector(): (state: FlowChatState) => number { + let previousIds: string[] = []; + let revision = 0; + + return (state: FlowChatState): number => { + let changed = previousIds.length !== state.sessions.size; + if (!changed) { + let index = 0; + for (const id of state.sessions.keys()) { + if (previousIds[index] !== id) { + changed = true; + break; + } + index += 1; + } + } + if (changed) { + previousIds = Array.from(state.sessions.keys()); + revision += 1; + } + return revision; + }; +} + function subscribe(notify: () => void) { - const stopSession = flowChatStore.subscribe(notify); + const stopSession = flowChatStore.subscribeSelector(createSessionKeysSelector(), notify); const stopDraft = sessionComposerStore.subscribe(notify); const stopQueue = pendingQueueManager.subscribe(notify); return () => { stopSession(); stopDraft(); stopQueue(); }; diff --git a/src/web-ui/src/flow_chat/services/AgenticEventListener.ts b/src/web-ui/src/flow_chat/services/AgenticEventListener.ts index db8dfea176..7ca1852557 100644 --- a/src/web-ui/src/flow_chat/services/AgenticEventListener.ts +++ b/src/web-ui/src/flow_chat/services/AgenticEventListener.ts @@ -103,6 +103,9 @@ export class AgenticEventListener { try { this.callbacks = callbacks; + if (typeof agentAPI.onEventBatch === 'function') { + this.unlistenFunctions.push(agentAPI.onEventBatch()); + } if (callbacks.onSessionCreated) { const unlisten = agentAPI.onSessionCreated((event) => { logger.debug('Session created:', event); diff --git a/src/web-ui/src/flow_chat/services/EventBatcher.ts b/src/web-ui/src/flow_chat/services/EventBatcher.ts index 970f09121d..e8ed389512 100644 --- a/src/web-ui/src/flow_chat/services/EventBatcher.ts +++ b/src/web-ui/src/flow_chat/services/EventBatcher.ts @@ -291,6 +291,26 @@ export class EventBatcher { if (this.buffer.size === 0) this.cancelScheduledFlush(); } + /** + * Synchronously flush pending events belonging only to `sessionId`, leaving + * other sessions' frame cadence untouched. + */ + flushSession(sessionId: string): void { + const sessionEvents: Array<{ key: string; payload: any }> = []; + for (const [key, event] of this.buffer) { + if ((event.payload as { sessionId?: unknown } | null)?.sessionId === sessionId) { + sessionEvents.push({ key, payload: event.payload }); + this.buffer.delete(key); + } + } + if (this.buffer.size === 0) { + this.cancelScheduledFlush(); + } + if (sessionEvents.length > 0) { + this.onFlush(sessionEvents); + } + } + destroy(): void { this.clear(); } diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index c4f836e2ae..f9ea67a964 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -26,6 +26,7 @@ import { } from '@/infrastructure/peer-device/deviceSurface'; import type { WorkspaceInfo } from '@/shared/types'; import { getDeviceOpenIntent, selectsDeviceSession } from '@/infrastructure/peer-device/deviceOpenIntent'; +import { getPendingWorkspaceSessionActivation } from './pendingWorkspaceSessionActivation'; import type { Session } from '../types/flow-chat'; import { compareSessionsForDisplay, @@ -73,6 +74,7 @@ import { updateSessionMetadata, } from './flow-chat-manager'; import { ensureBackendSession } from './flow-chat-manager/SessionModule'; +import { materializeStagedSessionBatch } from './flow-chat-manager/EventHandlerModule'; import { prepareSessionDraftForCommand, selectDraftWorkspace } from './sessionDraftService'; import { installPeerSessionRefresh } from './flow-chat-manager/PeerSessionRefreshModule'; import { installDispatchJobObserver } from '../session-drivers/dispatch/install'; @@ -115,6 +117,8 @@ export class FlowChatManager { private dispatchJobObserverCleanup: (() => void) | null = null; private navStatusCleanup: (() => void) | null = null; private surfaceActivationCleanup: (() => void) | null = null; + private beforeSessionVisibleCleanup: (() => void) | null = null; + private sessionBuffersDiscardedCleanup: (() => void) | null = null; private eventListenerRetryTimer: ReturnType | null = null; private disposed = false; @@ -131,6 +135,9 @@ export class FlowChatManager { pendingContextRestores: new Map(), contentBuffers: new Map(), activeTextItems: new Map(), + stagedSessionBatches: new Map(), + materializeStagedSession: (sessionId, options) => + materializeStagedSessionBatch(this.context, sessionId, options), saveDebouncers: new Map(), lastSaveTimestamps: new Map(), lastSaveHashes: new Map(), @@ -154,6 +161,17 @@ export class FlowChatManager { log.warn('Failed to persist unread completion change', { sessionId, value, err }); }); }); + this.beforeSessionVisibleCleanup = + this.context.flowChatStore.registerBeforeSessionVisibleCallback?.((sessionId, { notify }) => { + materializeStagedSessionBatch(this.context, sessionId, { + silent: !notify, + flushBatcher: true, + }); + }) ?? null; + this.sessionBuffersDiscardedCleanup = + this.context.flowChatStore.registerSessionBuffersDiscardedCallback?.(sessionId => { + cleanupSessionBuffers(this.context, sessionId); + }) ?? null; installPendingQueueDrainListener(this.context); this.peerSessionRefreshCleanup = installPeerSessionRefresh(this.context); this.dispatchJobObserverCleanup = installDispatchJobObserver(this.context); @@ -304,6 +322,14 @@ export class FlowChatManager { } return hasHistoricalSessions; } + const pendingTargetSessionId = getPendingWorkspaceSessionActivation(scope.surfaceId, workspaceId); + if (pendingTargetSessionId) { + if (isCurrentInitializationRequest()) { + this.context.currentWorkspaceId = workspaceId; + this.context.currentWorkspacePath = workspacePath; + } + return hasHistoricalSessions || state.sessions.has(pendingTargetSessionId); + } const activeSession = state.activeSessionId ? state.sessions.get(state.activeSessionId) ?? null : null; @@ -563,6 +589,7 @@ export class FlowChatManager { cleanupSaveState(this.context, sessionId); cleanupSessionBuffers(this.context, sessionId); }); + this.context.stagedSessionBatches?.clear(); this.context.flowChatStore.prepareForSurfaceSwitch(); try { useModernFlowChatStore.getState().clear(); @@ -600,6 +627,10 @@ export class FlowChatManager { } this.surfaceActivationCleanup?.(); this.surfaceActivationCleanup = null; + this.beforeSessionVisibleCleanup?.(); + this.beforeSessionVisibleCleanup = null; + this.sessionBuffersDiscardedCleanup?.(); + this.sessionBuffersDiscardedCleanup = null; this.peerSessionRefreshCleanup?.(); this.peerSessionRefreshCleanup = null; this.dispatchJobObserverCleanup?.(); 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 3a5d57eaf7..f67e2f8a06 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 @@ -7,9 +7,12 @@ import { handleSessionStateChanged, insertSteeringItemIfAbsent, isAppWindowFocused, + materializeStagedSessionBatch, + processBatchedEvents, projectDialogTurnRecovered, shouldProcessEvent, } from './EventHandlerModule'; +import { generateTextChunkKey, generateToolEventKey } from '../EventBatcher'; import { stateMachineManager } from '../../state-machine'; import { SessionExecutionEvent, SessionExecutionState } from '../../state-machine/types'; import { FlowChatStore } from '../../store/FlowChatStore'; @@ -2554,3 +2557,204 @@ describe('model round host timing', () => { expect(round?.firstVisibleOutputMs).toBe(ttftMs === undefined ? undefined : 200); }); }); + +describe('hidden session stream isolation', () => { + beforeEach(() => { + resetFlowChatStore(); + stateMachineManager.clear(); + }); + + afterEach(() => { + resetFlowChatStore(); + stateMachineManager.clear(); + }); + + it('stages hidden-session chunks with zero store notifications and materializes once on switchSession', () => { + const store = FlowChatStore.getInstance(); + store.createSession('active-session', {}); + store.createSession('hidden-session', {}); + store.addDialogTurn('hidden-session', { + ...createFinishingTurn(), + id: 'hidden-turn', + sessionId: 'hidden-session', + status: 'processing', + modelRounds: [{ + ...makeRound('hidden-round'), + items: [{ + id: 'tool-1', + type: 'tool', + toolName: 'Edit', + timestamp: 1000, + status: 'streaming', + isParamsStreaming: true, + toolCall: { id: 'tool-1', input: {} }, + } as FlowToolItem], + }], + }); + store.switchSession('active-session'); + + const context: FlowChatContext = { + ...createFlowChatContext(), + stagedSessionBatches: new Map(), + }; + context.materializeStagedSession = (sessionId, options) => + materializeStagedSessionBatch(context, sessionId, options); + const unregisterVisible = store.registerBeforeSessionVisibleCallback((sessionId, { notify }) => { + materializeStagedSessionBatch(context, sessionId, { + silent: !notify, + flushBatcher: true, + }); + }); + + let notifications = 0; + const initialState = store.getState(); + const unsubscribe = store.subscribe(() => { + notifications += 1; + }); + + try { + const textPayload1 = { + sessionId: 'hidden-session', + turnId: 'hidden-turn', + roundId: 'hidden-round', + text: 'Hello ', + contentType: 'text' as const, + }; + const textPayload2 = { + ...textPayload1, + text: 'background world!', + }; + const paramsPayload1 = { + sessionId: 'hidden-session', + turnId: 'hidden-turn', + roundId: 'hidden-round', + toolEvent: { + event_type: 'ParamsPartial' as const, + tool_id: 'tool-1', + tool_name: 'Edit', + params: '{"path":"a', + }, + }; + const paramsPayload2 = { + ...paramsPayload1, + toolEvent: { + ...paramsPayload1.toolEvent, + params: '.ts"}', + }, + }; + + processBatchedEvents( + context, + [ + { key: generateTextChunkKey(textPayload1), payload: textPayload1 }, + { key: generateToolEventKey(paramsPayload1)!.key, payload: paramsPayload1 }, + ], + vi.fn(), + ); + processBatchedEvents( + context, + [ + { key: generateTextChunkKey(textPayload2), payload: textPayload2 }, + { key: generateToolEventKey(paramsPayload2)!.key, payload: paramsPayload2 }, + ], + vi.fn(), + ); + + expect(notifications).toBe(0); + expect(store.getState()).toBe(initialState); + + store.switchSession('hidden-session'); + expect(notifications).toBe(1); + + const round = store.getState().sessions.get('hidden-session')?.dialogTurns[0].modelRounds[0]; + const textItem = round?.items.find(item => item.type === 'text') as { content?: string } | undefined; + const toolItem = round?.items.find(item => item.id === 'tool-1') as FlowToolItem | undefined; + expect(textItem?.content).toBe('Hello background world!'); + expect(toolItem?.toolCall?.input).toEqual({ path: 'a.ts' }); + } finally { + unsubscribe(); + unregisterVisible(); + for (const timer of context.saveDebouncers.values()) { + clearTimeout(timer); + } + } + }); + + it('materializes staged hidden chunks when leased by a secondary split view or settled by round completion', async () => { + const store = FlowChatStore.getInstance(); + store.createSession('active-session', {}); + store.createSession('subagent-session', {}); + store.addDialogTurn('subagent-session', { + ...createFinishingTurn(), + id: 'sub-turn', + sessionId: 'subagent-session', + status: 'processing', + modelRounds: [makeRound('sub-round')], + }); + store.switchSession('active-session'); + await stateMachineManager.transition('subagent-session', SessionExecutionEvent.START, { + taskId: 'subagent-session', + dialogTurnId: 'sub-turn', + }); + + const context: FlowChatContext = { + ...createFlowChatContext(), + stagedSessionBatches: new Map(), + }; + const unregisterVisible = store.registerBeforeSessionVisibleCallback((sessionId, { notify }) => { + materializeStagedSessionBatch(context, sessionId, { + silent: !notify, + flushBatcher: true, + }); + }); + + try { + const payload = { + sessionId: 'subagent-session', + turnId: 'sub-turn', + roundId: 'sub-round', + text: 'Streamed while hidden', + contentType: 'text' as const, + }; + processBatchedEvents( + context, + [{ key: generateTextChunkKey(payload), payload }], + vi.fn(), + ); + expect( + store.getState().sessions.get('subagent-session')?.dialogTurns[0].modelRounds[0].items, + ).toHaveLength(0); + + const releaseLease = store.retainSessionHistory('subagent-session'); + const leasedItems = store.getState().sessions.get('subagent-session')?.dialogTurns[0].modelRounds[0].items; + expect(leasedItems).toHaveLength(1); + expect((leasedItems?.[0] as { content?: string }).content).toBe('Streamed while hidden'); + releaseLease(); + + // Stage another chunk after releasing the secondary split view, then complete the round. + const payload2 = { + ...payload, + text: ' + settled tail', + }; + processBatchedEvents( + context, + [{ key: generateTextChunkKey(payload2), payload: payload2 }], + vi.fn(), + ); + __test_only__.handleModelRoundComplete(context, { + sessionId: 'subagent-session', + turnId: 'sub-turn', + roundId: 'sub-round', + hasToolCalls: false, + }); + + const finalItems = store.getState().sessions.get('subagent-session')?.dialogTurns[0].modelRounds[0].items; + expect((finalItems?.[0] as { content?: string }).content).toBe('Streamed while hidden + settled tail'); + } finally { + unregisterVisible(); + for (const timer of context.saveDebouncers.values()) { + clearTimeout(timer); + } + } + }); +}); 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 10207653c0..3c115f2778 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 @@ -136,7 +136,7 @@ export function handleUserQuestionWaiting(context: FlowChatContext, event: { questions: { responseDeadlineMs?: number | null; responseHostNowMs?: number }; }): void { if (!event || typeof event.session_id !== 'string' || typeof event.tool_id !== 'string') return; - context.eventBatcher.flushNow(); + materializeSessionBeforeLifecycle(context, event.session_id); const session = context.flowChatStore.getState().sessions.get(event.session_id); for (const turn of session?.dialogTurns ?? []) { for (const round of turn.modelRounds) { @@ -224,12 +224,185 @@ function mergeParamsPartialEventData( }; } +function isSessionStreamVisible(context: FlowChatContext, sessionId: string): boolean { + return context.flowChatStore?.isSessionStreamVisible?.(sessionId) ?? true; +} + +function flushSessionBatchedEvents(context: FlowChatContext, sessionId: string): void { + if (typeof context.eventBatcher?.flushSession === 'function') { + context.eventBatcher.flushSession(sessionId); + return; + } + if (typeof context.eventBatcher?.getBufferSize === 'function') { + if (context.eventBatcher.getBufferSize() > 0) { + context.eventBatcher.flushNow?.(); + } + return; + } + context.eventBatcher?.flushNow?.(); +} + +function applySingleBatchedEvent( + context: FlowChatContext, + eventType: 'text' | 'tool:params' | 'tool:progress', + payload: any, +): void { + if (eventType === 'text') { + const { + sessionId, + turnId, + roundId, + attemptId, + attemptIndex, + text, + contentType, + reasoningKind, + isThinkingEnd, + } = payload; + if (contentType === 'thinking') { + processThinkingChunkInternal( + context, + sessionId, + turnId, + roundId, + text, + isThinkingEnd, + attemptId, + attemptIndex, + reasoningKind, + ); + } else { + processNormalTextChunkInternal(context, sessionId, turnId, roundId, text, attemptId, attemptIndex); + } + + // The executing host owns turn persistence. A Peer controller receives + // the same chunks for rendering and must not echo a save RPC for every + // checkpoint, especially on a weak link. + if (!isPeerDeviceModeActive()) { + debouncedSaveDialogTurn(context, sessionId, turnId, 2000); + } + } else if (eventType === 'tool:params') { + const { sessionId, turnId, toolEvent } = payload; + processToolParamsPartialInternal(sessionId, turnId, toolEvent); + reconcileBackgroundSubagentFromParentTool(sessionId, turnId, toolEvent.tool_id); + } else if (eventType === 'tool:progress') { + const { sessionId, turnId, toolEvent } = payload; + processToolProgressInternal(sessionId, turnId, toolEvent); + reconcileBackgroundSubagentFromParentTool(sessionId, turnId, toolEvent.tool_id); + } +} + +function stageHiddenSessionBatchEntry( + context: FlowChatContext, + sessionId: string, + eventType: 'text' | 'tool:params' | 'tool:progress', + key: string, + payload: any, +): boolean { + if (!context.stagedSessionBatches) { + return false; + } + + let sessionQueue = context.stagedSessionBatches.get(sessionId); + if (!sessionQueue) { + sessionQueue = new Map(); + context.stagedSessionBatches.set(sessionId, sessionQueue); + } + + const existing = sessionQueue.get(key); + if (!existing) { + sessionQueue.set(key, { key, payload }); + } else if (eventType === 'text') { + sessionQueue.set(key, { + key, + payload: { + ...existing.payload, + ...payload, + text: (existing.payload.text ?? '') + (payload.text ?? ''), + isThinkingEnd: Boolean(existing.payload.isThinkingEnd || payload.isThinkingEnd), + }, + }); + } else if (eventType === 'tool:params') { + sessionQueue.set(key, { + key, + payload: mergeParamsPartialEventData(existing.payload, payload), + }); + } else { + sessionQueue.set(key, { key, payload }); + } + + if (eventType === 'text' && !isPeerDeviceModeActive()) { + debouncedSaveDialogTurn(context, sessionId, payload.turnId, 4000); + } + + return true; +} + +function drainStagedSessionEntries(context: FlowChatContext, sessionId: string): boolean { + const sessionQueue = context.stagedSessionBatches?.get(sessionId); + if (!sessionQueue || sessionQueue.size === 0) { + return false; + } + context.stagedSessionBatches!.delete(sessionId); + + for (const { key, payload } of sessionQueue.values()) { + const parsed = parseEventKey(key); + if (!parsed) continue; + const { eventType } = parsed; + if (eventType === 'text' || eventType === 'tool:params' || eventType === 'tool:progress') { + applySingleBatchedEvent(context, eventType, payload); + } + } + return true; +} + +export function materializeStagedSessionBatch( + context: FlowChatContext, + sessionId: string, + options?: { silent?: boolean; flushBatcher?: boolean }, +): boolean { + if (!sessionId) { + return false; + } + if (options?.flushBatcher) { + flushSessionBatchedEvents(context, sessionId); + } + const hasStaged = (context.stagedSessionBatches?.get(sessionId)?.size ?? 0) > 0; + if (!hasStaged) { + return false; + } + const store = context.flowChatStore; + if (options?.silent && typeof store?.runSilentWithoutNotify === 'function') { + return store.runSilentWithoutNotify(() => drainStagedSessionEntries(context, sessionId)); + } + store?.beginSilentMode?.(); + try { + return drainStagedSessionEntries(context, sessionId); + } finally { + store?.endSilentMode?.(); + } +} + +function materializeSessionBeforeLifecycle( + context: FlowChatContext, + sessionId: string | undefined, +): void { + if (!sessionId) { + return; + } + materializeStagedSessionBatch(context, sessionId, { + silent: !isSessionStreamVisible(context, sessionId), + flushBatcher: true, + }); +} + export const __test_only__ = { relayRecordsBeforeEvent, handleSessionDeleted, handleSessionCreated, resolveDialogTurnDisplayContent, mergeParamsPartialEventData, + materializeStagedSessionBatch, findSubagentParentInfoByRound, handleDialogTurnStarted, handleImageAnalysisStarted, @@ -1314,6 +1487,7 @@ function finalizeTurnCompletionState( sessionId: string, turnId: string ): void { + materializeSessionBeforeLifecycle(context, sessionId); const store = FlowChatStore.getInstance(); const session = store.getState().sessions.get(sessionId); @@ -1640,7 +1814,7 @@ export function insertSteeringItemIfAbsent(params: { * can see the steering message they just submitted. Idempotent — if the * client-side optimistic path already added the item, this is a no-op. */ -function handleUserSteeringInjected(_context: FlowChatContext, event: any): void { +function handleUserSteeringInjected(context: FlowChatContext, event: any): void { const sessionId: string | undefined = event?.sessionId; const turnId: string | undefined = event?.turnId; const steeringId: string | undefined = event?.steeringId; @@ -1653,6 +1827,7 @@ function handleUserSteeringInjected(_context: FlowChatContext, event: any): void return; } + materializeSessionBeforeLifecycle(context, sessionId); insertSteeringItemIfAbsent({ sessionId, turnId, @@ -1773,6 +1948,7 @@ export function handleSessionHistoryChanged(context: FlowChatContext, event: any return; } + materializeSessionBeforeLifecycle(context, sessionId); const session = context.flowChatStore.getState().sessions.get(sessionId); if (!session || isAcpFlowSession(session)) { return; @@ -2203,6 +2379,7 @@ function handleDialogTurnStarted(context: FlowChatContext, event: any): void { context.contentBuffers.set(sessionId, new Map()); context.activeTextItems.set(sessionId, new Map()); + context.stagedSessionBatches?.delete(sessionId); if (!hadTempTurn && !adoptNewerAdmittedTurn(sessionId, turnId, turnIndex)) { stateMachineManager.transition(sessionId, SessionExecutionEvent.START, { @@ -2352,50 +2529,24 @@ export function processBatchedEvents( if (!parsed) continue; const { eventType } = parsed; - - if (eventType === 'text') { - const { - sessionId, - turnId, - roundId, - attemptId, - attemptIndex, - text, - contentType, - reasoningKind, - isThinkingEnd, - } = payload; - if (contentType === 'thinking') { - processThinkingChunkInternal( - context, - sessionId, - turnId, - roundId, - text, - isThinkingEnd, - attemptId, - attemptIndex, - reasoningKind, - ); - } else { - processNormalTextChunkInternal(context, sessionId, turnId, roundId, text, attemptId, attemptIndex); - } - - // The executing host owns turn persistence. A Peer controller receives - // the same chunks for rendering and must not echo a save RPC for every - // checkpoint, especially on a weak link. - if (!isPeerDeviceModeActive()) { - debouncedSaveDialogTurn(context, sessionId, turnId, 2000); - } - } else if (eventType === 'tool:params') { - const { sessionId, turnId, toolEvent } = payload; - processToolParamsPartialInternal(sessionId, turnId, toolEvent); - reconcileBackgroundSubagentFromParentTool(sessionId, turnId, toolEvent.tool_id); - } else if (eventType === 'tool:progress') { - const { sessionId, turnId, toolEvent } = payload; - processToolProgressInternal(sessionId, turnId, toolEvent); - reconcileBackgroundSubagentFromParentTool(sessionId, turnId, toolEvent.tool_id); + if (eventType !== 'text' && eventType !== 'tool:params' && eventType !== 'tool:progress') { + continue; } + + const sessionId: string | undefined = payload?.sessionId; + if ( + sessionId && + !isSessionStreamVisible(context, sessionId) && + stageHiddenSessionBatchEntry(context, sessionId, eventType, key, payload) + ) { + continue; + } + + if (sessionId) { + drainStagedSessionEntries(context, sessionId); + } + + applySingleBatchedEvent(context, eventType, payload); } } finally { context.flowChatStore.endSilentMode(); @@ -2466,6 +2617,7 @@ function handleToolEvent( return; } + materializeSessionBeforeLifecycle(context, sessionId); processToolEvent(context, sessionId, turnId, roundId, toolEvent, attemptId, attemptIndex, undefined, onTodoWriteResult); reconcileBackgroundSubagentFromParentTool(sessionId, turnId, toolEvent.tool_id); } @@ -2506,6 +2658,7 @@ function handleModelRoundStart(context: FlowChatContext, event: ModelRoundStarte }); } + materializeSessionBeforeLifecycle(context, sessionId); completeActiveTextItems(context, sessionId, turnId); const disableExploreGrouping = @@ -2580,6 +2733,7 @@ function handleModelRoundAttemptSuperseded( return; } + materializeSessionBeforeLifecycle(context, sessionId); const round = context.flowChatStore.getState().sessions.get(sessionId) ?.dialogTurns.find(dialogTurn => dialogTurn.id === turnId) ?.modelRounds.find(modelRound => modelRound.id === roundId); @@ -2615,6 +2769,7 @@ function handleModelRoundComplete(context: FlowChatContext, event: ModelRoundCom return; } + materializeSessionBeforeLifecycle(context, sessionId); const store = FlowChatStore.getInstance(); const session = store.getState().sessions.get(sessionId); const dialogTurn = session?.dialogTurns.find((turn: DialogTurn) => turn.id === turnId); @@ -2748,7 +2903,7 @@ function handleAcpContextUsageUpdate(event: AcpContextUsageUpdatedEvent): void { /** * Handle context compression started event */ -function handleCompressionStarted(_context: FlowChatContext, event: any): void { +function handleCompressionStarted(context: FlowChatContext, event: any): void { const { sessionId, turnId, compressionId, trigger, tokensBefore, contextWindow } = event; log.info('Context compression started', { @@ -2759,6 +2914,7 @@ function handleCompressionStarted(_context: FlowChatContext, event: any): void { markManualCompactionPending(sessionId, typeof turnId === 'string' ? turnId : undefined); } + materializeSessionBeforeLifecycle(context, sessionId); const store = FlowChatStore.getInstance(); const session = store.getState().sessions.get(sessionId); @@ -2832,6 +2988,7 @@ function handleCompressionCompleted(context: FlowChatContext, event: any): void tokensBefore, tokensAfter, compressionRatio, durationMs }); + materializeSessionBeforeLifecycle(context, sessionId); const store = FlowChatStore.getInstance(); const session = store.getState().sessions.get(sessionId); if ( @@ -2891,6 +3048,7 @@ function handleCompressionFailed(context: FlowChatContext, event: any): void { log.error('Context compression failed', { sessionId, turnId, compressionId, error }); + materializeSessionBeforeLifecycle(context, sessionId); const store = FlowChatStore.getInstance(); settleManualCompactionEvent(sessionId, turnId); @@ -3011,6 +3169,7 @@ export function handleDialogTurnComplete( return; } settleManualCompactionEvent(sessionId, turnId); + materializeSessionBeforeLifecycle(context, sessionId); const store = FlowChatStore.getInstance(); const session = store.getState().sessions.get(sessionId); const existingTurn = session?.dialogTurns.find(turn => turn.id === turnId); @@ -3134,6 +3293,7 @@ function handleDialogTurnFailed(context: FlowChatContext, event: any): void { settleManualCompactionEvent(sessionId, turnId); clearPendingTurnCompletion(context, sessionId, turnId); clearRuntimeStatus(context, sessionId, turnId); + materializeSessionBeforeLifecycle(context, sessionId); const store = FlowChatStore.getInstance(); const session = store.getState().sessions.get(sessionId); @@ -3241,6 +3401,7 @@ function handleDialogTurnCancelled( settleManualCompactionEvent(sessionId, turnId); clearPendingTurnCompletion(context, sessionId, turnId); clearRuntimeStatus(context, sessionId, turnId); + materializeSessionBeforeLifecycle(context, sessionId); const store = FlowChatStore.getInstance(); const session = store.getState().sessions.get(sessionId); @@ -3322,6 +3483,7 @@ function handleDialogTurnInterrupted(context: FlowChatContext, event: any): void }); return; } + materializeSessionBeforeLifecycle(context, sessionId); const currentTurn = context.flowChatStore .getState() .sessions.get(sessionId) @@ -3411,6 +3573,7 @@ export function projectDialogTurnRecovered( outcome: RecoverInterruptedDialogTurnResponse, ): boolean { const { sessionId, turnId, executionGeneration } = outcome; + materializeSessionBeforeLifecycle(context, sessionId); const currentTurn = context.flowChatStore .getState() .sessions.get(sessionId) @@ -3436,6 +3599,7 @@ export function projectDialogTurnRecovered( context.handledTerminalTurnEvents.delete(`${sessionId}:${turnId}`); context.contentBuffers.set(sessionId, new Map()); context.activeTextItems.set(sessionId, new Map()); + context.stagedSessionBatches?.delete(sessionId); context.flowChatStore.updateDialogTurn(sessionId, turnId, turn => ({ ...turn, status: 'processing' as const, diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts index 61e2dd5143..f8b8bda04a 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts @@ -96,31 +96,214 @@ async function runSerialDialogTurnSave( await task; } +const FNV_OFFSET_1 = 0x811c9dc5; +const FNV_PRIME_1 = 0x01000193; +const FNV_OFFSET_2 = 0x9e3779b9; +const FNV_PRIME_2 = 0x85ebca6b; +const MAX_HASH_STRING_SAMPLES = 64; +const MAX_HASH_OBJECT_DEPTH = 5; + +const objectFingerprintCache = new WeakMap(); + +function mixUint32(h1: number, h2: number, value: number): [number, number] { + const v = value | 0; + const next1 = Math.imul(h1 ^ (v & 0xffff), FNV_PRIME_1); + const next1Hi = Math.imul(next1 ^ ((v >>> 16) & 0xffff), FNV_PRIME_1); + const next2 = Math.imul(h2 ^ v, FNV_PRIME_2); + return [next1Hi >>> 0, (next2 ^ (next2 >>> 13)) >>> 0]; +} + +function mixStringSampled(h1: number, h2: number, text: string): [number, number] { + const len = text.length; + [h1, h2] = mixUint32(h1, h2, len); + if (len === 0) return [h1, h2]; + if (len <= MAX_HASH_STRING_SAMPLES) { + for (let i = 0; i < len; i += 1) { + [h1, h2] = mixUint32(h1, h2, text.charCodeAt(i)); + } + return [h1, h2]; + } + // Sample prefix (16), suffix (16), and 32 evenly spaced interior code units + // with zero string slicing or heap allocation. + for (let i = 0; i < 16; i += 1) { + [h1, h2] = mixUint32(h1, h2, text.charCodeAt(i)); + } + const interiorSpan = len - 32; + for (let s = 0; s < 32; s += 1) { + const idx = 16 + Math.floor(((s + 0.5) * interiorSpan) / 32); + [h1, h2] = mixUint32(h1, h2, text.charCodeAt(idx)); + } + for (let i = len - 16; i < len; i += 1) { + [h1, h2] = mixUint32(h1, h2, text.charCodeAt(i)); + } + return [h1, h2]; +} + +function fingerprintValue(value: unknown, depth: number): readonly [number, number] { + if (value === null) return [0x11, 0x22]; + if (value === undefined) return [0x33, 0x44]; + const kind = typeof value; + if (kind === 'string') { + return mixStringSampled(FNV_OFFSET_1, FNV_OFFSET_2, value as string); + } + if (kind === 'number') { + const n = value as number; + return Number.isFinite(n) + ? mixUint32(FNV_OFFSET_1, FNV_OFFSET_2, Math.floor(n) ^ Math.floor(n / 0x100000000)) + : [0x55, 0x66]; + } + if (kind === 'boolean') { + return (value as boolean) ? [0x77, 0x88] : [0x99, 0xaa]; + } + if (kind !== 'object') { + return [0xbb, 0xcc]; + } + + const obj = value as object; + const canCacheObject = depth === MAX_HASH_OBJECT_DEPTH; + if (canCacheObject) { + const cached = objectFingerprintCache.get(obj); + if (cached) { + return cached; + } + } + + let h1 = FNV_OFFSET_1; + let h2 = FNV_OFFSET_2; + + if (Array.isArray(obj)) { + [h1, h2] = mixUint32(h1, h2, obj.length); + if (depth > 0) { + for (let i = 0; i < obj.length; i += 1) { + const [c1, c2] = fingerprintValue(obj[i], depth - 1); + [h1, h2] = mixUint32(h1, h2, c1); + [h1, h2] = mixUint32(h1, h2, c2); + } + } + } else { + let keyCount = 0; + for (const key in obj) { + if (!Object.prototype.hasOwnProperty.call(obj, key)) continue; + keyCount += 1; + [h1, h2] = mixStringSampled(h1, h2, key); + if (depth > 0) { + const [c1, c2] = fingerprintValue((obj as Record)[key], depth - 1); + [h1, h2] = mixUint32(h1, h2, c1); + [h1, h2] = mixUint32(h1, h2, c2); + } + } + [h1, h2] = mixUint32(h1, h2, keyCount); + } + + const pair: readonly [number, number] = [h1, h2]; + if (canCacheObject) { + objectFingerprintCache.set(obj, pair); + } + return pair; +} + +type TurnModelRound = DialogTurn['modelRounds'][number]; +const settledRoundFingerprintCache = new WeakMap(); + +function fingerprintModelRound(round: TurnModelRound): readonly [number, number] { + const isSettled = !round.isStreaming && Boolean(round.isComplete); + if (isSettled) { + const cached = settledRoundFingerprintCache.get(round); + if (cached) { + return cached; + } + } + let r1 = FNV_OFFSET_1; + let r2 = FNV_OFFSET_2; + [r1, r2] = mixStringSampled(r1, r2, round.id); + [r1, r2] = mixStringSampled(r1, r2, round.status || ''); + [r1, r2] = mixUint32(r1, r2, (round.isStreaming ? 1 : 0) | (round.isComplete ? 2 : 0)); + [r1, r2] = mixUint32(r1, r2, round.endTime ?? 0); + [r1, r2] = mixUint32(r1, r2, round.attemptCount ?? 0); + [r1, r2] = mixStringSampled(r1, r2, round.effectiveModelName || ''); + [r1, r2] = mixUint32(r1, r2, round.items.length); + for (let i = 0; i < round.items.length; i += 1) { + const [i1, i2] = fingerprintValue(round.items[i], MAX_HASH_OBJECT_DEPTH); + [r1, r2] = mixUint32(r1, r2, i1); + [r1, r2] = mixUint32(r1, r2, i2); + } + if (round.attemptDiagnostics?.length) { + const [d1, d2] = fingerprintValue(round.attemptDiagnostics, 3); + [r1, r2] = mixUint32(r1, r2, d1); + [r1, r2] = mixUint32(r1, r2, d2); + } + const pair: readonly [number, number] = [r1, r2]; + if (isSettled) { + settledRoundFingerprintCache.set(round, pair); + } + return pair; +} + /** - * Calculate content hash for dialog turn (for deduplication) + * Calculate content hash for dialog turn (for deduplication). + * Uses zero-allocation structural fingerprinting + WeakMap memoization on + * immutable round items/results instead of full-turn JSON.stringify. */ export function calculateTurnHash(dialogTurn: DialogTurn): string { - const keyData = JSON.stringify({ - status: dialogTurn.status, - roundsCount: dialogTurn.modelRounds.length, - lastRoundData: dialogTurn.modelRounds[dialogTurn.modelRounds.length - 1] - ? { - ...dialogTurn.modelRounds[dialogTurn.modelRounds.length - 1], - items: dialogTurn.modelRounds[dialogTurn.modelRounds.length - 1].items, - } - : null, - error: dialogTurn.error, - endTime: dialogTurn.endTime, - tokenUsage: dialogTurn.tokenUsage, - }); - - let hash = 0; - for (let i = 0; i < keyData.length; i++) { - const char = keyData.charCodeAt(i); - hash = ((hash << 5) - hash) + char; - hash = hash & hash; + let h1 = FNV_OFFSET_1; + let h2 = FNV_OFFSET_2; + + [h1, h2] = mixStringSampled(h1, h2, dialogTurn.status || ''); + [h1, h2] = mixUint32(h1, h2, dialogTurn.modelRounds.length); + [h1, h2] = mixStringSampled(h1, h2, dialogTurn.error || ''); + [h1, h2] = mixUint32(h1, h2, dialogTurn.endTime ?? 0); + + if (dialogTurn.tokenUsage) { + [h1, h2] = mixUint32(h1, h2, dialogTurn.tokenUsage.inputTokens ?? -1); + [h1, h2] = mixUint32(h1, h2, dialogTurn.tokenUsage.outputTokens ?? -1); + [h1, h2] = mixUint32(h1, h2, dialogTurn.tokenUsage.totalTokens ?? -1); + [h1, h2] = mixUint32(h1, h2, dialogTurn.tokenUsage.cachedTokens ?? -1); } - return hash.toString(36); + + for (let r = 0; r < dialogTurn.modelRounds.length; r += 1) { + const [r1, r2] = fingerprintModelRound(dialogTurn.modelRounds[r]); + [h1, h2] = mixUint32(h1, h2, r1); + [h1, h2] = mixUint32(h1, h2, r2); + } + + return `${h1.toString(36)}_${h2.toString(36)}`; +} + +const syncedUiSessionMetadataByContext = new WeakMap>(); + +function getSyncedUiMetadataMap(context: FlowChatContext): Map { + let map = syncedUiSessionMetadataByContext.get(context); + if (!map) { + map = new Map(); + syncedUiSessionMetadataByContext.set(context, map); + } + return map; +} + +function isPeerProjectedNonAcpSession(session: any): boolean { + if (isLocalSurface(getActiveSurfaceScope().surfaceId)) { + return false; + } + const provider = session?.config?.customMetadata?.sessionProvider ?? session?.customMetadata?.sessionProvider; + return provider !== 'acp'; +} + +function computeUiMetadataSyncFingerprint(session: any): string { + const [f1, f2] = fingerprintValue( + { + title: session?.title ?? '', + titleSource: session?.titleSource ?? '', + titleKey: session?.titleKey ?? '', + titleParams: session?.titleParams ?? null, + externalPlaceholder: Boolean(session?.externalPlaceholder), + needsUserAttention: String(session?.needsUserAttention ?? ''), + hasUnreadCompletion: String(session?.hasUnreadCompletion ?? ''), + tags: session?.tags ?? null, + todos: session?.todos ?? null, + }, + 3, + ); + return `${f1.toString(36)}_${f2.toString(36)}`; } /** @@ -136,6 +319,10 @@ export function debouncedSaveDialogTurn( turnId: string, delay: number = 2000 ): void { + const session = context.flowChatStore.getState().sessions.get(sessionId); + if (session && isPeerProjectedNonAcpSession(session)) { + return; + } const key = `${sessionId}:${turnId}`; const existingTimer = context.saveDebouncers.get(key); @@ -163,11 +350,14 @@ export function immediateSaveDialogTurn( turnId: string, skipDuplicateCheck: boolean = false ): void { + const session = context.flowChatStore.getState().sessions.get(sessionId); + if (session && isPeerProjectedNonAcpSession(session)) { + return; + } const key = `${sessionId}:${turnId}`; const dialogTurn = getDialogTurn(context, sessionId, turnId); if (!skipDuplicateCheck) { - const session = context.flowChatStore.getState().sessions.get(sessionId); if (session) { if (dialogTurn) { const currentHash = calculateTurnHash(dialogTurn); @@ -274,6 +464,14 @@ export function cleanupSaveState( context.deferredStorageIdentitySaves?.delete(key); context.turnSaveInFlight.delete(key); }); + const syncedUiSessionMetadataKeys = syncedUiSessionMetadataByContext.get(context); + if (syncedUiSessionMetadataKeys) { + for (const metaKey of syncedUiSessionMetadataKeys.keys()) { + if (metaKey.endsWith(`:${sessionId}`)) { + syncedUiSessionMetadataKeys.delete(metaKey); + } + } + } } } @@ -307,6 +505,7 @@ async function performSaveDialogTurnToDisk( const { sessionAPI } = await import('@/infrastructure/api/service-api/SessionAPI'); scope.assertCurrent(); + context.materializeStagedSession?.(sessionId, { silent: true, flushBatcher: true }); const session = context.flowChatStore.getState().sessions.get(sessionId); if (!session) { skip('GenUI session is unavailable'); @@ -352,7 +551,23 @@ async function performSaveDialogTurnToDisk( requireSessionOwningWorkspaceId(session)); scope.assertCurrent(); - await updateSessionMetadata(context, sessionId); + // PersistenceManager::save_dialog_turn on the host already updates + // metadata.json activity/counts/last_turn atomically. Skip the extra + // load+save metadata double-IPC on mid-turn checkpoints unless UI-owned + // session metadata changed or the turn reached a terminal state. + const syncedUiSessionMetadataKeys = getSyncedUiMetadataMap(context); + const metaSyncKey = `${scope.surfaceId}:${sessionId}`; + const uiMetaFingerprint = computeUiMetadataSyncFingerprint(session); + const shouldSyncMetadata = + strict || + isTerminalDialogTurnStatus(dialogTurn.status) || + syncedUiSessionMetadataKeys.get(metaSyncKey) !== uiMetaFingerprint; + if (shouldSyncMetadata) { + const persistedMetadata = await performUpdateSessionMetadata(context, sessionId); + if (persistedMetadata && scope.isCurrent()) { + syncedUiSessionMetadataKeys.set(metaSyncKey, uiMetaFingerprint); + } + } scope.assertCurrent(); } catch (error) { @@ -367,6 +582,11 @@ async function performSaveDialogTurnToDisk( * app alive and settling here would clear in-memory "active" state (e.g. desktop pet bubbles). */ export async function saveAllInProgressTurns(context: FlowChatContext): Promise { + if (context.stagedSessionBatches && context.stagedSessionBatches.size > 0) { + for (const stagedSessionId of [...context.stagedSessionBatches.keys()]) { + context.materializeStagedSession?.(stagedSessionId, { silent: true, flushBatcher: true }); + } + } const state = context.flowChatStore.getState(); const savePromises: Promise[] = []; @@ -422,6 +642,126 @@ export async function saveAllInProgressTurns(context: FlowChatContext): Promise< await Promise.all(savePromises); } +interface CachedBackendModelRound { + turnId: string; + roundIndex: number; + backendRound: any; +} + +const settledBackendRoundCache = new WeakMap(); + +function convertModelRoundToBackendFormat( + round: DialogTurn['modelRounds'][number], + turnId: string, + roundIndex: number, +): any { + const isSettled = round.isStreaming === false && round.isComplete === true; + if (isSettled) { + const cached = settledBackendRoundCache.get(round); + if (cached && cached.turnId === turnId && cached.roundIndex === roundIndex) { + return cached.backendRound; + } + } + + const textItems: any[] = []; + const toolItems: any[] = []; + const thinkingItems: any[] = []; + + for (let index = 0; index < round.items.length; index += 1) { + const item = round.items[index]; + if (item.type === 'text') { + const textItem = item as any; + textItems.push({ + id: item.id, + content: textItem.content || '', + isStreaming: textItem.isStreaming || false, + isMarkdown: textItem.isMarkdown !== undefined ? textItem.isMarkdown : true, + timestamp: item.timestamp, + status: item.status || 'completed', + orderIndex: index, + subagentSessionId: textItem.subagentSessionId, + attemptId: item.attemptId, + attemptIndex: item.attemptIndex, + }); + } else if (item.type === 'tool') { + const toolItem = item as any; + const effective = effectiveToolInvocation(toolItem.toolName, toolItem.toolCall?.input); + if ( + toolItem.toolName === DEFERRED_TOOL_GATEWAY_NAME + && toolItem.status === 'completed' + && !effective.isDeferred + ) { + throw new Error(`Completed deferred tool is missing its wire invocation: ${item.id}`); + } + toolItems.push({ + id: item.id, + toolName: toolItem.toolName || '', + interruptionReason: toolItem.interruptionReason, + toolCall: toolItem.toolCall || { input: {}, id: item.id }, + toolResult: toolItem.toolResult, + aiIntent: toolItem.aiIntent, + requiresConfirmation: toolItem.requiresConfirmation, + userConfirmed: toolItem.userConfirmed, + acpPermission: toolItem.acpPermission, + startTime: toolItem.startTime || item.timestamp, + endTime: toolItem.endTime, + status: item.status || 'completed', + orderIndex: index, + subagentSessionId: toolItem.subagentSessionId, + subagentDialogTurnId: toolItem.subagentDialogTurnId, + subagentModelId: toolItem.subagentModelId, + subagentModelDisplayName: toolItem.subagentModelDisplayName, + attemptId: item.attemptId, + attemptIndex: item.attemptIndex, + }); + } else if (item.type === 'thinking') { + const thinkingItem = item as any; + thinkingItems.push({ + id: item.id, + content: thinkingItem.content || '', + isStreaming: thinkingItem.isStreaming || false, + isCollapsed: thinkingItem.isCollapsed || false, + timestamp: item.timestamp, + status: item.status || 'completed', + orderIndex: index, + subagentSessionId: thinkingItem.subagentSessionId, + attemptId: item.attemptId, + attemptIndex: item.attemptIndex, + }); + } + } + + const backendRound = { + id: round.id, + turnId, + roundIndex, + roundGroupId: round.roundGroupId, + timestamp: round.startTime, + renderHints: round.renderHints, + modelConfigId: round.modelConfigId, + effectiveModelName: round.effectiveModelName, + routing: round.routing, + textItems, + toolItems, + thinkingItems, + startTime: round.startTime, + endTime: round.endTime, + durationMs: round.durationMs, + firstChunkMs: round.firstChunkMs, + firstVisibleOutputMs: round.firstVisibleOutputMs, + streamDurationMs: round.streamDurationMs, + ttftMs: round.ttftMs, + attemptCount: round.attemptCount, + attemptDiagnostics: round.attemptDiagnostics, + status: round.status || 'completed', + }; + + if (isSettled) { + settledBackendRoundCache.set(round, { turnId, roundIndex, backendRound }); + } + return backendRound; +} + /** * Convert FlowChat DialogTurn to backend format */ @@ -456,99 +796,9 @@ export function convertDialogTurnToBackendFormat(dialogTurn: DialogTurn, turnInd timestamp: dialogTurn.userMessage.timestamp, metadata: mergedUserMetadata, }, - modelRounds: dialogTurn.modelRounds.map((round, roundIndex) => { - return { - id: round.id, - turnId: dialogTurn.id, - roundIndex, - roundGroupId: round.roundGroupId, - timestamp: round.startTime, - renderHints: round.renderHints, - modelConfigId: round.modelConfigId, - effectiveModelName: round.effectiveModelName, - routing: round.routing, - textItems: round.items - .map((item, index) => ({ item, index })) - .filter(({ item }) => item.type === 'text') - .map(({ item, index }) => { - return { - id: item.id, - content: (item as any).content || '', - isStreaming: (item as any).isStreaming || false, - isMarkdown: (item as any).isMarkdown !== undefined ? (item as any).isMarkdown : true, - timestamp: item.timestamp, - status: item.status || 'completed', - orderIndex: index, - subagentSessionId: (item as any).subagentSessionId, - attemptId: item.attemptId, - attemptIndex: item.attemptIndex, - }; - }), - toolItems: round.items - .map((item, index) => ({ item, index })) - .filter(({ item }) => item.type === 'tool') - .map(({ item, index }) => { - const toolItem = item as any; - const effective = effectiveToolInvocation(toolItem.toolName, toolItem.toolCall?.input); - if ( - toolItem.toolName === DEFERRED_TOOL_GATEWAY_NAME - && toolItem.status === 'completed' - && !effective.isDeferred - ) { - throw new Error(`Completed deferred tool is missing its wire invocation: ${item.id}`); - } - return { - id: item.id, - toolName: toolItem.toolName || '', - interruptionReason: toolItem.interruptionReason, - toolCall: toolItem.toolCall || { input: {}, id: item.id }, - toolResult: toolItem.toolResult, - aiIntent: toolItem.aiIntent, - requiresConfirmation: toolItem.requiresConfirmation, - userConfirmed: toolItem.userConfirmed, - acpPermission: toolItem.acpPermission, - startTime: toolItem.startTime || item.timestamp, - endTime: toolItem.endTime, - status: item.status || 'completed', - orderIndex: index, - subagentSessionId: toolItem.subagentSessionId, - subagentDialogTurnId: toolItem.subagentDialogTurnId, - subagentModelId: toolItem.subagentModelId, - subagentModelDisplayName: toolItem.subagentModelDisplayName, - attemptId: item.attemptId, - attemptIndex: item.attemptIndex, - }; - }), - thinkingItems: round.items - .map((item, index) => ({ item, index })) - .filter(({ item }) => item.type === 'thinking') - .map(({ item, index }) => { - const thinkingItem = item as any; - return { - id: item.id, - content: thinkingItem.content || '', - isStreaming: thinkingItem.isStreaming || false, - isCollapsed: thinkingItem.isCollapsed || false, - timestamp: item.timestamp, - status: item.status || 'completed', - orderIndex: index, - subagentSessionId: thinkingItem.subagentSessionId, - attemptId: item.attemptId, - attemptIndex: item.attemptIndex, - }; - }), - startTime: round.startTime, - endTime: round.endTime, - durationMs: round.durationMs, - firstChunkMs: round.firstChunkMs, - firstVisibleOutputMs: round.firstVisibleOutputMs, - streamDurationMs: round.streamDurationMs, - ttftMs: round.ttftMs, - attemptCount: round.attemptCount, - attemptDiagnostics: round.attemptDiagnostics, - status: round.status || 'completed', - }; - }), + modelRounds: dialogTurn.modelRounds.map((round, roundIndex) => + convertModelRoundToBackendFormat(round, dialogTurn.id, roundIndex), + ), startTime: dialogTurn.startTime, endTime: dialogTurn.endTime, tokenUsage: dialogTurn.tokenUsage @@ -585,17 +835,26 @@ export async function updateSessionMetadata( fields?: UiSessionMetadataField[], options?: { unreadReceipt?: SessionLastTurn }, ): Promise { + await performUpdateSessionMetadata(context, sessionId, fields, options); +} + +async function performUpdateSessionMetadata( + context: FlowChatContext, + sessionId: string, + fields?: UiSessionMetadataField[], + options?: { unreadReceipt?: SessionLastTurn }, +): Promise { const scope = getActiveSurfaceScope(); // Runtime checkpoints already update host activity and counts. A peer // controller writes presentation metadata only for an explicit UI change. - if (!fields && !isLocalSurface(scope.surfaceId)) return; + if (!fields && !isLocalSurface(scope.surfaceId)) return true; try { const { sessionAPI } = await import('@/infrastructure/api/service-api/SessionAPI'); - if (!scope.isCurrent()) return; + if (!scope.isCurrent()) return false; const session = context.flowChatStore.getState().sessions.get(sessionId); - if (!session) return; - if (isTransientSession(session) || isObserverOnlyDispatchSession(sessionId, session)) return; + if (!session) return false; + if (isTransientSession(session) || isObserverOnlyDispatchSession(sessionId, session)) return true; let existingMetadata: any = null; @@ -610,9 +869,9 @@ export async function updateSessionMetadata( // unreachable remote workspace, a transient read failure) the write // would reset tags and title facts; the next checkpoint retries. log.debug('Skipped session metadata checkpoint: stored record unreadable', { sessionId, error }); - return; + return false; } - if (!scope.isCurrent()) return; + if (!scope.isCurrent()) return false; const metadata = buildSessionMetadata(session, existingMetadata); if (fields?.includes('unreadCompletion')) { @@ -638,8 +897,10 @@ export async function updateSessionMetadata( metadata, requireSessionOwningWorkspaceId(session), fields ?? defaultFields); + return true; } catch (error) { log.warn('Failed to update session metadata', { sessionId, error }); + return false; } } diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.ts index c9f18a8bc0..b3a86d6535 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.ts @@ -337,6 +337,7 @@ export function cleanupSessionBuffers(context: FlowChatContext, sessionId: strin if (context.eventBatcher.getBufferSize() > 0) { context.eventBatcher.clearSession(sessionId); } + context.stagedSessionBatches?.delete(sessionId); const pendingCompletion = context.pendingTurnCompletions.get(sessionId); if (pendingCompletion) { @@ -387,6 +388,7 @@ export function clearAllBuffers(context: FlowChatContext): void { context.contentBuffers.clear(); context.activeTextItems.clear(); + context.stagedSessionBatches?.clear(); for (const timer of context.runtimeStatusTimers.values()) { clearTimeout(timer); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts index 6b00756aeb..80768267c6 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts @@ -376,11 +376,13 @@ function applyParamsPartial( if (typeof effectiveParams.payload === 'string') { effectiveParams.content = effectiveParams.payload; } - const extractedPath = extractFilePathFromJsonBuffer(newBuffer); const hasPath = ['file_path', 'filePath', 'filepath', 'target_file', 'targetFile', 'path', 'filename'] .some((key) => typeof effectiveParams[key] === 'string' && effectiveParams[key].length > 0); - if (extractedPath && !hasPath) { - effectiveParams.file_path = extractedPath; + if (!hasPath) { + const extractedPath = extractFilePathFromJsonBuffer(newBuffer); + if (extractedPath) { + effectiveParams.file_path = extractedPath; + } } } } @@ -1057,11 +1059,21 @@ export function handleToolExecutionProgress( ); const nextLogs = shouldAppend ? [...existingLogs, progress_message].slice(-200) : existingLogs; - store.updateModelRoundItem(sessionId, dialogTurn.id, tool_use_id, { + const progressUpdates = { _progressMessage: progress_message, _progressPercentage: percentage, _progressLogs: nextLogs - } as any); + } as any; + if ( + store.isSessionStreamVisible?.(sessionId) === false && + typeof store.runSilentWithoutNotify === 'function' + ) { + store.runSilentWithoutNotify(() => { + store.updateModelRoundItemSilent(sessionId, dialogTurn.id, tool_use_id, progressUpdates); + }); + } else { + store.updateModelRoundItem(sessionId, dialogTurn.id, tool_use_id, progressUpdates); + } found = true; break; diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts index a505edc593..900c86441a 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts @@ -64,6 +64,7 @@ export { mapBackendStateToFrontend, initializeEventListeners, processBatchedEvents, + materializeStagedSessionBatch, projectDialogTurnRecovered, } from './EventHandlerModule'; diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/types.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/types.ts index b5a8b23220..a382760a47 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/types.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/types.ts @@ -37,6 +37,13 @@ export interface FlowChatContext { contentBuffers: Map>; /** Active text items: sessionId -> (roundId -> textItemId) */ activeTextItems: Map>; + /** Coalesced streaming chunks staged for hidden sessions until visible or checkpointed. */ + stagedSessionBatches?: Map>; + /** Materializes any staged streaming chunks for `sessionId` into `FlowChatStore`. */ + materializeStagedSession?: ( + sessionId: string, + options?: { silent?: boolean; flushBatcher?: boolean }, + ) => boolean; /** Debounced save timers: key = "sessionId:turnId" */ saveDebouncers: Map>; /** Last save timestamps: key = "sessionId:turnId" */ diff --git a/src/web-ui/src/flow_chat/services/pendingWorkspaceSessionActivation.ts b/src/web-ui/src/flow_chat/services/pendingWorkspaceSessionActivation.ts new file mode 100644 index 0000000000..d47a1f972a --- /dev/null +++ b/src/web-ui/src/flow_chat/services/pendingWorkspaceSessionActivation.ts @@ -0,0 +1,40 @@ +/** + * Tracks an explicit cross-workspace session activation in flight so workspace + * bootstrap (`FlowChatManager.initializeWorkspace`) loads metadata without + * racing to auto-select and hydrate a different latest session. + */ + +interface PendingWorkspaceSessionActivation { + surfaceId: string; + workspaceId: string; + sessionId: string; + request: number; +} + +let currentPendingActivation: PendingWorkspaceSessionActivation | null = null; + +export function setPendingWorkspaceSessionActivation( + next: PendingWorkspaceSessionActivation, +): void { + currentPendingActivation = next; +} + +export function clearPendingWorkspaceSessionActivation(request: number): void { + if (currentPendingActivation?.request === request) { + currentPendingActivation = null; + } +} + +export function getPendingWorkspaceSessionActivation( + surfaceId: string, + workspaceId: string, +): string | null { + if ( + currentPendingActivation + && currentPendingActivation.surfaceId === surfaceId + && currentPendingActivation.workspaceId === workspaceId + ) { + return currentPendingActivation.sessionId; + } + return null; +} diff --git a/src/web-ui/src/flow_chat/services/sessionActivation.test.ts b/src/web-ui/src/flow_chat/services/sessionActivation.test.ts index 510d123899..ef1d50ff28 100644 --- a/src/web-ui/src/flow_chat/services/sessionActivation.test.ts +++ b/src/web-ui/src/flow_chat/services/sessionActivation.test.ts @@ -48,6 +48,7 @@ vi.mock('./storeSync', () => ({ })); import { openMainSession } from './sessionActivation'; +import { getPendingWorkspaceSessionActivation } from './pendingWorkspaceSessionActivation'; import { syncSessionToModernStore } from './storeSync'; describe('openMainSession resource activation', () => { @@ -173,6 +174,7 @@ describe('openMainSession resource activation', () => { config: { executionTarget: { kind: 'local', rootPath: worktree.rootPath } }, }); mocks.setActiveWorkspace.mockImplementation(async (id: string) => { + expect(getPendingWorkspaceSessionActivation('local', id)).toBe('in-tree-session'); mocks.workspaceState.activeWorkspaceId = id; }); mocks.switchChatSession.mockImplementation(async (id: string) => { @@ -181,6 +183,7 @@ describe('openMainSession resource activation', () => { await openMainSession('in-tree-session'); + expect(getPendingWorkspaceSessionActivation('local', worktree.id)).toBeNull(); expect(mocks.setActiveWorkspace.mock.calls.map(([id]) => id)).toEqual([worktree.id]); expect(mocks.switchChatSession.mock.calls.map(([id]) => id)).toEqual(['in-tree-session']); expect(mocks.workspaceState.activeWorkspaceId).toBe(worktree.id); diff --git a/src/web-ui/src/flow_chat/services/sessionActivation.ts b/src/web-ui/src/flow_chat/services/sessionActivation.ts index 05cd37436f..1657ae5c9b 100644 --- a/src/web-ui/src/flow_chat/services/sessionActivation.ts +++ b/src/web-ui/src/flow_chat/services/sessionActivation.ts @@ -8,6 +8,10 @@ import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurfac import { resolveSessionSceneWorkspace } from '@/app/services/sessionSceneTarget'; import { sessionProjectWorkspacePath } from '../utils/sessionWorkspace'; import { i18nService } from '@/infrastructure/i18n'; +import { + clearPendingWorkspaceSessionActivation, + setPendingWorkspaceSessionActivation, +} from './pendingWorkspaceSessionActivation'; interface SessionActivationOptions { workspaceId?: string; @@ -50,51 +54,79 @@ export async function activateMainSession(sessionId: string, options?: SessionAc && workspaceManager.getState().currentWorkspace) { throw new Error(i18nService.t('common:sceneBar.workspaceUnavailable')); } - if (workspaceId && workspaceManager.getState().activeWorkspaceId !== workspaceId) { + const needsWorkspaceSwitch = Boolean( + workspaceId && workspaceManager.getState().activeWorkspaceId !== workspaceId, + ); + if (needsWorkspaceSwitch && workspaceId) { + setPendingWorkspaceSessionActivation({ + surfaceId: scope.surfaceId, + workspaceId, + sessionId, + request, + }); // Read the session's history while its workspace is being activated // instead of after: the read addresses the session's own workspace, and // the switch below adopts the read already in flight. flowChatManager.prewarmHistoricalSession(sessionId); } - if (workspaceId) { - // Workspace activation mutates the host as well as the frontend. Serialize - // it so a slow A request cannot finish after a newer B request on the host. - const activation = workspaceActivation.then(async () => { - if (!isCurrent() || workspaceManager.getState().activeWorkspaceId === workspaceId) return; - await (options?.activateWorkspace ?? (id => workspaceManager.setActiveWorkspace(id)))(workspaceId); - }); - workspaceActivation = activation.catch(() => undefined); - await activation; - if (!isCurrent()) return false; - } - const isTargetActive = () => { - const state = flowChatStore.getState(); - return isCurrent() && state.activeSessionId === sessionId && state.sessions.has(sessionId); - }; - const targetSession = flowChatStore.getState().sessions.get(sessionId) ?? null; - if (!targetSession) { - return false; - } + let activatedTarget = false; + try { + if (workspaceId) { + // Workspace activation mutates the host as well as the frontend. Serialize + // it so a slow A request cannot finish after a newer B request on the host. + const activation = workspaceActivation.then(async () => { + if (!isCurrent() || workspaceManager.getState().activeWorkspaceId === workspaceId) return; + await (options?.activateWorkspace ?? (id => workspaceManager.setActiveWorkspace(id)))(workspaceId); + }); + workspaceActivation = activation.catch(() => undefined); + await activation; + if (!isCurrent()) return false; + } + const isTargetActive = () => { + const state = flowChatStore.getState(); + return isCurrent() && state.activeSessionId === sessionId && state.sessions.has(sessionId); + }; + const targetSession = flowChatStore.getState().sessions.get(sessionId) ?? null; + if (!targetSession) { + return false; + } - if (isTargetActive()) { - const activeSession = flowChatStore.getState().sessions.get(sessionId) ?? null; - if ( - activeSession?.externalHistory?.mode === 'follow' || (activeSession?.isHistorical && - (activeSession.historyState === 'metadata-only' || activeSession.historyState === 'failed')) - ) { + if (isTargetActive()) { + const activeSession = flowChatStore.getState().sessions.get(sessionId) ?? null; + if ( + activeSession?.externalHistory?.mode === 'follow' || (activeSession?.isHistorical && + (activeSession.historyState === 'metadata-only' || activeSession.historyState === 'failed')) + ) { + await flowChatManager.switchChatSession(sessionId, isCurrent); + if (!isTargetActive()) { + return false; + } + } + syncSessionToModernStore(sessionId); + } else { await flowChatManager.switchChatSession(sessionId, isCurrent); if (!isTargetActive()) { return false; } + syncSessionToModernStore(sessionId); } - syncSessionToModernStore(sessionId); - } else { - await flowChatManager.switchChatSession(sessionId, isCurrent); - if (!isTargetActive()) { - return false; + + activatedTarget = true; + return true; + } finally { + if (needsWorkspaceSwitch) { + clearPendingWorkspaceSessionActivation(request); + if ( + !activatedTarget + && workspaceId + && isCurrent() + && workspaceManager.getState().activeWorkspaceId === workspaceId + ) { + const ws = workspaceManager.getState().currentWorkspace; + if (ws && ws.id === workspaceId) { + void flowChatManager.initialize?.(ws); + } + } } - syncSessionToModernStore(sessionId); } - - return true; } diff --git a/src/web-ui/src/flow_chat/services/sessionNavStatusService.ts b/src/web-ui/src/flow_chat/services/sessionNavStatusService.ts index 83f097dac5..b063031ee7 100644 --- a/src/web-ui/src/flow_chat/services/sessionNavStatusService.ts +++ b/src/web-ui/src/flow_chat/services/sessionNavStatusService.ts @@ -13,10 +13,87 @@ import { driverForSession, sessionDriverNavigationStatusSources } from '../sessi import { deriveSessionNavStatus, type SessionNavStatus } from '../utils/sessionNavStatus'; import { SessionNavOrdering } from '../utils/sessionNavOrdering'; import { isUnmaterializedSessionDraft } from '../utils/sessionDraft'; -import type { Session } from '../types/flow-chat'; +import type { DialogTurn, ModelRound, Session } from '../types/flow-chat'; import { ensureActivePermissionMailbox, liveSessionInteractionStore } from './liveSessionInteractionStore'; import { SessionActivitySync, type ActivityTarget } from './sessionActivitySync'; +function sameRoundToolItems(left: ModelRound, right: ModelRound): boolean { + if (left === right) return true; + let leftIndex = 0; + let rightIndex = 0; + const leftItems = left.items; + const rightItems = right.items; + + while (true) { + while (leftIndex < leftItems.length && leftItems[leftIndex].type !== 'tool') { + leftIndex += 1; + } + while (rightIndex < rightItems.length && rightItems[rightIndex].type !== 'tool') { + rightIndex += 1; + } + if (leftIndex >= leftItems.length || rightIndex >= rightItems.length) { + return leftIndex >= leftItems.length && rightIndex >= rightItems.length; + } + if (leftItems[leftIndex] !== rightItems[rightIndex]) { + return false; + } + leftIndex += 1; + rightIndex += 1; + } +} + +function sameTurnNavStatusInputs(left: DialogTurn | undefined, right: DialogTurn | undefined): boolean { + if (left === right) return true; + if (!left || !right) return false; + if ( + left.id !== right.id + || left.kind !== right.kind + || left.status !== right.status + || left.recoveryEpoch !== right.recoveryEpoch + || left.recovery?.status !== right.recovery?.status + || left.recovery?.executionGeneration !== right.recovery?.executionGeneration + || left.modelRounds.length !== right.modelRounds.length + ) { + return false; + } + for (let index = 0; index < left.modelRounds.length; index += 1) { + const leftRound = left.modelRounds[index]; + const rightRound = right.modelRounds[index]; + if (leftRound === rightRound) continue; + if (leftRound.id !== rightRound.id || !sameRoundToolItems(leftRound, rightRound)) { + return false; + } + } + return true; +} + +function sameSessionNavStatusInputs(prior: Session, next: Session): boolean { + if ( + prior.status !== next.status + || prior.hasUnreadCompletion !== next.hasUnreadCompletion + || prior.needsUserAttention !== next.needsUserAttention + || prior.historyState !== next.historyState + || prior.isTransient !== next.isTransient + || prior.createdAt !== next.createdAt + || prior.lastFinishedAt !== next.lastFinishedAt + || prior.projectWorkspaceId !== next.projectWorkspaceId + || prior.workspaceId !== next.workspaceId + || prior.config.workspaceId !== next.config.workspaceId + || prior.config.dispatchJobId !== next.config.dispatchJobId + || prior.config.dispatchJobState !== next.config.dispatchJobState + || prior.dialogTurns.length !== next.dialogTurns.length + ) { + return false; + } + + for (let index = 0; index < prior.dialogTurns.length; index += 1) { + if (!sameTurnNavStatusInputs(prior.dialogTurns[index], next.dialogTurns[index])) { + return false; + } + } + return true; +} + const log = createLogger('SessionNavStatus'); const RECONCILE_INTERVAL_MS = 15_000; const SETTLED_RECONCILE_INTERVAL_MS = 60_000; @@ -205,6 +282,10 @@ export function installSessionNavStatusService(): () => void { for (const [sessionId, session] of state.sessions) { const prior = ordering.get(surfaceId, sessionId)?.session; if (prior === session) continue; + if (prior && !isUnmaterializedSessionDraft(prior) && sameSessionNavStatusInputs(prior, session)) { + ordering.updateSessionRef(surfaceId, session); + continue; + } publish(sessionId); if (prior?.needsUserAttention !== session?.needsUserAttention) { sessionActivityStore.invalidate(sessionId); diff --git a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts index 2c76d168fa..ef4ceb0097 100644 --- a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts +++ b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts @@ -384,6 +384,10 @@ export const localSessionDriver: SessionDriver = { } if (settledInFinishing) { + context.materializeStagedSession?.(sessionId, { + silent: !context.flowChatStore.isSessionStreamVisible(sessionId), + flushBatcher: true, + }); markCurrentTurnItemsAsCancelled(context, sessionId); cleanupSessionBuffers(context, sessionId); } diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 00035e0c34..030c0903c7 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -2318,9 +2318,13 @@ export class FlowChatStore { private listeners: Set<(state: FlowChatState) => void> = new Set(); private selectorListeners: Set = new Set(); private silentMode = false; + private silentModeDepth = 0; + private silentModeInitialState?: FlowChatState; private stateBatchDepth = 0; private stateBatchInitialState?: FlowChatState; private stateBatchRestoreSilentMode = false; + private beforeSessionVisibleCallbacks = new Set<(sessionId: string, options: { notify: boolean }) => void>(); + private sessionBuffersDiscardedCallbacks = new Set<(sessionId: string) => void>(); /** * Request dedup and negotiated capabilities outlive the switch that stranded * them, so their keys carry the surface instead of the container: an entry @@ -2451,13 +2455,62 @@ export class FlowChatStore { return this.state; } + public isSessionStreamVisible(sessionId: string): boolean { + if (!sessionId) return false; + return this.state.activeSessionId === sessionId || this.historyLeases.has(this.surfaceKey(sessionId)); + } + + public registerBeforeSessionVisibleCallback( + callback: (sessionId: string, options: { notify: boolean }) => void, + ): () => void { + this.beforeSessionVisibleCallbacks.add(callback); + return () => { + this.beforeSessionVisibleCallbacks.delete(callback); + }; + } + + private notifyBeforeSessionVisible(sessionId: string, options: { notify: boolean }): void { + if (this.beforeSessionVisibleCallbacks.size === 0) return; + for (const callback of this.beforeSessionVisibleCallbacks) { + try { + callback(sessionId, options); + } catch (error) { + log.warn('Before-session-visible callback threw an error', { sessionId, error }); + } + } + } + + public registerSessionBuffersDiscardedCallback( + callback: (sessionId: string) => void, + ): () => void { + this.sessionBuffersDiscardedCallbacks.add(callback); + return () => { + this.sessionBuffersDiscardedCallbacks.delete(callback); + }; + } + + private notifySessionBuffersDiscarded(sessionId: string): void { + if (!sessionId || this.sessionBuffersDiscardedCallbacks.size === 0) return; + for (const callback of this.sessionBuffersDiscardedCallbacks) { + try { + callback(sessionId); + } catch (error) { + log.warn('Session-buffers-discarded callback threw an error', { sessionId, error }); + } + } + } + /** A mounted transcript owns a lease; it never owns an inactive copy of its body. */ public retainSessionHistory(sessionId: string): () => void { const key = this.surfaceKey(sessionId); - this.historyLeases.set(key, (this.historyLeases.get(key) ?? 0) + 1); + const previousLeaseCount = this.historyLeases.get(key) ?? 0; + this.historyLeases.set(key, previousLeaseCount + 1); this.activeSurface.relaySessionHistory.get(sessionId)?.setVisible(true); const resident = this.historyResidency.get(key); if (resident) { resident.lastUsedAt = Date.now(); resourceBudget.touch(resident.key); } + if (previousLeaseCount === 0) { + this.notifyBeforeSessionVisible(sessionId, { notify: true }); + } let released = false; const surface = this.activeSurface; return () => { @@ -3531,6 +3584,7 @@ export class FlowChatStore { private clearRemovedSessionHistoryState(sessionIds: Iterable, reason: string): void { const removedSessionIds = new Set(sessionIds); for (const id of removedSessionIds) { + this.notifySessionBuffersDiscarded(id); this.activeSurface.relaySessionHistory.get(id)?.close(); this.activeSurface.relaySessionHistory.delete(id); this.activeSurface.relaySessionRecords.delete(id); @@ -4706,12 +4760,37 @@ export class FlowChatStore { } public beginSilentMode(): void { - this.silentMode = true; + if (this.silentModeDepth === 0) { + this.silentModeInitialState = this.state; + this.silentMode = true; + } + this.silentModeDepth += 1; } public endSilentMode(): void { + if (this.silentModeDepth > 0) { + this.silentModeDepth -= 1; + if (this.silentModeDepth > 0) { + return; + } + } + const initialState = this.silentModeInitialState; + const hadInitialState = this.silentModeInitialState !== undefined; this.silentMode = false; - this.notifyListeners(); + this.silentModeInitialState = undefined; + if (!hadInitialState || this.state !== initialState) { + this.notifyListeners(); + } + } + + public runSilentWithoutNotify(fn: () => T): T { + const prevSilentMode = this.silentMode; + this.silentMode = true; + try { + return fn(); + } finally { + this.silentMode = prevSilentMode; + } } private collectCascadeSessionIds( @@ -5034,6 +5113,9 @@ export class FlowChatStore { if (targetSessionExists && previousSessionId && previousSessionId !== sessionId) { this.cancelLocalSessionHistoryCompletion(previousSessionId, 'session-switch'); } + if (targetSessionExists && previousSessionId !== sessionId) { + this.notifyBeforeSessionVisible(sessionId, { notify: false }); + } let sessionMode: string | undefined; @@ -5058,9 +5140,11 @@ export class FlowChatStore { }; }); - window.dispatchEvent(new CustomEvent('openbitfun:session-switched', { - detail: { sessionId, mode: sessionMode || 'Standard' } - })); + if (typeof window !== 'undefined' && typeof CustomEvent !== 'undefined') { + window.dispatchEvent(new CustomEvent('openbitfun:session-switched', { + detail: { sessionId, mode: sessionMode || 'Standard' } + })); + } if (targetSessionExists && previousSessionId !== sessionId) { this.scheduleActiveLegacyPartialSessionHistoryCompletion(sessionId, 'session-switch'); @@ -5985,6 +6069,24 @@ export class FlowChatStore { this.clearRemovedSessionHistoryState(removedSessionIds, 'session-removed'); useBackgroundSubagentActivityStore.getState().removeSessions(removedSessionIds); + const preRemovedSet = new Set(removedSessionIds); + if (this.state.activeSessionId && preRemovedSet.has(this.state.activeSessionId)) { + let candidateNextActiveId: string | null = null; + if (resolvedOptions && 'nextActiveSessionId' in resolvedOptions) { + candidateNextActiveId = resolvedOptions.nextActiveSessionId ?? null; + } else { + for (const key of this.state.sessions.keys()) { + if (!preRemovedSet.has(key)) { + candidateNextActiveId = key; + break; + } + } + } + if (candidateNextActiveId) { + this.notifyBeforeSessionVisible(candidateNextActiveId, { notify: false }); + } + } + this.setState(prev => { const removedSessionIdSet = new Set(removedSessionIds); const newSessions = new Map(prev.sessions); @@ -6052,6 +6154,8 @@ export class FlowChatStore { const targetSessionId = sessionId || this.state.activeSessionId; if (!targetSessionId) return; + this.notifySessionBuffersDiscarded(targetSessionId); + this.setState(prev => { const session = prev.sessions.get(targetSessionId); if (!session) return prev; @@ -6522,6 +6626,7 @@ export class FlowChatStore { * Used for turn rollback: revert to before this turn and remove this turn and all subsequent history */ public truncateDialogTurnsFrom(sessionId: string, turnIndex: number): void { + this.notifySessionBuffersDiscarded(sessionId); this.setState(prev => { const session = prev.sessions.get(sessionId); @@ -7016,9 +7121,17 @@ export class FlowChatStore { const session = prev.sessions.get(sessionId); if (!session) return prev; - const updatedDialogTurns = session.dialogTurns.map(turn => - turn.id === dialogTurnId ? updater(turn) : turn - ); + const turnIndex = session.dialogTurns.findIndex(turn => turn.id === dialogTurnId); + if (turnIndex < 0) return prev; + + const currentTurn = session.dialogTurns[turnIndex]; + const nextTurn = updater(currentTurn); + if (nextTurn === currentTurn && options?.touchActivity !== true) { + return prev; + } + + const updatedDialogTurns = [...session.dialogTurns]; + updatedDialogTurns[turnIndex] = nextTurn; const updatedSession = { ...session, @@ -7252,7 +7365,13 @@ export class FlowChatStore { }); } - public addModelRoundItem(sessionId: string, dialogTurnId: string, item: AnyFlowItem, modelRoundId?: string): void { + public addModelRoundItem( + sessionId: string, + dialogTurnId: string, + item: AnyFlowItem, + modelRoundId?: string, + options?: { touchActivity?: boolean }, + ): void { this.updateDialogTurn(sessionId, dialogTurnId, turn => { let targetModelRoundIndex = turn.modelRounds.length - 1; if (modelRoundId) { @@ -7342,7 +7461,7 @@ export class FlowChatStore { ...turn, modelRounds: updatedModelRounds }; - }); + }, options); } /** @@ -7353,13 +7472,19 @@ export class FlowChatStore { const prevSilentMode = this.silentMode; this.silentMode = true; try { - this.addModelRoundItem(sessionId, dialogTurnId, item, modelRoundId); + this.addModelRoundItem(sessionId, dialogTurnId, item, modelRoundId, { touchActivity: false }); } finally { this.silentMode = prevSilentMode; } } - public updateModelRoundItem(sessionId: string, dialogTurnId: string, itemId: string, updates: Partial): boolean { + public updateModelRoundItem( + sessionId: string, + dialogTurnId: string, + itemId: string, + updates: Partial, + options?: { touchActivity?: boolean }, + ): boolean { let updated = false; this.updateDialogTurn(sessionId, dialogTurnId, turn => { @@ -7427,7 +7552,7 @@ export class FlowChatStore { ...turn, modelRounds: updatedModelRounds }; - }); + }, options); if (!updated) { markRuntimeSessionProjectionStale(getActiveSurfaceScope().surfaceId, sessionId); } @@ -7442,7 +7567,7 @@ export class FlowChatStore { const prevSilentMode = this.silentMode; this.silentMode = true; try { - return this.updateModelRoundItem(sessionId, dialogTurnId, itemId, updates); + return this.updateModelRoundItem(sessionId, dialogTurnId, itemId, updates, { touchActivity: false }); } finally { this.silentMode = prevSilentMode; } @@ -7828,6 +7953,7 @@ export class FlowChatStore { * Called by SessionStateMachine side effects, updates all related states to cancelled */ public cancelSessionTask(sessionId: string): void { + this.notifyBeforeSessionVisible(sessionId, { notify: false }); this.setState(prev => { const session = prev.sessions.get(sessionId); if (!session) { diff --git a/src/web-ui/src/flow_chat/store/backgroundSubagentActivityStore.ts b/src/web-ui/src/flow_chat/store/backgroundSubagentActivityStore.ts index 1765a6b4e9..0bdab5fcc8 100644 --- a/src/web-ui/src/flow_chat/store/backgroundSubagentActivityStore.ts +++ b/src/web-ui/src/flow_chat/store/backgroundSubagentActivityStore.ts @@ -37,8 +37,7 @@ function isSameActivity( left.remoteSshHost === right.remoteSshHost && left.parentToolCallId === right.parentToolCallId && left.subagentType === right.subagentType && - left.createdAt === right.createdAt && - left.updatedAt === right.updatedAt; + left.createdAt === right.createdAt; } export const useBackgroundSubagentActivityStore = create((set) => ({ diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts index d2ecf406ca..a8234c7da6 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts @@ -421,6 +421,8 @@ function projectTurnItemsWithPrefixCache( return reconciled; } +const MAX_CACHED_SESSION_PREFIXES = 12; + /** Each mounted projection owns its mutable streaming prefix. */ export function createSessionVirtualItemProjector( { reuseSnapshotsAcrossMounts = false }: { reuseSnapshotsAcrossMounts?: boolean } = {}, @@ -430,19 +432,37 @@ export function createSessionVirtualItemProjector( let warmProjections = reuseSnapshotsAcrossMounts ? retainedSessionProjections : new WeakMap(); - const cachedTurnPrefixById = new Map(); + const cachedTurnPrefixesBySession = new Map>(); const emptyItems: VirtualItem[] = []; + const getSessionPrefixCache = (targetSessionId: string): Map => { + let prefixMap = cachedTurnPrefixesBySession.get(targetSessionId); + if (prefixMap) { + cachedTurnPrefixesBySession.delete(targetSessionId); + cachedTurnPrefixesBySession.set(targetSessionId, prefixMap); + return prefixMap; + } + prefixMap = new Map(); + cachedTurnPrefixesBySession.set(targetSessionId, prefixMap); + while (cachedTurnPrefixesBySession.size > MAX_CACHED_SESSION_PREFIXES) { + const oldestKey = cachedTurnPrefixesBySession.keys().next().value; + if (oldestKey === undefined) break; + cachedTurnPrefixesBySession.delete(oldestKey); + } + return prefixMap; + }; + return (session: Session | null): VirtualItem[] => { if (!session) { sessionId = null; previousProjection = undefined; if (!reuseSnapshotsAcrossMounts) warmProjections = new WeakMap(); - cachedTurnPrefixById.clear(); + cachedTurnPrefixesBySession.clear(); return emptyItems; } - if (sessionId !== session.sessionId) cachedTurnPrefixById.clear(); + const previousSessionId = sessionId; sessionId = session.sessionId; + const cachedTurnPrefixById = getSessionPrefixCache(session.sessionId); const restored = warmProjections.get(session.dialogTurns); if (restored && restored.sessionId === session.sessionId @@ -450,8 +470,10 @@ export function createSessionVirtualItemProjector( && restored.isPartial === session.isPartial && restored.totalTurnCount === session.totalTurnCount && restored.needsUserAttention === session.needsUserAttention) { - // Returning to another snapshot must release the previous window's body. - if (restored !== previousProjection) cachedTurnPrefixById.clear(); + // Returning to another snapshot within the same session must release the previous window's body. + if (restored !== previousProjection && previousSessionId === session.sessionId) { + cachedTurnPrefixById.clear(); + } previousProjection = restored; return restored.virtualItems; } @@ -685,21 +707,30 @@ export const createModernFlowChatStore = (initialSession?: Session | null) => { setActiveSession: (session) => { const items = projectSession(session); - set((state) => ({ - ...(state.activeSession?.sessionId !== session?.sessionId ? { visibleTurnInfo: null } : null), - activeSession: session, - virtualItems: items, - })); + set((state) => { + if (state.activeSession === session && state.virtualItems === items) { + return state; + } + return { + ...(state.activeSession?.sessionId !== session?.sessionId ? { visibleTurnInfo: null } : null), + activeSession: session, + virtualItems: items, + }; + }); }, updateVirtualItems: () => { const session = get().activeSession; const items = projectSession(session); - set((state) => ({ - ...state, - virtualItems: items, - })); + set((state) => ( + state.virtualItems === items + ? state + : { + ...state, + virtualItems: items, + } + )); }, setVisibleTurnInfo: (info) => { diff --git a/src/web-ui/src/flow_chat/timeline/retainedViewMemory.ts b/src/web-ui/src/flow_chat/timeline/retainedViewMemory.ts index d8fbdab8f3..0c4ba8e4c0 100644 --- a/src/web-ui/src/flow_chat/timeline/retainedViewMemory.ts +++ b/src/web-ui/src/flow_chat/timeline/retainedViewMemory.ts @@ -47,8 +47,8 @@ export class RetainedViewMemory { } } -/** About three: the session just left, and the one or two before it. */ -export const MAX_RETAINED_SESSION_VIEWS = 3; +/** Retain recently left sessions across concurrent multi-session switches. */ +export const MAX_RETAINED_SESSION_VIEWS = 8; /** * Measured row sizes of recently left transcripts, by the view's reader store. diff --git a/src/web-ui/src/flow_chat/timeline/useConversationTimeline.retention.test.tsx b/src/web-ui/src/flow_chat/timeline/useConversationTimeline.retention.test.tsx index 94f8acfc05..6b7d4b796a 100644 --- a/src/web-ui/src/flow_chat/timeline/useConversationTimeline.retention.test.tsx +++ b/src/web-ui/src/flow_chat/timeline/useConversationTimeline.retention.test.tsx @@ -3,6 +3,7 @@ import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import type { VirtualItem } from '../types/flow-chat-projection'; +import { MAX_RETAINED_SESSION_VIEWS } from './retainedViewMemory'; import { retainedTimelineProjections, useConversationTimeline } from './useConversationTimeline'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -54,8 +55,10 @@ it('reopens a recently left session with the rows its projection already built', it('builds a fresh projection for a session left too long ago', () => { const first = open('a'); - for (const id of ['b', 'c', 'd', 'e']) open(id); - expect(retainedTimelineProjections.size).toBe(3); + for (let index = 0; index <= MAX_RETAINED_SESSION_VIEWS; index += 1) { + open(`other-${index}`); + } + expect(retainedTimelineProjections.size).toBe(MAX_RETAINED_SESSION_VIEWS); const reopened = open('a'); expect(reopened).toHaveLength(first.length); expect(reopened[0]).not.toBe(first[0]); diff --git a/src/web-ui/src/flow_chat/timeline/useConversationTimeline.ts b/src/web-ui/src/flow_chat/timeline/useConversationTimeline.ts index 3879dadc71..e66a30972e 100644 --- a/src/web-ui/src/flow_chat/timeline/useConversationTimeline.ts +++ b/src/web-ui/src/flow_chat/timeline/useConversationTimeline.ts @@ -20,6 +20,11 @@ export const retainedTimelineProjections = new RetainedViewMemory(); +const prewarmedSources = new WeakSet(); + export function useConversationTimeline(sources: VirtualItem[], sessionId: string | undefined, viewport: string) { const { t } = useI18n('flow-chat'); const transcriptSources = useHostQueueTimeline(sources, sessionId); @@ -41,19 +46,11 @@ export function useConversationTimeline(sources: VirtualItem[], sessionId: strin return () => retainedTimelineProjections.put(reader, projection); }, [reader, projection]); const labels = useMemo(() => flowGroupToolLabels(t), [t]); - // Every streamed flush produces a new sources array; skip texts already - // queued or parsed instead of re-walking the whole timeline each time. - const prewarmedTextsRef = useRef>(new WeakSet()); - // A projected source is reused by identity only while its members are the - // same objects, so a source already walked has nothing new to queue. - const prewarmedSourcesRef = useRef>(new WeakSet()); useEffect(() => { - const seen = prewarmedTextsRef.current; - const seenSources = prewarmedSourcesRef.current; const texts = new Set(); for (const source of sources) { - if (seenSources.has(source)) continue; - seenSources.add(source); + if (prewarmedSources.has(source)) continue; + prewarmedSources.add(source); const members = source.type === 'explore-group' ? source.data.allItems : source.type === 'model-round' ? source.projectedGroups?.flatMap(group => group.type === 'critical' ? [group.item] : group.projection?.allItems ?? group.items) ?? source.data.items : []; @@ -61,8 +58,8 @@ export function useConversationTimeline(sources: VirtualItem[], sessionId: strin if (member.type !== 'text' || !('content' in member) || typeof member.content !== 'string') continue; if ('isStreaming' in member && member.isStreaming) continue; if (member.content.length < 2000) continue; - if (seen.has(member)) continue; - seen.add(member); + if (prewarmedTexts.has(member)) continue; + prewarmedTexts.add(member); texts.add(member.content); } } diff --git a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx index 5992bcbb5f..e6583c2b87 100644 --- a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx @@ -166,7 +166,7 @@ const GenericFileOperationToolCard: React.FC = ({ snapshotsAvailable: nativeSnapshotsAvailable, error, clearError - } = useSnapshotState(imported ? undefined : sessionId); + } = useSnapshotState(imported ? undefined : sessionId, { trackFiles: false }); const snapshotsAvailable = !imported && nativeSnapshotsAvailable; // A recorded operation is viewable even when older Session history has no // complete snapshot coverage. Absence preserves compatibility with old hosts. diff --git a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx index a48c2b1c13..7a4dc88f4f 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx @@ -109,12 +109,30 @@ export const ModelThinkingDisplay: React.FC = ({ const yieldToSuccessor = successorReady && (!(readerChoice ?? revealStreamingContent) || successor.immediate); const lastPreview = useRef(''); const expandContainerRef = useRef(null); + const thinkingPaintIntervalMs = !isActive || isSummary + ? undefined + : content.length > 10_000 + ? 48 + : content.length > 4_000 + ? 32 + : undefined; + const thinkingMaxCharsPerPaint = !isActive || isSummary + ? undefined + : content.length > 10_000 + ? 640 + : content.length > 4_000 + ? 400 + : undefined; const { displayText: displayContent, isRevealing } = useTypewriter( isSummary ? '' : content, isActive && !isSummary, // Keep playback through the closing transition, then release the reveal // gate and track current content without animating an invisible backlog. - { revealImmediately: !retainClosingContent || yieldToSuccessor }, + { + revealImmediately: !retainClosingContent || yieldToSuccessor, + minPaintIntervalMs: thinkingPaintIntervalMs, + maxCharsPerPaint: thinkingMaxCharsPerPaint, + }, ); // Keep footer presentation behind the handoff as well. A parent-owned fold // must not wait for its own retained child to close (a circular reveal gate). @@ -199,7 +217,18 @@ export const ModelThinkingDisplay: React.FC = ({ }, [isVisuallyStreaming, streamingViewportExpanded, revealStreamingContent, streamIdentity, resume]); const contentLengthText = useMemo(() => { - return t('toolCards.think.thinkingCharacters', { count: Array.from(content).length }); + let count = 0; + for (let i = 0; i < content.length; i += 1) { + const code = content.charCodeAt(i); + if (code >= 0xd800 && code <= 0xdbff && i + 1 < content.length) { + const low = content.charCodeAt(i + 1); + if (low >= 0xdc00 && low <= 0xdfff) { + i += 1; + } + } + count += 1; + } + return t('toolCards.think.thinkingCharacters', { count }); }, [content, t]); const summaryPreview = useMemo( diff --git a/src/web-ui/src/flow_chat/tool-cards/fileOperationDiffStats.ts b/src/web-ui/src/flow_chat/tool-cards/fileOperationDiffStats.ts index 467aaffff4..11d4dbed9f 100644 --- a/src/web-ui/src/flow_chat/tool-cards/fileOperationDiffStats.ts +++ b/src/web-ui/src/flow_chat/tool-cards/fileOperationDiffStats.ts @@ -23,16 +23,37 @@ export function fileOperationPreview(item: FlowToolItem) { }; } +const MAX_STREAMING_EXACT_DIFF_LINES = 80; +const MAX_INLINE_DIFF_CHARS = 100_000; + +function countContentLines(text: string): number { + if (!text) return 0; + let lines = 1; + for (let i = 0; i < text.length; i += 1) { + if (text.charCodeAt(i) === 10) { + lines += 1; + } + } + return text.charCodeAt(text.length - 1) === 10 ? lines - 1 : lines; +} + /** The same per-operation fallback used by a native card and its collection. */ export function localFileOperationDiffStats(item: FlowToolItem): FileOperationDiffStats { const tool = projectEffectiveToolItem(item); if (getToolCardStatus(tool) === 'error') return EMPTY_FILE_DIFF_STATS; const { oldStringContent, newStringContent, contentPreview } = fileOperationPreview(tool); if (tool.toolName === 'Write' && contentPreview) { - const lines = contentPreview.split('\n'); - return { additions: lines.length - (lines.at(-1) === '' ? 1 : 0), deletions: 0 }; + return { additions: countContentLines(contentPreview), deletions: 0 }; } if (tool.toolName === 'Edit' && (oldStringContent || newStringContent)) { + const oldLines = countContentLines(oldStringContent); + const newLines = countContentLines(newStringContent); + if ( + (tool.isParamsStreaming && oldLines + newLines > MAX_STREAMING_EXACT_DIFF_LINES) || + oldStringContent.length + newStringContent.length > MAX_INLINE_DIFF_CHARS + ) { + return { additions: newLines, deletions: oldLines }; + } const stats = { additions: 0, deletions: 0 }; for (const change of diffLines(oldStringContent, newStringContent)) { if (change.added) stats.additions += change.count ?? 0; diff --git a/src/web-ui/src/flow_chat/utils/askUserQuestionState.ts b/src/web-ui/src/flow_chat/utils/askUserQuestionState.ts index e9c9ea55d4..b7f6b9e4ba 100644 --- a/src/web-ui/src/flow_chat/utils/askUserQuestionState.ts +++ b/src/web-ui/src/flow_chat/utils/askUserQuestionState.ts @@ -1,5 +1,5 @@ import { stateMachineManager } from '../state-machine/SessionStateMachineManager'; -import type { DialogTurn, FlowToolItem, Session } from '../types/flow-chat'; +import type { DialogTurn, FlowToolItem, ModelRound, Session } from '../types/flow-chat'; import { effectiveToolInvocation } from './toolInvocationIdentity'; export const TRANSIENT_TURN_STATUSES = new Set([ @@ -17,6 +17,33 @@ const TERMINAL_TOOL_STATUSES = new Set([ 'rejected', ]); +function findPendingAskUserQuestionInRound(round: ModelRound): FlowToolItem | undefined { + for (let itemIndex = round.items.length - 1; itemIndex >= 0; itemIndex -= 1) { + const item = round.items[itemIndex]; + if ( + item.type !== 'tool' + || TERMINAL_TOOL_STATUSES.has(item.status) + || item.isParamsStreaming + ) { + continue; + } + + const effective = effectiveToolInvocation(item.toolName, item.toolCall?.input); + if (effective.toolName !== 'AskUserQuestion') { + continue; + } + + const questions = effective.input && typeof effective.input === 'object' + ? (effective.input as Record).questions + : undefined; + if (Array.isArray(questions) && questions.length > 0) { + return item; + } + } + + return undefined; +} + /** * Scan a dialog turn's model rounds (newest-first) for a non-terminal * AskUserQuestion tool item whose parameters have finished streaming and @@ -34,28 +61,9 @@ export function findPendingAskUserQuestion( } for (let roundIndex = turn.modelRounds.length - 1; roundIndex >= 0; roundIndex -= 1) { - const round = turn.modelRounds[roundIndex]; - for (let itemIndex = round.items.length - 1; itemIndex >= 0; itemIndex -= 1) { - const item = round.items[itemIndex]; - if ( - item.type !== 'tool' - || TERMINAL_TOOL_STATUSES.has(item.status) - || item.isParamsStreaming - ) { - continue; - } - - const effective = effectiveToolInvocation(item.toolName, item.toolCall?.input); - if (effective.toolName !== 'AskUserQuestion') { - continue; - } - - const questions = effective.input && typeof effective.input === 'object' - ? (effective.input as Record).questions - : undefined; - if (Array.isArray(questions) && questions.length > 0) { - return item; - } + const pending = findPendingAskUserQuestionInRound(turn.modelRounds[roundIndex]); + if (pending) { + return pending; } } diff --git a/src/web-ui/src/flow_chat/utils/sessionNavOrdering.ts b/src/web-ui/src/flow_chat/utils/sessionNavOrdering.ts index dadedf34e0..ca1ccb7fa7 100644 --- a/src/web-ui/src/flow_chat/utils/sessionNavOrdering.ts +++ b/src/web-ui/src/flow_chat/utils/sessionNavOrdering.ts @@ -22,6 +22,13 @@ export class SessionNavOrdering { return this.surfaces.get(surfaceId)?.keys() ?? []; } + updateSessionRef(surfaceId: DeviceSurfaceId, session: Session): void { + const entry = this.surfaces.get(surfaceId)?.get(session.sessionId); + if (entry) { + entry.session = session; + } + } + observe(surfaceId: DeviceSurfaceId, session: Session, status: SessionNavStatus, now = Date.now()): boolean { let entries = this.surfaces.get(surfaceId); if (!entries) this.surfaces.set(surfaceId, entries = new Map()); diff --git a/src/web-ui/src/flow_chat/utils/sessionReviewActivity.ts b/src/web-ui/src/flow_chat/utils/sessionReviewActivity.ts index 8ce6fde304..a5f57928b6 100644 --- a/src/web-ui/src/flow_chat/utils/sessionReviewActivity.ts +++ b/src/web-ui/src/flow_chat/utils/sessionReviewActivity.ts @@ -118,6 +118,27 @@ export function isReviewActivityBlocking( return Boolean(activity?.isBlocking); } +export function isSameSessionReviewActivity( + left: SessionReviewActivity | null, + right: SessionReviewActivity | null, +): boolean { + if (left === right) { + return true; + } + if (!left || !right) { + return false; + } + return ( + left.parentSessionId === right.parentSessionId && + left.childSessionId === right.childSessionId && + left.kind === right.kind && + left.lifecycle === right.lifecycle && + left.isBlocking === right.isBlocking && + left.startedAt === right.startedAt && + left.updatedAt === right.updatedAt + ); +} + export function deriveSessionReviewActivity( state: FlowChatState, parentSessionId?: string | null, @@ -127,15 +148,28 @@ export function deriveSessionReviewActivity( return null; } - const activities = Array.from(state.sessions.values()) - .map(session => toReviewActivity(session, parentSessionId, resolveExecutionState)) - .filter((activity): activity is SessionReviewActivity => Boolean(activity)); + let bestBlocking: SessionReviewActivity | null = null; + let bestAny: SessionReviewActivity | null = null; + + const isNewer = (candidate: SessionReviewActivity, current: SessionReviewActivity | null): boolean => { + if (!current) return true; + const updatedDelta = candidate.updatedAt - current.updatedAt; + return updatedDelta !== 0 ? updatedDelta > 0 : candidate.startedAt > current.startedAt; + }; - const blockingActivities = activities.filter(activity => activity.isBlocking); - const candidates = blockingActivities.length > 0 ? blockingActivities : activities; + for (const session of state.sessions.values()) { + const activity = toReviewActivity(session, parentSessionId, resolveExecutionState); + if (!activity) { + continue; + } + if (activity.isBlocking) { + if (isNewer(activity, bestBlocking)) { + bestBlocking = activity; + } + } else if (!bestBlocking && isNewer(activity, bestAny)) { + bestAny = activity; + } + } - return candidates.sort((left, right) => { - const updatedDelta = right.updatedAt - left.updatedAt; - return updatedDelta !== 0 ? updatedDelta : right.startedAt - left.startedAt; - })[0] ?? null; + return bestBlocking ?? bestAny; } diff --git a/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.test.ts b/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.test.ts index e643e48921..55b5a20b71 100644 --- a/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.test.ts +++ b/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { isExpectedTauriRequestError, TauriTransportAdapter } from './tauri-adapter'; +import { + isExpectedTauriRequestError, + TAURI_EVENT_BATCH_NAME, + TauriTransportAdapter, +} from './tauri-adapter'; import { beginRuntimeSessionAttachment, resetRuntimeSessionEventGateForTest, @@ -261,4 +265,60 @@ describe('Tauri adapter expected errors', () => { unlistenSecond(); }); + + it('unpacks agentic://event-batch items in FIFO cursor order across subscribers', async () => { + const handlers = new Map void>(); + listenMock.mockImplementation(async (event: string, handler: (event: { payload: unknown }) => void) => { + handlers.set(event, handler); + return vi.fn(); + }); + + const adapter = new TauriTransportAdapter(); + const order: string[] = []; + const unlistenBatch = adapter.listen(TAURI_EVENT_BATCH_NAME, () => {}); + const unlistenText = adapter.listen<{ sessionId: string; text: string }>('agentic://text-chunk', payload => { + order.push(`text:${payload.sessionId}:${payload.text}`); + }); + const unlistenDone = adapter.listen<{ sessionId: string }>('agentic://dialog-turn-completed', payload => { + order.push(`done:${payload.sessionId}`); + }); + await adapter.waitForListenerRegistrations(); + + handlers.get(TAURI_EVENT_BATCH_NAME)?.({ + payload: [ + { + event: 'agentic://text-chunk', + payload: { + sessionId: 's1', + text: 'hello', + [RUNTIME_EVENT_STREAM_ID_KEY]: 'rt-1', + [RUNTIME_EVENT_CURSOR_KEY]: 1, + }, + }, + { + event: 'agentic://text-chunk', + payload: { + sessionId: 's2', + text: 'world', + [RUNTIME_EVENT_STREAM_ID_KEY]: 'rt-1', + [RUNTIME_EVENT_CURSOR_KEY]: 2, + }, + }, + { + event: 'agentic://dialog-turn-completed', + payload: { + sessionId: 's1', + turnId: 't1', + [RUNTIME_EVENT_STREAM_ID_KEY]: 'rt-1', + [RUNTIME_EVENT_CURSOR_KEY]: 3, + }, + }, + ], + }); + + expect(order).toEqual(['text:s1:hello', 'text:s2:world', 'done:s1']); + unlistenBatch(); + unlistenText(); + unlistenDone(); + }); }); diff --git a/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.ts index 29dbd6a829..e5c8de84ce 100644 --- a/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.ts @@ -4,11 +4,20 @@ import { listen, UnlistenFn } from '@tauri-apps/api/event'; import { elapsedMs, nowMs } from '@/shared/utils/timing'; import { ITransportAdapter, type TransportRequestTiming } from './base'; import { createLogger } from '@/shared/utils/logger'; -import { getActiveSurfaceDeviceId, isSurfaceScopedEvent, routeSurfaceEvent } from '@/infrastructure/peer-device/deviceSurfaceRouting'; +import { + getActiveSurfaceDeviceId, + isSurfaceScopedEvent, + PEER_EVENT_SOURCE_KEY, + readSourceDeviceId, + routeSurfaceEvent, + unwrapSurfacePayload, +} from '@/infrastructure/peer-device/deviceSurfaceRouting'; import { getActiveSurfaceScope, surfaceIdForDevice } from '@/infrastructure/peer-device/deviceSurface'; import { markRuntimeSessionProjectionStale, routeRuntimeSessionEvent } from '@/infrastructure/peer-device/runtimeSessionEventGate'; import { sanitizeErrorForLog } from '../logSanitizer'; +export const TAURI_EVENT_BATCH_NAME = 'agentic://event-batch'; + const log = createLogger('TauriAdapter'); interface SharedTauriEventListener { @@ -52,6 +61,87 @@ function closeSharedTauriEventListener( } } +function dispatchSharedTauriEvent(event: string, rawPayload: unknown): void { + const shared = sharedTauriEventListeners.get(event); + + // Capture the logical listeners that owned the event when it arrived. A + // Session read may hold delivery after accepting the write; those owners + // may paint it if still subscribed when released; later subscribers must + // not receive the native event retroactively. + const subscriptions = shared && !shared.closed ? [...shared.subscriptions] : []; + + // Peer devices stay attached while the UI renders another device, so + // several product event streams share this bus. Always route through surface + // observation first (even when unpacked from a batch with no local channel + // listener), while only the rendered device surface reaches product listeners. + const route = routeSurfaceEvent(event, rawPayload); + if (!route.deliver || !shared || shared.closed) { + return; + } + + const sourceSurfaceId = surfaceIdForDevice(route.sourceDeviceId); + const scope = getActiveSurfaceScope(); + routeRuntimeSessionEvent( + sourceSurfaceId, + event, + route.payload, + payload => { + // Fenced delivery can outlive a device switch or an unsubscribe. Never + // call an obsolete listener against the newly selected store container. + const currentSubscriptions = subscriptions.filter(subscription => shared.subscriptions.has(subscription)); + if (!scope.isCurrent() || + (isSurfaceScopedEvent(event) && getActiveSurfaceDeviceId() !== route.sourceDeviceId) || + shared.closed || currentSubscriptions.length === 0) { + const sessionId = (payload as { sessionId?: unknown } | null)?.sessionId; + if (typeof sessionId === 'string') markRuntimeSessionProjectionStale(sourceSurfaceId, sessionId); + return; + } + for (const subscription of currentSubscriptions) { + try { + subscription.callback(payload); + } catch (error) { + log.error('Error in event listener callback', { + event, + error: sanitizeErrorForLog(error), + }); + } + } + }, + ); +} + +function dispatchTauriEventBatch(rawPayload: unknown): void { + const outerSourceDeviceId = readSourceDeviceId(rawPayload); + const unwrapped = unwrapSurfacePayload(rawPayload); + if (!Array.isArray(unwrapped)) { + return; + } + + for (const item of unwrapped) { + if (!item || typeof item !== 'object') { + continue; + } + const record = item as { event?: unknown; payload?: unknown }; + if (typeof record.event !== 'string' || !record.event) { + continue; + } + let itemPayload = record.payload; + if ( + outerSourceDeviceId && + itemPayload && + typeof itemPayload === 'object' && + !Array.isArray(itemPayload) && + !(PEER_EVENT_SOURCE_KEY in (itemPayload as Record)) + ) { + itemPayload = { + ...(itemPayload as Record), + [PEER_EVENT_SOURCE_KEY]: outerSourceDeviceId, + }; + } + dispatchSharedTauriEvent(record.event, itemPayload); + } +} + function registerSharedTauriEventListener( event: string, shared: SharedTauriEventListener, @@ -61,49 +151,12 @@ function registerSharedTauriEventListener( return; } - // Capture the logical listeners that owned the event when it arrived. A - // Session read may hold delivery after accepting the write; those owners - // may paint it if still subscribed when released; later subscribers must - // not receive the native event retroactively. - const subscriptions = [...shared.subscriptions]; - - // Peer devices stay attached while the UI renders another device, so - // several product event streams share this bus. Only the rendered device - // surface may reach product listeners. - const route = routeSurfaceEvent(event, e.payload); - if (!route.deliver) { + if (event === TAURI_EVENT_BATCH_NAME) { + dispatchTauriEventBatch(e.payload); return; } - const sourceSurfaceId = surfaceIdForDevice(route.sourceDeviceId); - const scope = getActiveSurfaceScope(); - routeRuntimeSessionEvent( - sourceSurfaceId, - event, - route.payload, - payload => { - // Fenced delivery can outlive a device switch or an unsubscribe. Never - // call an obsolete listener against the newly selected store container. - const currentSubscriptions = subscriptions.filter(subscription => shared.subscriptions.has(subscription)); - if (!scope.isCurrent() || - (isSurfaceScopedEvent(event) && getActiveSurfaceDeviceId() !== route.sourceDeviceId) || - shared.closed || currentSubscriptions.length === 0) { - const sessionId = (payload as { sessionId?: unknown } | null)?.sessionId; - if (typeof sessionId === 'string') markRuntimeSessionProjectionStale(sourceSurfaceId, sessionId); - return; - } - for (const subscription of currentSubscriptions) { - try { - subscription.callback(payload); - } catch (error) { - log.error('Error in event listener callback', { - event, - error: sanitizeErrorForLog(error), - }); - } - } - }, - ); + dispatchSharedTauriEvent(event, e.payload); }).then(fn => { if (shared.closed || sharedTauriEventListeners.get(event) !== shared) { fn(); 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 dc6564865f..c072d9528c 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -1488,6 +1488,10 @@ export class AgentAPI { return api.listen('agentic://text-chunk', callback); } + onEventBatch(callback: () => void = () => {}): () => void { + return api.listen('agentic://event-batch', callback); + } + onToolEvent(callback: (event: ToolEvent) => void): () => void { return api.listen('agentic://tool-event', callback); diff --git a/src/web-ui/src/infrastructure/contexts/WorkspaceProvider.tsx b/src/web-ui/src/infrastructure/contexts/WorkspaceProvider.tsx index ab7c00e415..0589361acd 100644 --- a/src/web-ui/src/infrastructure/contexts/WorkspaceProvider.tsx +++ b/src/web-ui/src/infrastructure/contexts/WorkspaceProvider.tsx @@ -13,6 +13,53 @@ import { const log = createLogger('WorkspaceProvider'); +function sameStringList(left: readonly string[] | undefined, right: readonly string[] | undefined): boolean { + if (left === right) return true; + if (!left || !right || left.length !== right.length) return false; + for (let i = 0; i < left.length; i += 1) { + if (left[i] !== right[i]) return false; + } + return true; +} + +function sameWorkspaceInfoEntry(left: WorkspaceInfo | null, right: WorkspaceInfo | null): boolean { + if (left === right) return true; + if (!left || !right) return false; + return left.id === right.id + && left.name === right.name + && left.rootPath === right.rootPath + && left.workspaceType === right.workspaceType + && left.workspaceKind === right.workspaceKind + && left.assistantId === right.assistantId + && left.defaultNameNumber === right.defaultNameNumber + && left.openedAt === right.openedAt + && left.lastAccessed === right.lastAccessed + && left.description === right.description + && left.connectionId === right.connectionId + && left.connectionName === right.connectionName + && left.sshHost === right.sshHost + && left.statistics === right.statistics + && left.identity === right.identity + && left.relatedPaths === right.relatedPaths + && left.worktree === right.worktree + && sameStringList(left.languages, right.languages) + && sameStringList(left.tags, right.tags); +} + +function sameWorkspaceList( + left: readonly WorkspaceInfo[] | null, + right: readonly WorkspaceInfo[] | null, +): boolean { + if (left === right) return true; + if (!left || !right || left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + if (!sameWorkspaceInfoEntry(left[index], right[index])) { + return false; + } + } + return true; +} + interface WorkspaceProviderProps { children: ReactNode; } @@ -150,11 +197,28 @@ export const WorkspaceProvider: React.FC = ({ children } const activeWorkspace = nextState.currentWorkspace; const openedWorkspacesList = Array.from(nextState.openedWorkspaces.values()); + if ( + prev.activeWorkspaceId === nextState.activeWorkspaceId + && prev.lastUsedWorkspaceId === nextState.lastUsedWorkspaceId + && prev.primaryAssistantWorkspaceId === nextState.primaryAssistantWorkspaceId + && prev.loading === nextState.loading + && prev.error === nextState.error + && sameWorkspaceInfoEntry(prev.currentWorkspace, activeWorkspace) + && sameWorkspaceList(prev.openedWorkspacesList, openedWorkspacesList) + && sameWorkspaceList(prev.recentWorkspaces, nextState.recentWorkspaces) + && sameWorkspaceList(prev.assistantWorkspaces, nextState.assistantWorkspaces) + ) { + return prev; + } + return { ...prev, ...nextState, activeWorkspace, openedWorkspacesList, + allAssistantWorkspacesList: nextState.assistantWorkspaces ?? openedWorkspacesList.filter( + workspace => workspace.workspaceKind === WorkspaceKind.Assistant + ), normalWorkspacesList: openedWorkspacesList.filter( (workspace) => workspace.workspaceKind !== WorkspaceKind.Assistant ), diff --git a/src/web-ui/src/infrastructure/markdown/MarkdownMathRenderer.tsx b/src/web-ui/src/infrastructure/markdown/MarkdownMathRenderer.tsx index a4a121a56b..f720299eff 100644 --- a/src/web-ui/src/infrastructure/markdown/MarkdownMathRenderer.tsx +++ b/src/web-ui/src/infrastructure/markdown/MarkdownMathRenderer.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import ReactMarkdown from 'react-markdown'; import type { Components } from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -16,6 +16,23 @@ import { rehypeSourceRange, type MarkdownSourceRange } from './rehypeSourceRange import { rehypeWindowsDrivePaths } from './rehypeWindowsDrivePaths'; import { rehypeImageThumbnails } from './rehypeImageThumbnails'; +function hasRawHastNode(node: any): boolean { + if (!node || typeof node !== 'object') return false; + if (node.type === 'raw') return true; + const children = node.children; + if (Array.isArray(children)) { + for (let i = 0; i < children.length; i += 1) { + if (hasRawHastNode(children[i])) return true; + } + } + return false; +} + +function rehypeRawIfPresent() { + const runRehypeRaw = rehypeRaw(); + return (tree: any, file: any) => (hasRawHastNode(tree) ? runRehypeRaw(tree, file) : tree); +} + interface MarkdownMathRendererProps { markdownContent: string; isStreaming?: boolean; @@ -39,10 +56,31 @@ export const MarkdownMathRenderer: React.FC = ({ inline = false, imageThumbnails = false, }) => { + const remarkPlugins = useMemo( + () => [ + [remarkGfm, markdownGfmOptions], + remarkMath, + [remarkStreamingTableLinks, { isStreaming }], + remarkAutolinkBoundaries, + remarkAutolinkComputerFileLinks, + ] as any, + [isStreaming, remarkAutolinkComputerFileLinks], + ); + const rehypePlugins = useMemo( + () => [ + rehypeRawIfPresent, + [rehypeWindowsDrivePaths, { markdownSource: markdownContent }], + [rehypeSanitize, sanitizeSchema], + [rehypeSourceRange, sourceRange], + rehypeKatex, + [rehypeImageThumbnails, { enabled: imageThumbnails }], + ] as any, + [markdownContent, sanitizeSchema, sourceRange, imageThumbnails], + ); const content = ( diff --git a/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx b/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx index 72dbaa15db..6f53f75ab2 100644 --- a/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx +++ b/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx @@ -56,6 +56,25 @@ const MarkdownMathRenderer = lazyWithRecovery(() => import('./MarkdownMathRender const ThinkingMarkdown = lazyWithRecovery(() => import('./ThinkingMarkdown')); const InlineFragment = ({ children }: { children?: ReactNode }) => <>{children}; +const EMPTY_UNIFIED_PLUGINS: any[] = []; + +function hasRawHastNode(node: any): boolean { + if (!node || typeof node !== 'object') return false; + if (node.type === 'raw') return true; + const children = node.children; + if (Array.isArray(children)) { + for (let i = 0; i < children.length; i += 1) { + if (hasRawHastNode(children[i])) return true; + } + } + return false; +} + +function rehypeRawIfPresent() { + const runRehypeRaw = rehypeRaw(); + return (tree: any, file: any) => (hasRawHastNode(tree) ? runRehypeRaw(tree, file) : tree); +} + function markdownUrlTransform(value: string, key?: string): string { if (/^openbitfun:\/\/(?:runtime|current-session)\//.test(value)) return value; // These references are resolved through the owning host, never by the browser. @@ -175,16 +194,227 @@ function mayNeedWorkspacePathForMarkdownLinks(content: string): boolean { /]*href=["']\s*(?!https?:|mailto:|visualization:|tab:|#)[^"']+["']/i.test(content); } +function parseFenceLinePrefix( + source: string, + lineStart: number, + lineEnd: number, +): { char: '`' | '~'; length: number; trailingStart: number } | null { + let cursor = lineStart; + let indent = 0; + while (cursor < lineEnd && indent < 4) { + const ch = source.charCodeAt(cursor); + if (ch === 32) { + indent += 1; + cursor += 1; + } else if (ch === 9) { + indent += 4 - (indent % 4); + cursor += 1; + } else { + break; + } + } + if (indent >= 4 || cursor >= lineEnd) return null; + const first = source.charCodeAt(cursor); + if (first !== 96 && first !== 126) return null; + let runEnd = cursor + 1; + while (runEnd < lineEnd && source.charCodeAt(runEnd) === first) { + runEnd += 1; + } + const length = runEnd - cursor; + if (length < 3) return null; + return { + char: first === 96 ? '`' : '~', + length, + trailingStart: runEnd, + }; +} + +function findUnclosedMarkdownFence(source: string): string | null { + if (!source.includes('```') && !source.includes('~~~')) { + return null; + } + let openFence: { char: '`' | '~'; length: number } | null = null; + let lineStart = 0; + const totalLen = source.length; + while (lineStart <= totalLen) { + const nlIndex = source.indexOf('\n', lineStart); + const rawLineEnd = nlIndex === -1 ? totalLen : nlIndex; + const lineEnd = rawLineEnd > lineStart && source.charCodeAt(rawLineEnd - 1) === 13 + ? rawLineEnd - 1 + : rawLineEnd; + const fence = parseFenceLinePrefix(source, lineStart, lineEnd); + if (fence) { + if (!openFence) { + let validInfo = true; + if (fence.char === '`') { + for (let i = fence.trailingStart; i < lineEnd; i += 1) { + if (source.charCodeAt(i) === 96) { + validInfo = false; + break; + } + } + } + if (validInfo) { + openFence = { char: fence.char, length: fence.length }; + } + } else if (fence.char === openFence.char && fence.length >= openFence.length) { + let onlyWhitespace = true; + for (let i = fence.trailingStart; i < lineEnd; i += 1) { + const c = source.charCodeAt(i); + if (c !== 32 && c !== 9) { + onlyWhitespace = false; + break; + } + } + if (onlyWhitespace) { + openFence = null; + } + } + } + if (nlIndex === -1) break; + lineStart = nlIndex + 1; + } + return openFence ? openFence.char.repeat(openFence.length) : null; +} + +function stripMarkdownCodeForFeatureDetection(content: string): string { + if (!content.includes('`') && !content.includes('~')) { + return content; + } + let withoutFences = content; + if (content.includes('```') || content.includes('~~~')) { + let out = ''; + let openFence: { char: '`' | '~'; length: number } | null = null; + let lineStart = 0; + const totalLen = content.length; + while (lineStart <= totalLen) { + const nlIndex = content.indexOf('\n', lineStart); + const rawLineEnd = nlIndex === -1 ? totalLen : nlIndex; + const lineEnd = rawLineEnd > lineStart && content.charCodeAt(rawLineEnd - 1) === 13 + ? rawLineEnd - 1 + : rawLineEnd; + const fence = parseFenceLinePrefix(content, lineStart, lineEnd); + if (!openFence) { + let opened = false; + if (fence) { + let validInfo = true; + if (fence.char === '`') { + for (let i = fence.trailingStart; i < lineEnd; i += 1) { + if (content.charCodeAt(i) === 96) { + validInfo = false; + break; + } + } + } + if (validInfo) { + openFence = { char: fence.char, length: fence.length }; + opened = true; + } + } + if (!opened) { + out += content.slice(lineStart, rawLineEnd); + } + } else if (fence && fence.char === openFence.char && fence.length >= openFence.length) { + let onlyWhitespace = true; + for (let i = fence.trailingStart; i < lineEnd; i += 1) { + const c = content.charCodeAt(i); + if (c !== 32 && c !== 9) { + onlyWhitespace = false; + break; + } + } + if (onlyWhitespace) { + openFence = null; + } + } + if (nlIndex === -1) break; + out += '\n'; + lineStart = nlIndex + 1; + } + withoutFences = out; + } + + if (!withoutFences.includes('`')) { + return withoutFences; + } + + let out = ''; + let i = 0; + const len = withoutFences.length; + while (i < len) { + if (withoutFences.charCodeAt(i) !== 96) { + const nextTick = withoutFences.indexOf('`', i + 1); + if (nextTick < 0) { + out += withoutFences.slice(i); + break; + } + out += withoutFences.slice(i, nextTick); + i = nextTick; + continue; + } + let runEnd = i + 1; + while (runEnd < len && withoutFences.charCodeAt(runEnd) === 96) runEnd += 1; + const runLen = runEnd - i; + // CommonMark inline code spans cannot cross a blank-line paragraph break. + let paragraphEnd = len; + let nlSearch = runEnd; + while (nlSearch < len) { + const nl = withoutFences.indexOf('\n', nlSearch); + if (nl < 0) break; + let cursor = nl + 1; + while (cursor < len && (withoutFences.charCodeAt(cursor) === 32 || withoutFences.charCodeAt(cursor) === 9)) { + cursor += 1; + } + if (cursor < len && withoutFences.charCodeAt(cursor) === 13) cursor += 1; + if (cursor < len && withoutFences.charCodeAt(cursor) === 10) { + paragraphEnd = nl; + break; + } + nlSearch = nl + 1; + } + let search = runEnd; + let closedAt = -1; + while (search < paragraphEnd) { + const candidate = withoutFences.indexOf('`', search); + if (candidate < 0 || candidate >= paragraphEnd) break; + let candidateEnd = candidate + 1; + while (candidateEnd < paragraphEnd && withoutFences.charCodeAt(candidateEnd) === 96) candidateEnd += 1; + if (candidateEnd - candidate === runLen) { + closedAt = candidateEnd; + break; + } + search = candidateEnd; + } + if (closedAt >= 0) { + const hasNewline = withoutFences.indexOf('\n', runEnd) !== -1 && withoutFences.indexOf('\n', runEnd) < closedAt; + out += hasNewline ? '\n' : ' '; + i = closedAt; + } else { + out += withoutFences.slice(i, runEnd); + i = runEnd; + } + } + return out; +} + function mayContainMarkdownMath(content: string): boolean { if (!content) { return false; } + if (!content.includes('$') && !content.includes('\\(') && !content.includes('\\[')) { + return false; + } - if (content.includes('$$') || content.includes('\\(') || content.includes('\\[')) { + const prose = stripMarkdownCodeForFeatureDetection(content); + if (!prose.includes('$') && !prose.includes('\\(') && !prose.includes('\\[')) { + return false; + } + + if (prose.includes('$$') || prose.includes('\\(') || prose.includes('\\[')) { return true; } - return /(^|[^\\$])\$[^$\n]{1,240}\$(?!\d)/.test(content); + return /(^|[^\\$])\$[^$\n]{1,240}\$(?!\d)/.test(prose); } /** Catches render errors from react-markdown/remark-gfm (e.g. RegExp in transformGfmAutolinkLiterals) and shows plain text fallback. */ @@ -857,7 +1087,7 @@ const CodeBlockFallback: React.FC = ({ ); }; -const CopyButton: React.FC<{ code: string }> = ({ code }) => { +const CopyButton = React.memo<{ code: string }>(({ code }) => { const [copied, setCopied] = useState(false); const resetTimer = useRef | null>(null); const label = copied ? translateMarkdownLabel('markdown.copySuccess') : translateMarkdownLabel('markdown.copyCode'); @@ -892,7 +1122,7 @@ const CopyButton: React.FC<{ code: string }> = ({ code }) => { /> ); -}; +}); export interface MarkdownRendererProps { content: string; @@ -1007,11 +1237,10 @@ const MarkdownSurface = React.memo mayNeedWorkspacePathForMarkdownLinks(markdownContent), - [markdownContent], + () => ( + thinking || fileActionsViaCallbackOnly || Boolean(currentWorkspacePath || basePath) + ? false + : mayNeedWorkspacePathForMarkdownLinks(markdownContent) + ), + [basePath, currentWorkspacePath, fileActionsViaCallbackOnly, markdownContent, thinking], ); useEffect(() => { @@ -1046,14 +1279,17 @@ const MarkdownSurface = React.memo ({ - contentLength: markdownContent.length, - hasCodeBlock: /^[ \t]{0,3}(`{3,}|~{3,})/m.test(markdownContent), - hasTable: /^[ \t]*\|.+\|[ \t]*$/m.test(markdownContent), - }), [markdownContent]); + const markdownFeatureProfile = useMemo(() => (renderTraceEnabled + ? { + contentLength: markdownContent.length, + hasCodeBlock: /^[ \t]{0,3}(`{3,}|~{3,})/m.test(markdownContent), + hasTable: /^[ \t]*\|.+\|[ \t]*$/m.test(markdownContent), + } + : { contentLength: markdownContent.length, hasCodeBlock: false, hasTable: false }), + [markdownContent, renderTraceEnabled]); const shouldUseMathRenderer = useMemo( - () => mayContainMarkdownMath(markdownContent), - [markdownContent], + () => (thinking ? false : mayContainMarkdownMath(markdownContent)), + [markdownContent, thinking], ); // Parse line ranges like #L42 / 1-20 @@ -1370,7 +1606,7 @@ const MarkdownSurface = React.memo 12_000 && code.length <= 24_000; return (
@@ -1765,7 +2001,7 @@ const MarkdownSurface = React.memo{fragment} @@ -1784,10 +2020,30 @@ const MarkdownSurface = React.memo ) : basicFragment; }, [components, isStreaming]); + const basicRemarkPlugins = useMemo( + () => (thinking ? EMPTY_UNIFIED_PLUGINS : [ + [remarkGfm, markdownGfmOptions], + ...(!isStreaming ? [remarkCachedParse] : []), + [remarkStreamingTableLinks, { isStreaming }], + remarkAutolinkBoundaries, + remarkAutolinkInternalLinks, + ] as any), + [isStreaming, thinking], + ); + const basicRehypePlugins = useMemo( + () => (thinking ? EMPTY_UNIFIED_PLUGINS : [ + rehypeRawIfPresent, + [rehypeWindowsDrivePaths, { markdownSource: markdownContent }], + [rehypeSanitize, sanitizeSchema], + [rehypeSourceRange, sourceRange], + [rehypeImageThumbnails, { enabled: imageThumbnails }], + ] as any), + [markdownContent, sourceRange, imageThumbnails, thinking], + ); const basicMarkdownRenderer = ( diff --git a/src/web-ui/src/infrastructure/markdown/markdownSections.test.ts b/src/web-ui/src/infrastructure/markdown/markdownSections.test.ts index bc17b48f1d..57386f55bd 100644 --- a/src/web-ui/src/infrastructure/markdown/markdownSections.test.ts +++ b/src/web-ui/src/infrastructure/markdown/markdownSections.test.ts @@ -61,4 +61,15 @@ describe('Markdown presentation sections', () => { const source = prefix + dependent + '\n\n' + prefix; expect(markdownSections(source)).toEqual([{ start: 0, end: source.length }]); }); + + it('keeps multi-section splitting when prose contains < comparisons, inline code generics/HTML, or autolinks', () => { + const technical = [ + 'Check `Vec`, `
`, `matrix[i][j]`, and `/[^a-z]/` when x < 10 and y < z.', + 'See or for details.', + ].join('\n\n'); + const source = `${prefix}${technical}\n\n${prefix}`; + const sections = markdownSections(source, { streaming: true }); + expect(sections.length).toBeGreaterThan(4); + expect(sections.map(sec => source.slice(sec.start, sec.end)).join('')).toBe(source); + }); }); diff --git a/src/web-ui/src/infrastructure/markdown/markdownSections.ts b/src/web-ui/src/infrastructure/markdown/markdownSections.ts index bccdfc58ae..3e7dc1afdd 100644 --- a/src/web-ui/src/infrastructure/markdown/markdownSections.ts +++ b/src/web-ui/src/infrastructure/markdown/markdownSections.ts @@ -24,6 +24,71 @@ interface SettledPrefixRecord { let lastRecord: SettledPrefixRecord | undefined; +const INLINE_HTML_OR_FOOTNOTE_RE = /<(?:!--|\/?[a-zA-Z][a-zA-Z0-9:-]*(?:\s|\/|>|$))|\[\^[^\s\]]+\]/; + +function stripInlineCodeSpans(text: string): string { + if (!text.includes('`')) return text; + let out = ''; + let i = 0; + const len = text.length; + while (i < len) { + const ch = text.charCodeAt(i); + if (ch !== 96 /* ` */) { + const nextTick = text.indexOf('`', i + 1); + if (nextTick < 0) { + out += text.slice(i); + break; + } + out += text.slice(i, nextTick); + i = nextTick; + continue; + } + let runEnd = i + 1; + while (runEnd < len && text.charCodeAt(runEnd) === 96) runEnd += 1; + const runLen = runEnd - i; + let search = runEnd; + let closedAt = -1; + while (search < len) { + const candidate = text.indexOf('`', search); + if (candidate < 0) break; + let candidateEnd = candidate + 1; + while (candidateEnd < len && text.charCodeAt(candidateEnd) === 96) candidateEnd += 1; + if (candidateEnd - candidate === runLen) { + closedAt = candidateEnd; + break; + } + search = candidateEnd; + } + if (closedAt >= 0) { + out += ' '; + i = closedAt; + } else if (!text.includes('\n', runEnd)) { + // Mask an unclosed code span on the final streaming line so partial + // inline code like `` `
\s]+>/gi, ' ') + : withoutCode; + return INLINE_HTML_OR_FOOTNOTE_RE.test(withoutAutolinks); +} + function computeRawBoundaries(source: string): { boundaries: number[]; extendable: boolean } { const normalized = source.replace(/\r\n?|\0/g, match => match === '\0' ? '\uFFFD' : '\n'); const tokens: Parameters[3] = []; @@ -31,8 +96,8 @@ function computeRawBoundaries(source: string): { boundaries: number[]; extendabl parser.block.parse(normalized, parser, environment, tokens); if (environment.references || environment.footnotes || tokens.some(token => token.type.startsWith('html') || token.type.startsWith('footnote') - // Inline HTML/reference definitions may bind beyond a top-level block. - || (token.type === 'inline' && /<|\]\s*\[|\[\^/.test(token.content)))) { + // Inline HTML/footnote references outside code spans may bind beyond a top-level block. + || (token.type === 'inline' && hasCrossBlockInlineSyntax(token.content)))) { return { boundaries: [0], extendable: false }; } const offsets = [0]; diff --git a/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.ts b/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.ts index c31408e502..4110edc1db 100644 --- a/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.ts +++ b/src/web-ui/src/infrastructure/markdown/useStreamingTextReveal.ts @@ -12,7 +12,7 @@ type HighlightAPI = { Highlight?: new (...ranges: Range[]) => TextHighlight; }; interface Arrival { start: number; end: number; at: number } -interface TextRun { node: Text; start: number; end: number } +interface TextRun { node: Text; start: number; end: number; skipHighlight?: boolean } interface OwnedRange { registry: Map; name: string; @@ -20,6 +20,8 @@ interface OwnedRange { range: Range; } +const CODE_HIGHLIGHT_SKIP_CHARS = 512; + function pruneEmptyBuckets(entries: OwnedRange[]): void { for (const { registry, name, highlight } of entries) { if (highlight.size === 0 && registry.get(name) === highlight) registry.delete(name); @@ -27,16 +29,41 @@ function pruneEmptyBuckets(entries: OwnedRange[]): void { } function readRuns(root: HTMLElement): { runs: TextRun[]; text: string } { - const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT, { - acceptNode: node => node.parentElement?.closest('button, [aria-hidden="true"], .katex-mathml') - ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT, - }); + if (root.closest?.('button, [aria-hidden="true"], .katex-mathml')) { + return { runs: [], text: '' }; + } + const walker = root.ownerDocument.createTreeWalker( + root, + NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, + { + acceptNode: node => { + if (node.nodeType === 1 /* ELEMENT_NODE */) { + const el = node as Element; + if ( + el.tagName === 'BUTTON' || + el.getAttribute('aria-hidden') === 'true' || + el.classList.contains('katex-mathml') + ) { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_SKIP; + } + return NodeFilter.FILTER_ACCEPT; + }, + }, + ); const runs: TextRun[] = []; let text = ''; for (let node = walker.nextNode(); node; node = walker.nextNode()) { const start = text.length; - text += node.textContent ?? ''; - runs.push({ node: node as Text, start, end: text.length }); + const nodeText = node.textContent ?? ''; + text += nodeText; + const parent = node.parentElement; + const skipHighlight = + nodeText.length > CODE_HIGHLIGHT_SKIP_CHARS && + parent?.tagName === 'CODE' && + parent.parentElement?.tagName === 'PRE'; + runs.push({ node: node as Text, start, end: text.length, skipHighlight }); } return { runs, text }; } @@ -143,6 +170,7 @@ export function useStreamingTextReveal( for (let index = low; index < current.runs.length; index++) { const run = current.runs[index]; if (run.start >= arrival.end) break; + if (run.skipHighlight) continue; const start = Math.max(arrival.start, run.start); const end = Math.min(arrival.end, run.end); if (start >= end) continue; diff --git a/src/web-ui/src/infrastructure/services/business/workspaceManager.ts b/src/web-ui/src/infrastructure/services/business/workspaceManager.ts index 834e0414fb..d5df06ee3b 100644 --- a/src/web-ui/src/infrastructure/services/business/workspaceManager.ts +++ b/src/web-ui/src/infrastructure/services/business/workspaceManager.ts @@ -1449,8 +1449,9 @@ class WorkspaceManager { return currentWorkspace; } - this.setLoading(true); - this.setError(null); + if (this.state.error !== null) { + this.setError(null); + } const isClosedAssistant = !this.state.openedWorkspaces.has(workspaceId) && this.state.assistantWorkspaces?.some(workspace => workspace.id === workspaceId); @@ -1466,11 +1467,15 @@ class WorkspaceManager { // user waits on (opening a session in another workspace waits on this). const knownOpened = this.getOpenedWorkspacesList(); const opened = knownOpened.some(candidate => candidate.id === workspace.id) - ? knownOpened : [workspace, ...knownOpened]; + ? knownOpened.map(candidate => (candidate.id === workspace.id ? workspace : candidate)) + : [workspace, ...knownOpened]; + const optimisticRecent = workspace.workspaceKind === WorkspaceKind.Assistant + ? this.state.recentWorkspaces + : [workspace, ...this.state.recentWorkspaces.filter(candidate => candidate.id !== workspace.id)]; const switchSequence = ++this.activeWorkspaceSwitchSequence; this.updateWorkspaceState( workspace, - this.state.recentWorkspaces, + optimisticRecent, this.preserveOpenedWorkspaceOrder(opened), false, null, diff --git a/src/web-ui/src/shared/utils/resourceBudget.ts b/src/web-ui/src/shared/utils/resourceBudget.ts index 77d508e0d6..e6d675b593 100644 --- a/src/web-ui/src/shared/utils/resourceBudget.ts +++ b/src/web-ui/src/shared/utils/resourceBudget.ts @@ -88,15 +88,114 @@ if (import.meta.env.DEV || globalThis.__OPENBITFUN_PERF_TRACE_ENABLED__ === true globalThis.__OPENBITFUN_RESOURCE_BUDGET__ = { snapshot: () => resourceBudget.snapshot() }; } +interface CachedSettledRoundBytes { + bytes: number; + objects: object[]; +} + +const settledRoundByteCache = new WeakMap(); + +function isSettledModelRound(value: object): boolean { + const candidate = value as { + status?: unknown; + isComplete?: unknown; + isStreaming?: unknown; + items?: unknown; + }; + return ( + candidate.status === 'completed' && + candidate.isComplete === true && + candidate.isStreaming === false && + Array.isArray(candidate.items) + ); +} + +function indexKeyLength(index: number): number { + if (index < 10) return 1; + if (index < 100) return 2; + if (index < 1000) return 3; + if (index < 10000) return 4; + return String(index).length; +} + +function measureSettledModelRound(round: object, outerSeen: Set): number { + const cached = settledRoundByteCache.get(round); + if (cached) { + const { objects } = cached; + let hasOverlap = false; + for (let i = 0; i < objects.length; i += 1) { + if (outerSeen.has(objects[i])) { + hasOverlap = true; + break; + } + } + if (!hasOverlap) { + for (let i = 0; i < objects.length; i += 1) { + outerSeen.add(objects[i]); + } + return cached.bytes; + } + } + + const localSeen = new Set(); + let hadOuterOverlap = false; + + const walk = (node: unknown): number => { + if (typeof node === 'string') return node.length * 2; + if (!node || typeof node !== 'object') return 8; + if (localSeen.has(node)) return 0; + if (outerSeen.has(node)) { + hadOuterOverlap = true; + return 0; + } + localSeen.add(node); + outerSeen.add(node); + + let bytes = 32; + if (Array.isArray(node)) { + for (let i = 0; i < node.length; i += 1) { + if (!(i in node)) continue; + bytes += indexKeyLength(i) * 2 + 8 + walk(node[i]); + } + return bytes; + } + for (const key in node) { + if (!Object.prototype.hasOwnProperty.call(node, key)) continue; + bytes += key.length * 2 + 8 + walk((node as Record)[key]); + } + return bytes; + }; + + const bytes = walk(round); + if (!cached && !hadOuterOverlap) { + settledRoundByteCache.set(round, { + bytes, + objects: Array.from(localSeen), + }); + } + return bytes; +} + /** No stringify-sized temporary copy. Shared references are counted once per working set. */ export function estimateRetainedBytes(value: unknown, seen = new Set()): number { if (typeof value === 'string') return value.length * 2; if (!value || typeof value !== 'object') return 8; if (seen.has(value)) return 0; + if (!Array.isArray(value) && isSettledModelRound(value)) { + return measureSettledModelRound(value, seen); + } seen.add(value); let bytes = 32; - for (const [key, child] of Object.entries(value)) { - bytes += key.length * 2 + 8 + estimateRetainedBytes(child, seen); + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i += 1) { + if (!(i in value)) continue; + bytes += indexKeyLength(i) * 2 + 8 + estimateRetainedBytes(value[i], seen); + } + return bytes; + } + for (const key in value) { + if (!Object.prototype.hasOwnProperty.call(value, key)) continue; + bytes += key.length * 2 + 8 + estimateRetainedBytes((value as Record)[key], seen); } return bytes; } diff --git a/src/web-ui/src/tools/git/services/WorkspaceGitInitializer.test.ts b/src/web-ui/src/tools/git/services/WorkspaceGitInitializer.test.ts index 89c95a5f1a..e311992b9c 100644 --- a/src/web-ui/src/tools/git/services/WorkspaceGitInitializer.test.ts +++ b/src/web-ui/src/tools/git/services/WorkspaceGitInitializer.test.ts @@ -26,6 +26,8 @@ import { workspaceGitInitializer } from './WorkspaceGitInitializer'; describe('WorkspaceGitInitializer startup refresh', () => { it('refreshes only the basic Git layer for the current workspace on startup', async () => { + workspaceGitInitializer.stop(); + vi.clearAllMocks(); workspaceGitInitializer.start(); await Promise.resolve(); @@ -36,4 +38,41 @@ describe('WorkspaceGitInitializer startup refresh', () => { source: 'workspace_git_initializer', }); }); + + it('keeps outgoing workspace Git cache warm on switch and invalidates only on close', async () => { + workspaceGitInitializer.stop(); + vi.clearAllMocks(); + let workspaceEventListener: ((event: any) => Promise | void) | undefined; + workspaceManagerMock.addEventListener.mockImplementationOnce((listener: any) => { + workspaceEventListener = listener; + return vi.fn(); + }); + + workspaceGitInitializer.start(); + await Promise.resolve(); + gitStateManagerMock.refresh.mockClear(); + + await workspaceEventListener?.({ + type: 'workspace:switched', + workspace: { id: 'workspace-2', rootPath: 'D:/workspace/Other' }, + }); + + expect(gitStateManagerMock.invalidateCache).not.toHaveBeenCalled(); + expect(gitStateManagerMock.refresh).toHaveBeenCalledWith({ workspaceId: 'workspace-2' }, { + layers: ['basic'], + reason: 'mount', + force: false, + source: 'workspace_git_initializer', + }); + + await workspaceEventListener?.({ + type: 'workspace:closed', + workspaceId: 'workspace-1', + }); + + expect(gitStateManagerMock.invalidateCache).toHaveBeenCalledWith( + { workspaceId: 'workspace-1' }, + ['basic', 'status', 'detailed'], + ); + }); }); diff --git a/src/web-ui/src/tools/git/services/WorkspaceGitInitializer.ts b/src/web-ui/src/tools/git/services/WorkspaceGitInitializer.ts index 5a904638d7..91cb582bc3 100644 --- a/src/web-ui/src/tools/git/services/WorkspaceGitInitializer.ts +++ b/src/web-ui/src/tools/git/services/WorkspaceGitInitializer.ts @@ -77,10 +77,10 @@ class WorkspaceGitInitializer { private async handleWorkspaceClosed(workspaceId: string): Promise { try { - if (this.currentWorkspaceId) { - gitStateManager.invalidateCache({ workspaceId: this.currentWorkspaceId }, ['basic', 'status', 'detailed']); + gitStateManager.invalidateCache({ workspaceId }, ['basic', 'status', 'detailed']); + if (this.currentWorkspaceId === workspaceId) { + this.currentWorkspaceId = null; } - this.currentWorkspaceId = null; } catch (error) { log.error('Failed to cleanup Git state', { workspaceId, error }); } @@ -88,14 +88,11 @@ class WorkspaceGitInitializer { private async handleWorkspaceSwitched(workspaceId: string): Promise { try { - if (this.currentWorkspaceId && this.currentWorkspaceId !== workspaceId) { - gitStateManager.invalidateCache({ workspaceId: this.currentWorkspaceId }, ['basic', 'status', 'detailed']); - } this.currentWorkspaceId = workspaceId; await gitStateManager.refresh({ workspaceId }, { layers: ['basic'], reason: 'mount', - force: true, + force: false, source: 'workspace_git_initializer', }); } catch (error) { diff --git a/src/web-ui/src/tools/snapshot_system/core/SnapshotStateManager.ts b/src/web-ui/src/tools/snapshot_system/core/SnapshotStateManager.ts index 196ac373f3..eaa3b14fe6 100644 --- a/src/web-ui/src/tools/snapshot_system/core/SnapshotStateManager.ts +++ b/src/web-ui/src/tools/snapshot_system/core/SnapshotStateManager.ts @@ -51,6 +51,7 @@ export class SnapshotStateManager { private eventBus: SnapshotEventBus; private snapshotService: SnapshotSystemService; private readonly surfaces = new Map(); + private readonly inFlightSessionRefreshes = new Map>(); private get currentSurface(): SurfaceSnapshotState { const surfaceId = getActiveSurfaceId(); @@ -168,52 +169,67 @@ export class SnapshotStateManager { shouldRefreshSnapshotForSession(flowChatStore.getState().sessions.get(sessionId), sessionId); if (!canRefresh()) return; - try { - const stats = await this.snapshotService.getSessionStats(sessionId); - if (!canRefresh()) return; - - const sessionState: SessionState = { - sessionId, - files: new Map(), - totalBlocks: 0, - acceptedBlocks: 0, - rejectedBlocks: 0, - pendingBlocks: 0, - sessionStatus: stats.total_changes > 0 ? 'pending' : 'working', - lastActivity: Date.now() - }; + const refreshKey = scope.key(scope.epoch, sessionId); + const existing = this.inFlightSessionRefreshes.get(refreshKey); + if (existing) { + return existing; + } - // NOTE: `getSessionStats` does not include file lists. Fetch files separately when available. + const promise = (async () => { try { - const { snapshotAPI } = await import('@/infrastructure/api'); + const stats = await this.snapshotService.getSessionStats(sessionId); if (!canRefresh()) return; - const files = await snapshotAPI.getSessionFiles(sessionId); - if (!canRefresh()) return; - - for (const filePath of files) { - const snapshotFile: SnapshotFile = { - filePath, - sessionId, - originalContent: '', // loaded on demand - modifiedContent: '', // loaded on demand - fileStatus: 'pending', - lastModified: Date.now() - }; - - sessionState.files.set(filePath, snapshotFile); - this.files.set(filePath, snapshotFile); + + const sessionState: SessionState = { + sessionId, + files: new Map(), + totalBlocks: 0, + acceptedBlocks: 0, + rejectedBlocks: 0, + pendingBlocks: 0, + sessionStatus: stats.total_changes > 0 ? 'pending' : 'working', + lastActivity: Date.now() + }; + + // NOTE: `getSessionStats` does not include file lists. Fetch files separately when available. + try { + const { snapshotAPI } = await import('@/infrastructure/api'); + if (!canRefresh()) return; + const files = await snapshotAPI.getSessionFiles(sessionId); + if (!canRefresh()) return; + + for (const filePath of files) { + const snapshotFile: SnapshotFile = { + filePath, + sessionId, + originalContent: '', // loaded on demand + modifiedContent: '', // loaded on demand + fileStatus: 'pending', + lastModified: Date.now() + }; + + sessionState.files.set(filePath, snapshotFile); + this.files.set(filePath, snapshotFile); + } + } catch (fileListError) { + if (!canRefresh()) return; + log.warn('Failed to get file list, using basic stats', { sessionId, error: fileListError }); } - } catch (fileListError) { + + this.sessions.set(sessionId, sessionState); + this.eventBus.emit(SNAPSHOT_EVENTS.SESSION_STATE_CHANGED, sessionState, sessionId); + } catch (error) { if (!canRefresh()) return; - log.warn('Failed to get file list, using basic stats', { sessionId, error: fileListError }); + log.error('Failed to refresh session state', { sessionId, error }); } + })().finally(() => { + if (this.inFlightSessionRefreshes.get(refreshKey) === promise) { + this.inFlightSessionRefreshes.delete(refreshKey); + } + }); - this.sessions.set(sessionId, sessionState); - this.eventBus.emit(SNAPSHOT_EVENTS.SESSION_STATE_CHANGED, sessionState, sessionId); - } catch (error) { - if (!canRefresh()) return; - log.error('Failed to refresh session state', { sessionId, error }); - } + this.inFlightSessionRefreshes.set(refreshKey, promise); + return promise; } async refreshFileState(sessionId: string, filePath: string): Promise { diff --git a/src/web-ui/src/tools/snapshot_system/hooks/useSnapshotState.ts b/src/web-ui/src/tools/snapshot_system/hooks/useSnapshotState.ts index aa5cb571cd..badb5094e0 100644 --- a/src/web-ui/src/tools/snapshot_system/hooks/useSnapshotState.ts +++ b/src/web-ui/src/tools/snapshot_system/hooks/useSnapshotState.ts @@ -12,6 +12,12 @@ import { getActiveSurfaceScope, onSurfaceActivated } from '@/infrastructure/peer const log = createLogger('useSnapshotState'); const subscribeToSurfaceActivation = (listener: () => void): (() => void) => onSurfaceActivated(() => listener()); +const subscribeToFlowChatStore = (callback: () => void): (() => void) => + flowChatStore.subscribe(() => callback()); + +interface UseSnapshotStateOptions { + trackFiles?: boolean; +} interface UseSnapshotStateReturn { surfaceEpoch: number; @@ -35,7 +41,11 @@ interface UseSnapshotStateReturn { clearError: () => void; } -export const useSnapshotState = (sessionId?: string): UseSnapshotStateReturn => { +export const useSnapshotState = ( + sessionId?: string, + options?: UseSnapshotStateOptions, +): UseSnapshotStateReturn => { + const trackFiles = options?.trackFiles !== false; const { t } = useTranslation('flow-chat'); const surfaceScope = useSyncExternalStore( subscribeToSurfaceActivation, getActiveSurfaceScope, getActiveSurfaceScope, @@ -43,7 +53,7 @@ export const useSnapshotState = (sessionId?: string): UseSnapshotStateReturn => const identity = surfaceScope.key(surfaceScope.epoch, sessionId); const [boundIdentity, setBoundIdentity] = useState(identity); const snapshotsAvailable = useSyncExternalStore( - (callback) => flowChatStore.subscribe(() => callback()), + subscribeToFlowChatStore, () => hasSessionFileSnapshots(sessionId ? flowChatStore.getState().sessions.get(sessionId) : undefined, sessionId), () => false, ); @@ -253,7 +263,7 @@ export const useSnapshotState = (sessionId?: string): UseSnapshotStateReturn => setBoundIdentity(identity); setLoading(false); setError(null); - if (!sessionId) { + if (!sessionId || !trackFiles) { setFiles([]); setSessionState(null); activeSessionIdRef.current = undefined; @@ -326,7 +336,7 @@ export const useSnapshotState = (sessionId?: string): UseSnapshotStateReturn => unsubscribeFile(); unsubscribeFlowChat(); }; - }, [identity, sessionId, stateManager, refreshSession, surfaceScope]); + }, [identity, sessionId, stateManager, refreshSession, surfaceScope, trackFiles]); // An activation may render the same Session id before effects rebind. Never // paint the previous device's cached files during that render.