diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index 14c60c4406..4d2eee30ef 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -821,10 +821,9 @@ 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, reset_menubar); + let _ = app; let step_started = Instant::now(); let previous_workspace_id = state.workspace_id.write().await.take(); @@ -881,7 +880,7 @@ async fn clear_active_workspace_context( } #[cfg(target_os = "macos")] - if reset_menubar { + { let step_started = Instant::now(); let language = state .config_service @@ -915,7 +914,7 @@ async fn apply_active_workspace_context( let _ = app; let step_started = Instant::now(); - clear_active_workspace_context(state, app, startup_trace, false).await; + clear_active_workspace_context(state, app, startup_trace).await; if let Some(trace) = startup_trace { trace.record_elapsed_step( "tauri_command", @@ -1025,7 +1024,7 @@ async fn initialize_global_state_impl( ); } else { let step_started = Instant::now(); - clear_active_workspace_context(state, app, Some(trace), true).await; + clear_active_workspace_context(state, app, Some(trace)).await; trace.record_elapsed_step( "tauri_command", "initialize_global_state.clear_active_workspace_context", @@ -1757,7 +1756,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, true).await; + clear_active_workspace_context(&state, &app, None).await; } if let Err(e) = state @@ -1968,7 +1967,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, true).await; + clear_active_workspace_context(&state, &app, None).await; } info!("Workspace closed: workspace_id={}", request.workspace_id); @@ -2298,7 +2297,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, true).await; + clear_active_workspace_context(state, app, None).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 5a0db90854..a280cb8c27 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, ToolEventData, ToolEventIdentity}; +use openbitfun_events::AgenticEvent; use std::collections::HashMap; use std::time::Duration; @@ -118,37 +118,15 @@ 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, 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, - }, -} +/// Stable merge key for one streaming text/thinking stream. +type ChunkStreamKey = ( + String, + String, + String, + String, + bool, + Option, +); fn resolve_attempt_token(attempt_id: &Option, attempt_index: Option) -> String { if let Some(id) = attempt_id { @@ -181,25 +159,6 @@ 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 { @@ -239,55 +198,18 @@ 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/tool-partial chunks within a short time window. +/// Coalesces streamed text/thinking 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, @@ -334,12 +256,14 @@ impl TextChunkCoalescer { attempt_index, text, } => { - 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), - }; + let key = ( + session_id.clone(), + turn_id.clone(), + round_id.clone(), + resolve_attempt_token(&attempt_id, attempt_index), + false, + None, + ); match self.pending.get_mut(&key) { Some(PendingChunk::Text { text: pending, .. }) => { pending.push_str(&text); @@ -375,13 +299,14 @@ impl TextChunkCoalescer { reasoning_kind, is_end, } => { - 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), + let key = ( + session_id.clone(), + turn_id.clone(), + round_id.clone(), + resolve_attempt_token(&attempt_id, attempt_index), + true, reasoning_kind, - }; + ); match self.pending.get_mut(&key) { Some(PendingChunk::Thinking { content: pending, @@ -414,109 +339,6 @@ 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); @@ -954,142 +776,4 @@ 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 eff4146d2e..fbe03d1f6d 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; +use openbitfun_transport::{TauriTransportAdapter, TransportAdapter}; use serde::Deserialize; use std::path::PathBuf; use std::sync::{ @@ -2446,93 +2446,83 @@ fn configure_workspace_search_daemon_env() -> Option { path } -/// 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( +/// 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( transport: &TauriTransportAdapter, - events: Vec, + event: AgenticEvent, session_event_journal: &SessionEventJournal, ) { - if events.is_empty() { + 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"); return; + }; + if let Some(cursor) = cursor { + attach_session_event_cursor(&mut projected.payload, cursor); } - 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 + 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) { - 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(), + openbitfun_core::service::remote_connect::synchronize_session_record_turn( + &hub, session_id, turn, ) .await - { - log::error!("Unable to publish session control event: {error}"); + } else if name == "agentic://session-history-changed" { + openbitfun_core::service::remote_connect::synchronize_session_records( + &hub, session_id, + ) + .await + } else { + Ok(()) } } + .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_batch(out_batch) { - log::error!("Failed to emit event batch: {:?}", e); + if let Err(e) = transport + .emit_generic(&projected.event_name, projected.payload.clone()) + .await + { + log::error!("Failed to emit event: {:?}", e); } // Session events reach controllers through host streams they read on @@ -2562,25 +2552,24 @@ 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_batch: &mut D, + deliver: &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(Vec) -> F, + D: FnMut(AgenticEvent) -> F, F: std::future::Future, { let flushed_chars = coalescer.buffered_chars(); update_rate_after_flush(rate_ema, flushed_chars, arm_time, last_flush_time); - let flushed = coalescer.flush(); - if !flushed.is_empty() { - deliver_batch(flushed).await; + for event in coalescer.flush() { + deliver(event).await; } } /// Drive the agentic event queue: route raw events to internal subscribers, -/// coalesce streamed chunks, and deliver ordered event batches through `deliver_batch`. +/// coalesce streamed text chunks, and deliver merged events through `deliver`. /// /// Scheduling contract: /// - The coalescing window is armed as soon as the first chunk is buffered @@ -2591,12 +2580,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_batch_driver( +async fn event_loop_driver( event_queue: Arc, event_router: Arc, - mut deliver_batch: D, + mut deliver: D, ) where - D: FnMut(Vec) -> F, + D: FnMut(AgenticEvent) -> F, F: std::future::Future, { use crate::api::event_coalescer::{ @@ -2635,8 +2624,6 @@ async fn event_loop_batch_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. @@ -2655,7 +2642,9 @@ async fn event_loop_batch_driver( let pushed = coalescer.push(envelope.event); let did_flush = !pushed.is_empty(); - ready_events.extend(pushed); + for event in pushed { + deliver(event).await; + } if did_flush && pre_flush_chars > 0 { if let Some(arm) = arm_time { @@ -2702,18 +2691,14 @@ async fn event_loop_batch_driver( .expect("arm_time must be set when a deadline is armed"); flush_deadline = None; flush_arm_time = None; - let flushed_chars = coalescer.buffered_chars(); - update_rate_after_flush( + flush_coalesced( + &mut deliver, + &mut coalescer, &mut rate_ema, - flushed_chars, arm_time, &mut last_flush_time, - ); - ready_events.extend(coalescer.flush()); - } - - if !ready_events.is_empty() { - deliver_batch(ready_events).await; + ) + .await; } } } @@ -2723,7 +2708,7 @@ async fn event_loop_batch_driver( flush_deadline = None; flush_arm_time = None; flush_coalesced( - &mut deliver_batch, + &mut deliver, &mut coalescer, &mut rate_ema, arm_time, @@ -2735,31 +2720,6 @@ async fn event_loop_batch_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, @@ -2767,11 +2727,11 @@ fn start_event_loop_with_transport( session_event_journal: Arc, ) { tokio::spawn(async move { - event_loop_batch_driver(event_queue, event_router, |events| { + event_loop_driver(event_queue, event_router, |event| { let transport = transport.clone(); let session_event_journal = session_event_journal.clone(); async move { - deliver_events_to_webview(&transport, events, &session_event_journal).await; + deliver_event_to_webview(&transport, event, &session_event_journal).await; } }) .await; @@ -3171,69 +3131,6 @@ 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 be4c17103c..710ea84d1e 100644 --- a/src/apps/desktop/src/macos_menubar.rs +++ b/src/apps/desktop/src/macos_menubar.rs @@ -99,10 +99,6 @@ 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, @@ -110,16 +106,8 @@ pub fn set_macos_menubar_with_mode( mode: MenubarMode, edit_mode: EditMenuMode, ) -> tauri::Result<()> { - 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 _ = mode; let app_menu = SubmenuBuilder::new(app, "OpenBitFun") .text("openbitfun.about", labels.about_openbitfun) @@ -181,9 +169,6 @@ 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 8d93d0b6da..583c5bcc1f 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) { } } }); - const FORWARDED_AGENTIC_EVENTS: [&str; 10] = [ + for event_name in [ "dialog-turn-started", "model-round-started", "model-round-completed", @@ -252,65 +252,38 @@ 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(payload) = serde_json::from_str::(event.payload()) else { + let Ok(mut payload) = serde_json::from_str::(event.payload()) else { return; }; - 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; + let Some(session) = payload["sessionId"].as_str().map(str::to_owned) else { + return; }; - if FORWARDED_AGENTIC_EVENTS.contains(&short_name) { - forward_miniapp_agentic_event(&batch_handle, short_name, entry.payload); + 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()); + } } - } - }); + }); + } } 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 61e06e1972..c570b4f180 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::{TauriBatchedEvent, TauriTransportAdapter, TAURI_EVENT_BATCH_NAME}; +pub use tauri::TauriTransportAdapter; diff --git a/src/crates/adapters/transport/src/adapters/tauri.rs b/src/crates/adapters/transport/src/adapters/tauri.rs index b7bb0928ac..ffdce460e9 100644 --- a/src/crates/adapters/transport/src/adapters/tauri.rs +++ b/src/crates/adapters/transport/src/adapters/tauri.rs @@ -8,95 +8,21 @@ 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")] @@ -130,42 +56,3 @@ 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 b6328dc83c..c9abff0db1 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::{TauriBatchedEvent, TauriTransportAdapter, TAURI_EVENT_BATCH_NAME}; +pub use adapters::TauriTransportAdapter; diff --git a/src/crates/assembly/core/src/service/workspace/service.rs b/src/crates/assembly/core/src/service/workspace/service.rs index 70c85d539f..e4d2243459 100644 --- a/src/crates/assembly/core/src/service/workspace/service.rs +++ b/src/crates/assembly/core/src/service/workspace/service.rs @@ -255,15 +255,6 @@ 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 @@ -287,7 +278,7 @@ impl WorkspaceService { workspace.remote_ssh_connection_id(), &workspace.root_path.to_string_lossy(), ); - runtime_service + self.runtime_service .ensure_remote_workspace_runtime( &identity_host, &workspace.root_path.to_string_lossy(), @@ -299,7 +290,7 @@ impl WorkspaceService { return; } - runtime_service + self.runtime_service .ensure_local_workspace_runtime(&workspace.root_path) .await } @@ -1076,15 +1067,8 @@ impl WorkspaceService { if result.is_ok() { if let Some(workspace) = self.get_workspace(workspace_id).await { - let runtime_service = self.runtime_service.clone(); - tokio::spawn(async move { - Self::ensure_workspace_runtime_for_workspace( - &runtime_service, - &workspace, - "activated", - ) + self.ensure_workspace_runtime_best_effort(&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 2983c3989b..81622afc10 100644 --- a/src/web-ui/src/app/components/NavPanel/AskUserAnnouncer.tsx +++ b/src/web-ui/src/app/components/NavPanel/AskUserAnnouncer.tsx @@ -51,9 +51,6 @@ 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 a59d76e097..196857bb08 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,7 +39,6 @@ import { resolveSessionRelationship } from '@/flow_chat/utils/sessionMetadata'; import { isWorktreeIsolatedSession, sessionBelongsToWorkspaceNavRow, - sessionOwningWorkspaceId, } from '@/flow_chat/utils/sessionOrdering'; import { sessionWorktreeRootPath } from '@/flow_chat/utils/sessionWorktree'; import { @@ -461,65 +460,14 @@ 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 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 selector = createSessionsNavSelector(); 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(); @@ -563,6 +511,11 @@ 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 194a614dc9..0505797a2c 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,22 +42,4 @@ 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 04c6c4667f..e0c0e27cf3 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,18 +1,9 @@ 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']; @@ -33,16 +24,12 @@ interface SessionNavSignature { dispatchJobState: Session['config']['dispatchJobState']; } -function readSessionNavSignature(session: Session, isSelected: boolean): SessionNavSignature { +function readSessionNavSignature(session: Session): 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, @@ -70,8 +57,6 @@ 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 @@ -99,48 +84,28 @@ 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( - options?: SessionsNavSelectorOptions, -): (state: FlowChatState) => number { - const includeSession = options?.includeSession; +export function createSessionsNavSelector(): (state: FlowChatState) => number { + let activeSessionId: string | null | undefined; let orderedSessionIds: string[] = []; const signatures = new Map(); let revision = 0; return (state: FlowChatState): number => { - let changed = false; - let orderChanged = false; + let changed = activeSessionId !== state.activeSessionId + || orderedSessionIds.length !== state.sessions.size; + let orderChanged = orderedSessionIds.length !== state.sessions.size; 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 && previous.sessionRef === session && previous.isSelected === isSelected) { - index += 1; - continue; - } - - if (!previous || previous.isSelected !== isSelected || !sameSessionNavSignature(previous, session)) { - signatures.set(session.sessionId, readSessionNavSignature(session, isSelected)); + if (!previous || !sameSessionNavSignature(previous, session)) { + signatures.set(session.sessionId, readSessionNavSignature(session)); changed = true; - } else { - previous.sessionRef = session; } index += 1; } @@ -148,25 +113,16 @@ export function createSessionsNavSelector( if (index !== orderedSessionIds.length) { changed = true; orderChanged = true; - if (!nextOrderedIds) { - nextOrderedIds = orderedSessionIds.slice(0, index); - } } - 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); - } + 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); } } } 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 35ed0fbf86..9f98a0e823 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,30 +1205,10 @@ 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 = shallowCloneCanvasSnapshot(snapshot); + const clone = structuredClone(snapshot); + clone.draggingTabId = null; + clone.draggingFromGroupId = null; agentScopeSnapshots.set(key, clone); const idx = agentSnapshotLruOrder.indexOf(key); if (idx >= 0) agentSnapshotLruOrder.splice(idx, 1); @@ -1256,7 +1236,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(shallowCloneCanvasSnapshot(snapshot)); + if (snapshot) target.setState(structuredClone(snapshot)); target.setState({ scopeKey: key }); } const state = target.getState(); @@ -1286,7 +1266,7 @@ export function updateAgentCanvasTabsForSession( if (target !== useAgentCanvasStore) { const snapshot = agentScopeSnapshots.get(key); if (!snapshot) return false; - target.setState(shallowCloneCanvasSnapshot(snapshot)); + target.setState(structuredClone(snapshot)); } const state = target.getState(); let updated = false; @@ -1348,8 +1328,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 shallow copy. - const next = shallowCloneCanvasSnapshot(snapshot); + // Restoring a scope can freeze shared groups through Immer; revise a copy. + const next = structuredClone(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)) { @@ -1403,7 +1383,7 @@ export function switchAgentCanvasScope(nextScopeId: string | null | undefined): if (from === to) return; const rawNext = agentScopeSnapshots.get(to); - const nextSnapshotClone = rawNext ? shallowCloneCanvasSnapshot(rawNext) : null; + const nextSnapshotClone = rawNext ? structuredClone(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 e3eb3efd85..c9f3f5105c 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -270,15 +270,11 @@ 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 () => { - const targetWorkspace = currentWorkspaceRef.current; - if (!targetWorkspace?.rootPath) return; + if (!currentWorkspace?.rootPath) return; const explicitOpen = getDeviceOpenIntent(); // Remote session index and turns live under ~/.openbitfun/remote_ssh/... (local disk). @@ -292,12 +288,12 @@ const AppLayout: React.FC = ({ className = '' }) => { } const initializationPreferredMode = - targetWorkspace.workspaceKind === WorkspaceKind.Assistant + currentWorkspace.workspaceKind === WorkspaceKind.Assistant ? 'Claw' : explicitPreferredMode; const flowChatManager = FlowChatManager.getInstance(); - const hasHistoricalSessions = await flowChatManager.initialize(targetWorkspace, initializationPreferredMode); + const hasHistoricalSessions = await flowChatManager.initialize(currentWorkspace, initializationPreferredMode); if (cancelled) { return; } @@ -313,11 +309,11 @@ const AppLayout: React.FC = ({ className = '' }) => { } if (!hasHistoricalSessions) { const initialSessionMode = - targetWorkspace.workspaceKind === WorkspaceKind.Assistant + currentWorkspace.workspaceKind === WorkspaceKind.Assistant ? 'Claw' : explicitPreferredMode; sessionId = await flowChatManager.createChatDraft( - flowChatSessionConfigForWorkspace(targetWorkspace), + flowChatSessionConfigForWorkspace(currentWorkspace), initialSessionMode, ); if (cancelled) { @@ -395,6 +391,7 @@ 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 86896a3782..c65fb8ae0f 100644 --- a/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts +++ b/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts @@ -852,10 +852,7 @@ 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 e1ab14944d..281b811b85 100644 --- a/src/web-ui/src/app/services/sessionSceneLifecycle.ts +++ b/src/web-ui/src/app/services/sessionSceneLifecycle.ts @@ -1,7 +1,4 @@ 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'; @@ -15,48 +12,10 @@ import { selectedSessionTab, tabPane } from '../workbench/scenePaneModel'; const log = createLogger('SessionSceneLifecycle'); -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, - }; -} +/** + * Tabs own one resource reference per workspace; FlowChat owns all sessions. + * The shell coordinates navigation with the one active-session presentation. + */ export function startSessionSceneLifecycle(): () => void { const stopProjectionSync = startAutoSync(); const stopPaneLayoutSync = startSessionPaneLayoutSync(); @@ -115,39 +74,6 @@ 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; @@ -203,29 +129,12 @@ export function startSessionSceneLifecycle(): () => void { } } useSceneStore.getState().reconcileSessionScenes(targets); - syncTrackedFlowChatSignature(flowChatStore.getState(), surfaceId); } finally { reconciling = false; } }; - 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 unsubscribeSessions = flowChatStore.subscribe(() => 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 ab7fc196b9..e0a0c6a59f 100644 --- a/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx +++ b/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx @@ -17,30 +17,11 @@ 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; @@ -115,23 +96,9 @@ 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 @@ -195,16 +162,7 @@ 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 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 markdownTraceContext = isStartupRenderTraceEnabled() ? traceContext : undefined; const parsedContent = useMemo( () => parseDeepResearchContent(displayContent), [displayContent], @@ -214,40 +172,6 @@ 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 && - shallowEqualRecord( - prevProps.traceContext as Record | undefined, - nextProps.traceContext as Record | undefined, - ) && + prevProps.traceContext === nextProps.traceContext && prevProps.testId === nextProps.testId && prevProps.hidden === nextProps.hidden && prevProps.markdownSection?.start === nextProps.markdownSection?.start && prevProps.markdownSection?.end === nextProps.markdownSection?.end && - shallowEqualRecord( - prevProps.testAttributes as Record | undefined, - nextProps.testAttributes as Record | undefined, - ) + prevProps.testAttributes === nextProps.testAttributes ); }); 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 f7bae57d0c..4d3b6b2a97 100644 --- a/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.tsx +++ b/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.tsx @@ -218,27 +218,6 @@ 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 25aa224bd7..c7b0dba7da 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 { useEffect, useMemo, useSyncExternalStore } from 'react'; +import { useMemo, useSyncExternalStore } from 'react'; import { Button, ScrollArea } from '@openbitfun/ui'; import { useContainedTailFollow } from '@openbitfun/flow-chat-presentation/scroll'; import { useI18n } from '@/infrastructure/i18n'; @@ -31,12 +31,6 @@ 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 09b36cfcc6..702a745644 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, { trackFiles: false }); + const { surfaceEpoch, snapshotsAvailable } = useSnapshotState(sessionId); 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 15c6622fb7..67d857fd1e 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -1115,11 +1115,8 @@ export const ModernFlowChatContainer: React.FC = ( [activeSession?.dialogTurns, renderedHistoryPresentation], ); const [projectTurnSummaries] = useState(createFlowChatTurnSummaryProjector); - const { turnSummaries, renderedTurnSummaries, absoluteRenderedTurnSummaries } = useMemo( - () => projectTurnSummaries( - activeSession, renderedTurns, renderedHistoryPresentation?.range.startOrdinal, - ), - [projectTurnSummaries, activeSession, renderedTurns, renderedHistoryPresentation?.range.startOrdinal], + const { turnSummaries, renderedTurnSummaries, absoluteRenderedTurnSummaries } = projectTurnSummaries( + activeSession, renderedTurns, renderedHistoryPresentation?.range.startOrdinal, ); const activeTurnCatalog = activeSession?.turnCatalog; const turnCatalog = activeTurnCatalog?.sessionId === activeSession?.sessionId @@ -2536,12 +2533,11 @@ export const ModernFlowChatContainer: React.FC = ( }); }, [backgroundCommands, stoppingBackgroundCommandIds.size]); - const activeSessionRemoteSshHost = activeSession?.remoteSshHost; const handleOpenSessionTreeSession = useCallback((selection: SessionTreeSelection) => { if ( - !activeSessionId || + !activeSession?.sessionId || selection.isRoot || - selection.sessionId === activeSessionId || + selection.sessionId === activeSession.sessionId || !selection.parentSessionId ) { return; @@ -2550,24 +2546,18 @@ export const ModernFlowChatContainer: React.FC = ( openBtwSessionInAuxPane({ childSessionId: selection.sessionId, parentSessionId: selection.parentSessionId, - workspaceId: selection.workspaceId || activeSessionWorkspaceId, - workspacePath: selection.workspacePath || activeSessionWorkspacePath, + workspaceId: selection.workspaceId || activeSession.workspaceId || activeSession.config?.workspaceId, + workspacePath: selection.workspacePath || activeSession.workspacePath, sessionKind: 'subagent', sessionTitle: selection.displayTitle, agentType: selection.agentType, parentToolCallId: selection.parentToolCallId, subagentType: selection.subagentType, - remoteConnectionId: selection.remoteConnectionId || activeSessionRemoteConnectionId, - remoteSshHost: selection.remoteSshHost || activeSessionRemoteSshHost, + remoteConnectionId: selection.remoteConnectionId || activeSession.remoteConnectionId, + remoteSshHost: selection.remoteSshHost || activeSession.remoteSshHost, includeInternal: true, }); - }, [ - activeSessionId, - activeSessionWorkspaceId, - activeSessionWorkspacePath, - activeSessionRemoteConnectionId, - activeSessionRemoteSshHost, - ]); + }, [activeSession]); 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 d89abb11fb..a486354d4b 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 { MAX_RETAINED_SESSION_VIEWS, retainedTranscriptRowSizes } from '../../timeline/retainedViewMemory'; +import { 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 = string; + type SessionId = 'a' | 'b' | 'c' | 'd' | 'e'; 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,11 +335,9 @@ describe('switching between long sessions (real controllers, supplied DOM geomet it('keeps only the most recently left sessions', () => { const firstA = open('a'); - 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); + 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); 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 0a69b52500..e22f1045be 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatSearch.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatSearch.ts @@ -122,8 +122,6 @@ 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()) { - if (hasEntries) { - cache = new WeakMap(); - hasEntries = false; - } + cache = new WeakMap(); return emptySearchMatches; } - hasEntries = true; return buildFlowChatSearchMatches(items, query, cache); }; } @@ -203,7 +196,6 @@ 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); @@ -214,7 +206,7 @@ export function useFlowChatSearch(virtualItems: VirtualItem[]): UseFlowChatSearc }, [matches]); const matchIndices = useMemo>(() => ( - matchesByVirtualIndex.size === 0 ? EMPTY_MATCH_INDICES : new Set(matchesByVirtualIndex.keys()) + 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 fbf47b6fab..5261581c5b 100644 --- a/src/web-ui/src/flow_chat/deep-research/deepResearchProtocol.ts +++ b/src/web-ui/src/flow_chat/deep-research/deepResearchProtocol.ts @@ -163,12 +163,6 @@ 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 ef94d33778..7843123895 100644 --- a/src/web-ui/src/flow_chat/hooks/useResolvedModeSkills.ts +++ b/src/web-ui/src/flow_chat/hooks/useResolvedModeSkills.ts @@ -6,7 +6,6 @@ import { createLogger } from '@/shared/utils/logger'; const log = createLogger('useResolvedModeSkills'); const EMPTY_SKILLS: ModeSkillInfo[] = []; -const MAX_CACHED_SKILL_SCOPES = 8; type Snapshot = { key: string; @@ -31,7 +30,7 @@ export function useResolvedModeSkills({ workspaceId?: string | null; }) { const key = JSON.stringify([surfaceEpoch, connectionId ?? null, workspaceId || null, modeId]); - const entriesRef = useRef>(new Map()); + const entryRef = useRef(null); const mountedRef = useRef(false); const [snapshot, setSnapshot] = useState(null); const [revision, setRevision] = useState(0); @@ -43,7 +42,7 @@ export function useResolvedModeSkills({ useEffect(() => { const invalidate = () => { - entriesRef.current.clear(); + entryRef.current = null; setSnapshot(null); setRevision(value => value + 1); }; @@ -52,25 +51,11 @@ export function useResolvedModeSkills({ }, []); useEffect(() => { - 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); + if (entryRef.current?.key !== key) { + entryRef.current = { key, skills: null, loading: false, failed: false }; } - if (!enabled) { - setSnapshot({ ...entry }); - return; - } - if (entry.loading) return; + const entry = entryRef.current; + if (!enabled || 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. @@ -92,15 +77,12 @@ export function useResolvedModeSkills({ }) .finally(() => { entry.loading = false; - if (mountedRef.current && entriesRef.current.get(key) === entry) { - setSnapshot({ ...entry }); - } + if (mountedRef.current && entryRef.current === entry) setSnapshot({ ...entry }); }); }, [enabled, key, modeId, workspaceId, revision]); - // 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); + // Hide a previous host/workspace/mode synchronously, before effects run. + const current = snapshot?.key === key ? snapshot : 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 a4e8aceec4..0ce6d3b924 100644 --- a/src/web-ui/src/flow_chat/hooks/useSessionGoalModeActive.ts +++ b/src/web-ui/src/flow_chat/hooks/useSessionGoalModeActive.ts @@ -1,12 +1,9 @@ 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( - subscribeToFlowChatStore, + (callback) => flowChatStore.subscribe(() => callback()), () => ( 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 7f429bd061..2b383a7c19 100644 --- a/src/web-ui/src/flow_chat/hooks/useSessionReviewActivity.ts +++ b/src/web-ui/src/flow_chat/hooks/useSessionReviewActivity.ts @@ -3,7 +3,6 @@ import { stateMachineManager } from '../state-machine'; import { flowChatStore } from '../store/FlowChatStore'; import { deriveSessionReviewActivity, - isSameSessionReviewActivity, type SessionReviewActivity, } from '../utils/sessionReviewActivity'; @@ -24,8 +23,7 @@ export function useSessionReviewActivity( useEffect(() => { const sync = () => { - const next = resolveCurrentActivity(parentSessionId); - setActivity(prev => (isSameSessionReviewActivity(prev, next) ? prev : next)); + setActivity(resolveCurrentActivity(parentSessionId)); }; sync(); diff --git a/src/web-ui/src/flow_chat/hooks/useTypewriter.ts b/src/web-ui/src/flow_chat/hooks/useTypewriter.ts index 9d232f3e07..9b05e31e4a 100644 --- a/src/web-ui/src/flow_chat/hooks/useTypewriter.ts +++ b/src/web-ui/src/flow_chat/hooks/useTypewriter.ts @@ -127,10 +127,6 @@ 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 { @@ -225,10 +221,6 @@ 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); @@ -323,10 +315,10 @@ export function useTypewriter( const finishing = !animateRef.current; const minPaintInterval = finishing ? TYPEWRITER_FINISH_MIN_PAINT_INTERVAL_MS - : (minPaintIntervalMsRef.current ?? TYPEWRITER_MIN_PAINT_INTERVAL_MS); + : TYPEWRITER_MIN_PAINT_INTERVAL_MS; const maxCharsPerPaint = finishing ? TYPEWRITER_FINISH_MAX_CHARS_PER_PAINT - : (maxCharsPerPaintRef.current ?? TYPEWRITER_MAX_CHARS_PER_PAINT); + : 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 7b596c54be..f08304ce36 100644 --- a/src/web-ui/src/flow_chat/selection/ConversationExcerptSources.tsx +++ b/src/web-ui/src/flow_chat/selection/ConversationExcerptSources.tsx @@ -1,7 +1,6 @@ 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'; @@ -9,32 +8,8 @@ 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.subscribeSelector(createSessionKeysSelector(), notify); + const stopSession = flowChatStore.subscribe(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 7ca1852557..db8dfea176 100644 --- a/src/web-ui/src/flow_chat/services/AgenticEventListener.ts +++ b/src/web-ui/src/flow_chat/services/AgenticEventListener.ts @@ -103,9 +103,6 @@ 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 e8ed389512..970f09121d 100644 --- a/src/web-ui/src/flow_chat/services/EventBatcher.ts +++ b/src/web-ui/src/flow_chat/services/EventBatcher.ts @@ -291,26 +291,6 @@ 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 f9ea67a964..c4f836e2ae 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -26,7 +26,6 @@ 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, @@ -74,7 +73,6 @@ 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'; @@ -117,8 +115,6 @@ 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; @@ -135,9 +131,6 @@ 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(), @@ -161,17 +154,6 @@ 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); @@ -322,14 +304,6 @@ 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; @@ -589,7 +563,6 @@ export class FlowChatManager { cleanupSaveState(this.context, sessionId); cleanupSessionBuffers(this.context, sessionId); }); - this.context.stagedSessionBatches?.clear(); this.context.flowChatStore.prepareForSurfaceSwitch(); try { useModernFlowChatStore.getState().clear(); @@ -627,10 +600,6 @@ 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 f67e2f8a06..3a5d57eaf7 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,12 +7,9 @@ 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'; @@ -2557,204 +2554,3 @@ 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 3c115f2778..10207653c0 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; - materializeSessionBeforeLifecycle(context, event.session_id); + context.eventBatcher.flushNow(); const session = context.flowChatStore.getState().sessions.get(event.session_id); for (const turn of session?.dialogTurns ?? []) { for (const round of turn.modelRounds) { @@ -224,185 +224,12 @@ 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, @@ -1487,7 +1314,6 @@ function finalizeTurnCompletionState( sessionId: string, turnId: string ): void { - materializeSessionBeforeLifecycle(context, sessionId); const store = FlowChatStore.getInstance(); const session = store.getState().sessions.get(sessionId); @@ -1814,7 +1640,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; @@ -1827,7 +1653,6 @@ function handleUserSteeringInjected(context: FlowChatContext, event: any): void return; } - materializeSessionBeforeLifecycle(context, sessionId); insertSteeringItemIfAbsent({ sessionId, turnId, @@ -1948,7 +1773,6 @@ export function handleSessionHistoryChanged(context: FlowChatContext, event: any return; } - materializeSessionBeforeLifecycle(context, sessionId); const session = context.flowChatStore.getState().sessions.get(sessionId); if (!session || isAcpFlowSession(session)) { return; @@ -2379,7 +2203,6 @@ 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, { @@ -2529,24 +2352,50 @@ export function processBatchedEvents( if (!parsed) continue; const { eventType } = parsed; - 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); + + 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); } - - applySingleBatchedEvent(context, eventType, payload); } } finally { context.flowChatStore.endSilentMode(); @@ -2617,7 +2466,6 @@ function handleToolEvent( return; } - materializeSessionBeforeLifecycle(context, sessionId); processToolEvent(context, sessionId, turnId, roundId, toolEvent, attemptId, attemptIndex, undefined, onTodoWriteResult); reconcileBackgroundSubagentFromParentTool(sessionId, turnId, toolEvent.tool_id); } @@ -2658,7 +2506,6 @@ function handleModelRoundStart(context: FlowChatContext, event: ModelRoundStarte }); } - materializeSessionBeforeLifecycle(context, sessionId); completeActiveTextItems(context, sessionId, turnId); const disableExploreGrouping = @@ -2733,7 +2580,6 @@ 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); @@ -2769,7 +2615,6 @@ 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); @@ -2903,7 +2748,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', { @@ -2914,7 +2759,6 @@ 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); @@ -2988,7 +2832,6 @@ 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 ( @@ -3048,7 +2891,6 @@ 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); @@ -3169,7 +3011,6 @@ 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); @@ -3293,7 +3134,6 @@ 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); @@ -3401,7 +3241,6 @@ 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); @@ -3483,7 +3322,6 @@ function handleDialogTurnInterrupted(context: FlowChatContext, event: any): void }); return; } - materializeSessionBeforeLifecycle(context, sessionId); const currentTurn = context.flowChatStore .getState() .sessions.get(sessionId) @@ -3573,7 +3411,6 @@ export function projectDialogTurnRecovered( outcome: RecoverInterruptedDialogTurnResponse, ): boolean { const { sessionId, turnId, executionGeneration } = outcome; - materializeSessionBeforeLifecycle(context, sessionId); const currentTurn = context.flowChatStore .getState() .sessions.get(sessionId) @@ -3599,7 +3436,6 @@ 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 f8b8bda04a..61e2dd5143 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,214 +96,31 @@ 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). - * Uses zero-allocation structural fingerprinting + WeakMap memoization on - * immutable round items/results instead of full-turn JSON.stringify. + * Calculate content hash for dialog turn (for deduplication) */ export function calculateTurnHash(dialogTurn: DialogTurn): string { - 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); - } - - 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 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; } - 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)}`; + return hash.toString(36); } /** @@ -319,10 +136,6 @@ 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); @@ -350,14 +163,11 @@ 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); @@ -464,14 +274,6 @@ 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); - } - } - } } } @@ -505,7 +307,6 @@ 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'); @@ -551,23 +352,7 @@ async function performSaveDialogTurnToDisk( requireSessionOwningWorkspaceId(session)); scope.assertCurrent(); - // 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); - } - } + await updateSessionMetadata(context, sessionId); scope.assertCurrent(); } catch (error) { @@ -582,11 +367,6 @@ 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[] = []; @@ -642,126 +422,6 @@ 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 */ @@ -796,9 +456,99 @@ export function convertDialogTurnToBackendFormat(dialogTurn: DialogTurn, turnInd timestamp: dialogTurn.userMessage.timestamp, metadata: mergedUserMetadata, }, - modelRounds: dialogTurn.modelRounds.map((round, roundIndex) => - convertModelRoundToBackendFormat(round, dialogTurn.id, roundIndex), - ), + 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', + }; + }), startTime: dialogTurn.startTime, endTime: dialogTurn.endTime, tokenUsage: dialogTurn.tokenUsage @@ -835,26 +585,17 @@ 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 true; + if (!fields && !isLocalSurface(scope.surfaceId)) return; try { const { sessionAPI } = await import('@/infrastructure/api/service-api/SessionAPI'); - if (!scope.isCurrent()) return false; + if (!scope.isCurrent()) return; const session = context.flowChatStore.getState().sessions.get(sessionId); - if (!session) return false; - if (isTransientSession(session) || isObserverOnlyDispatchSession(sessionId, session)) return true; + if (!session) return; + if (isTransientSession(session) || isObserverOnlyDispatchSession(sessionId, session)) return; let existingMetadata: any = null; @@ -869,9 +610,9 @@ async function performUpdateSessionMetadata( // 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 false; + return; } - if (!scope.isCurrent()) return false; + if (!scope.isCurrent()) return; const metadata = buildSessionMetadata(session, existingMetadata); if (fields?.includes('unreadCompletion')) { @@ -897,10 +638,8 @@ async function performUpdateSessionMetadata( 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 b3a86d6535..c9f18a8bc0 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,7 +337,6 @@ 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) { @@ -388,7 +387,6 @@ 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 80768267c6..6b00756aeb 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,13 +376,11 @@ 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 (!hasPath) { - const extractedPath = extractFilePathFromJsonBuffer(newBuffer); - if (extractedPath) { - effectiveParams.file_path = extractedPath; - } + if (extractedPath && !hasPath) { + effectiveParams.file_path = extractedPath; } } } @@ -1059,21 +1057,11 @@ export function handleToolExecutionProgress( ); const nextLogs = shouldAppend ? [...existingLogs, progress_message].slice(-200) : existingLogs; - const progressUpdates = { + store.updateModelRoundItem(sessionId, dialogTurn.id, tool_use_id, { _progressMessage: progress_message, _progressPercentage: percentage, _progressLogs: nextLogs - } 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); - } + } as any); 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 900c86441a..a505edc593 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,7 +64,6 @@ 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 a382760a47..b5a8b23220 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,13 +37,6 @@ 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 deleted file mode 100644 index d47a1f972a..0000000000 --- a/src/web-ui/src/flow_chat/services/pendingWorkspaceSessionActivation.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * 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 ef1d50ff28..510d123899 100644 --- a/src/web-ui/src/flow_chat/services/sessionActivation.test.ts +++ b/src/web-ui/src/flow_chat/services/sessionActivation.test.ts @@ -48,7 +48,6 @@ vi.mock('./storeSync', () => ({ })); import { openMainSession } from './sessionActivation'; -import { getPendingWorkspaceSessionActivation } from './pendingWorkspaceSessionActivation'; import { syncSessionToModernStore } from './storeSync'; describe('openMainSession resource activation', () => { @@ -174,7 +173,6 @@ 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) => { @@ -183,7 +181,6 @@ 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 1657ae5c9b..05cd37436f 100644 --- a/src/web-ui/src/flow_chat/services/sessionActivation.ts +++ b/src/web-ui/src/flow_chat/services/sessionActivation.ts @@ -8,10 +8,6 @@ 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; @@ -54,79 +50,51 @@ export async function activateMainSession(sessionId: string, options?: SessionAc && workspaceManager.getState().currentWorkspace) { throw new Error(i18nService.t('common:sceneBar.workspaceUnavailable')); } - const needsWorkspaceSwitch = Boolean( - workspaceId && workspaceManager.getState().activeWorkspaceId !== workspaceId, - ); - if (needsWorkspaceSwitch && workspaceId) { - setPendingWorkspaceSessionActivation({ - surfaceId: scope.surfaceId, - workspaceId, - sessionId, - request, - }); + if (workspaceId && workspaceManager.getState().activeWorkspaceId !== workspaceId) { // 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); } - 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 (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')) - ) { - await flowChatManager.switchChatSession(sessionId, isCurrent); - if (!isTargetActive()) { - return false; - } - } - syncSessionToModernStore(sessionId); - } else { + 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); } - - 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); + } else { + await flowChatManager.switchChatSession(sessionId, isCurrent); + if (!isTargetActive()) { + return false; } + 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 b063031ee7..83f097dac5 100644 --- a/src/web-ui/src/flow_chat/services/sessionNavStatusService.ts +++ b/src/web-ui/src/flow_chat/services/sessionNavStatusService.ts @@ -13,87 +13,10 @@ import { driverForSession, sessionDriverNavigationStatusSources } from '../sessi import { deriveSessionNavStatus, type SessionNavStatus } from '../utils/sessionNavStatus'; import { SessionNavOrdering } from '../utils/sessionNavOrdering'; import { isUnmaterializedSessionDraft } from '../utils/sessionDraft'; -import type { DialogTurn, ModelRound, Session } from '../types/flow-chat'; +import type { 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; @@ -282,10 +205,6 @@ 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 ef4ceb0097..2c76d168fa 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,10 +384,6 @@ 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 030c0903c7..00035e0c34 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -2318,13 +2318,9 @@ 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 @@ -2455,62 +2451,13 @@ 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); - const previousLeaseCount = this.historyLeases.get(key) ?? 0; - this.historyLeases.set(key, previousLeaseCount + 1); + this.historyLeases.set(key, (this.historyLeases.get(key) ?? 0) + 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 () => { @@ -3584,7 +3531,6 @@ 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); @@ -4760,37 +4706,12 @@ export class FlowChatStore { } public beginSilentMode(): void { - if (this.silentModeDepth === 0) { - this.silentModeInitialState = this.state; - this.silentMode = true; - } - this.silentModeDepth += 1; + this.silentMode = true; } 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.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; - } + this.notifyListeners(); } private collectCascadeSessionIds( @@ -5113,9 +5034,6 @@ 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; @@ -5140,11 +5058,9 @@ export class FlowChatStore { }; }); - if (typeof window !== 'undefined' && typeof CustomEvent !== 'undefined') { - window.dispatchEvent(new CustomEvent('openbitfun:session-switched', { - detail: { sessionId, mode: sessionMode || 'Standard' } - })); - } + window.dispatchEvent(new CustomEvent('openbitfun:session-switched', { + detail: { sessionId, mode: sessionMode || 'Standard' } + })); if (targetSessionExists && previousSessionId !== sessionId) { this.scheduleActiveLegacyPartialSessionHistoryCompletion(sessionId, 'session-switch'); @@ -6069,24 +5985,6 @@ 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); @@ -6154,8 +6052,6 @@ 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; @@ -6626,7 +6522,6 @@ 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); @@ -7121,17 +7016,9 @@ export class FlowChatStore { const session = prev.sessions.get(sessionId); if (!session) return prev; - 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 updatedDialogTurns = session.dialogTurns.map(turn => + turn.id === dialogTurnId ? updater(turn) : turn + ); const updatedSession = { ...session, @@ -7365,13 +7252,7 @@ export class FlowChatStore { }); } - public addModelRoundItem( - sessionId: string, - dialogTurnId: string, - item: AnyFlowItem, - modelRoundId?: string, - options?: { touchActivity?: boolean }, - ): void { + public addModelRoundItem(sessionId: string, dialogTurnId: string, item: AnyFlowItem, modelRoundId?: string): void { this.updateDialogTurn(sessionId, dialogTurnId, turn => { let targetModelRoundIndex = turn.modelRounds.length - 1; if (modelRoundId) { @@ -7461,7 +7342,7 @@ export class FlowChatStore { ...turn, modelRounds: updatedModelRounds }; - }, options); + }); } /** @@ -7472,19 +7353,13 @@ export class FlowChatStore { const prevSilentMode = this.silentMode; this.silentMode = true; try { - this.addModelRoundItem(sessionId, dialogTurnId, item, modelRoundId, { touchActivity: false }); + this.addModelRoundItem(sessionId, dialogTurnId, item, modelRoundId); } finally { this.silentMode = prevSilentMode; } } - public updateModelRoundItem( - sessionId: string, - dialogTurnId: string, - itemId: string, - updates: Partial, - options?: { touchActivity?: boolean }, - ): boolean { + public updateModelRoundItem(sessionId: string, dialogTurnId: string, itemId: string, updates: Partial): boolean { let updated = false; this.updateDialogTurn(sessionId, dialogTurnId, turn => { @@ -7552,7 +7427,7 @@ export class FlowChatStore { ...turn, modelRounds: updatedModelRounds }; - }, options); + }); if (!updated) { markRuntimeSessionProjectionStale(getActiveSurfaceScope().surfaceId, sessionId); } @@ -7567,7 +7442,7 @@ export class FlowChatStore { const prevSilentMode = this.silentMode; this.silentMode = true; try { - return this.updateModelRoundItem(sessionId, dialogTurnId, itemId, updates, { touchActivity: false }); + return this.updateModelRoundItem(sessionId, dialogTurnId, itemId, updates); } finally { this.silentMode = prevSilentMode; } @@ -7953,7 +7828,6 @@ 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 0bdab5fcc8..1765a6b4e9 100644 --- a/src/web-ui/src/flow_chat/store/backgroundSubagentActivityStore.ts +++ b/src/web-ui/src/flow_chat/store/backgroundSubagentActivityStore.ts @@ -37,7 +37,8 @@ function isSameActivity( left.remoteSshHost === right.remoteSshHost && left.parentToolCallId === right.parentToolCallId && left.subagentType === right.subagentType && - left.createdAt === right.createdAt; + left.createdAt === right.createdAt && + left.updatedAt === right.updatedAt; } 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 a8234c7da6..d2ecf406ca 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts @@ -421,8 +421,6 @@ 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 } = {}, @@ -432,37 +430,19 @@ export function createSessionVirtualItemProjector( let warmProjections = reuseSnapshotsAcrossMounts ? retainedSessionProjections : new WeakMap(); - const cachedTurnPrefixesBySession = new Map>(); + const cachedTurnPrefixById = 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(); - cachedTurnPrefixesBySession.clear(); + cachedTurnPrefixById.clear(); return emptyItems; } - const previousSessionId = sessionId; + if (sessionId !== session.sessionId) cachedTurnPrefixById.clear(); sessionId = session.sessionId; - const cachedTurnPrefixById = getSessionPrefixCache(session.sessionId); const restored = warmProjections.get(session.dialogTurns); if (restored && restored.sessionId === session.sessionId @@ -470,10 +450,8 @@ export function createSessionVirtualItemProjector( && restored.isPartial === session.isPartial && restored.totalTurnCount === session.totalTurnCount && restored.needsUserAttention === session.needsUserAttention) { - // Returning to another snapshot within the same session must release the previous window's body. - if (restored !== previousProjection && previousSessionId === session.sessionId) { - cachedTurnPrefixById.clear(); - } + // Returning to another snapshot must release the previous window's body. + if (restored !== previousProjection) cachedTurnPrefixById.clear(); previousProjection = restored; return restored.virtualItems; } @@ -707,30 +685,21 @@ export const createModernFlowChatStore = (initialSession?: Session | null) => { setActiveSession: (session) => { const items = projectSession(session); - set((state) => { - if (state.activeSession === session && state.virtualItems === items) { - return state; - } - return { - ...(state.activeSession?.sessionId !== session?.sessionId ? { visibleTurnInfo: null } : null), - activeSession: session, - virtualItems: items, - }; - }); + set((state) => ({ + ...(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 - ? state - : { - ...state, - virtualItems: items, - } - )); + set((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 0c4ba8e4c0..d8fbdab8f3 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 { } } -/** Retain recently left sessions across concurrent multi-session switches. */ -export const MAX_RETAINED_SESSION_VIEWS = 8; +/** About three: the session just left, and the one or two before it. */ +export const MAX_RETAINED_SESSION_VIEWS = 3; /** * 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 6b7d4b796a..94f8acfc05 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,7 +3,6 @@ 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; @@ -55,10 +54,8 @@ 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 (let index = 0; index <= MAX_RETAINED_SESSION_VIEWS; index += 1) { - open(`other-${index}`); - } - expect(retainedTimelineProjections.size).toBe(MAX_RETAINED_SESSION_VIEWS); + for (const id of ['b', 'c', 'd', 'e']) open(id); + expect(retainedTimelineProjections.size).toBe(3); 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 e66a30972e..3879dadc71 100644 --- a/src/web-ui/src/flow_chat/timeline/useConversationTimeline.ts +++ b/src/web-ui/src/flow_chat/timeline/useConversationTimeline.ts @@ -20,11 +20,6 @@ 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); @@ -46,11 +41,19 @@ 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 (prewarmedSources.has(source)) continue; - prewarmedSources.add(source); + if (seenSources.has(source)) continue; + seenSources.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 : []; @@ -58,8 +61,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 (prewarmedTexts.has(member)) continue; - prewarmedTexts.add(member); + if (seen.has(member)) continue; + seen.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 e6583c2b87..5992bcbb5f 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, { trackFiles: false }); + } = useSnapshotState(imported ? undefined : sessionId); 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 7a4dc88f4f..a48c2b1c13 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx @@ -109,30 +109,12 @@ 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, - minPaintIntervalMs: thinkingPaintIntervalMs, - maxCharsPerPaint: thinkingMaxCharsPerPaint, - }, + { revealImmediately: !retainClosingContent || yieldToSuccessor }, ); // 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). @@ -217,18 +199,7 @@ export const ModelThinkingDisplay: React.FC = ({ }, [isVisuallyStreaming, streamingViewportExpanded, revealStreamingContent, streamIdentity, resume]); const contentLengthText = useMemo(() => { - 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 }); + return t('toolCards.think.thinkingCharacters', { count: Array.from(content).length }); }, [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 11d4dbed9f..467aaffff4 100644 --- a/src/web-ui/src/flow_chat/tool-cards/fileOperationDiffStats.ts +++ b/src/web-ui/src/flow_chat/tool-cards/fileOperationDiffStats.ts @@ -23,37 +23,16 @@ 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) { - return { additions: countContentLines(contentPreview), deletions: 0 }; + const lines = contentPreview.split('\n'); + return { additions: lines.length - (lines.at(-1) === '' ? 1 : 0), 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 b7f6b9e4ba..e9c9ea55d4 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, ModelRound, Session } from '../types/flow-chat'; +import type { DialogTurn, FlowToolItem, Session } from '../types/flow-chat'; import { effectiveToolInvocation } from './toolInvocationIdentity'; export const TRANSIENT_TURN_STATUSES = new Set([ @@ -17,33 +17,6 @@ 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 @@ -61,9 +34,28 @@ export function findPendingAskUserQuestion( } for (let roundIndex = turn.modelRounds.length - 1; roundIndex >= 0; roundIndex -= 1) { - const pending = findPendingAskUserQuestionInRound(turn.modelRounds[roundIndex]); - if (pending) { - return pending; + 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; + } } } diff --git a/src/web-ui/src/flow_chat/utils/sessionNavOrdering.ts b/src/web-ui/src/flow_chat/utils/sessionNavOrdering.ts index ca1ccb7fa7..dadedf34e0 100644 --- a/src/web-ui/src/flow_chat/utils/sessionNavOrdering.ts +++ b/src/web-ui/src/flow_chat/utils/sessionNavOrdering.ts @@ -22,13 +22,6 @@ 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 a5f57928b6..8ce6fde304 100644 --- a/src/web-ui/src/flow_chat/utils/sessionReviewActivity.ts +++ b/src/web-ui/src/flow_chat/utils/sessionReviewActivity.ts @@ -118,27 +118,6 @@ 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, @@ -148,28 +127,15 @@ export function deriveSessionReviewActivity( return null; } - 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 activities = Array.from(state.sessions.values()) + .map(session => toReviewActivity(session, parentSessionId, resolveExecutionState)) + .filter((activity): activity is SessionReviewActivity => Boolean(activity)); - 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; - } - } + const blockingActivities = activities.filter(activity => activity.isBlocking); + const candidates = blockingActivities.length > 0 ? blockingActivities : activities; - return bestBlocking ?? bestAny; + return candidates.sort((left, right) => { + const updatedDelta = right.updatedAt - left.updatedAt; + return updatedDelta !== 0 ? updatedDelta : right.startedAt - left.startedAt; + })[0] ?? null; } 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 55b5a20b71..e643e48921 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,9 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { - isExpectedTauriRequestError, - TAURI_EVENT_BATCH_NAME, - TauriTransportAdapter, -} from './tauri-adapter'; +import { isExpectedTauriRequestError, TauriTransportAdapter } from './tauri-adapter'; import { beginRuntimeSessionAttachment, resetRuntimeSessionEventGateForTest, @@ -265,60 +261,4 @@ 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 e5c8de84ce..29dbd6a829 100644 --- a/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.ts @@ -4,20 +4,11 @@ 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, - PEER_EVENT_SOURCE_KEY, - readSourceDeviceId, - routeSurfaceEvent, - unwrapSurfacePayload, -} from '@/infrastructure/peer-device/deviceSurfaceRouting'; +import { getActiveSurfaceDeviceId, isSurfaceScopedEvent, routeSurfaceEvent } 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 { @@ -61,87 +52,6 @@ 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, @@ -151,12 +61,49 @@ function registerSharedTauriEventListener( return; } - if (event === TAURI_EVENT_BATCH_NAME) { - dispatchTauriEventBatch(e.payload); + // 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) { return; } - dispatchSharedTauriEvent(event, e.payload); + 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), + }); + } + } + }, + ); }).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 c072d9528c..dc6564865f 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -1488,10 +1488,6 @@ 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 0589361acd..ab7c00e415 100644 --- a/src/web-ui/src/infrastructure/contexts/WorkspaceProvider.tsx +++ b/src/web-ui/src/infrastructure/contexts/WorkspaceProvider.tsx @@ -13,53 +13,6 @@ 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; } @@ -197,28 +150,11 @@ 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 f720299eff..a4a121a56b 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, { useMemo } from 'react'; +import React from 'react'; import ReactMarkdown from 'react-markdown'; import type { Components } from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -16,23 +16,6 @@ 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; @@ -56,31 +39,10 @@ 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 6f53f75ab2..72dbaa15db 100644 --- a/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx +++ b/src/web-ui/src/infrastructure/markdown/MarkdownRenderer.tsx @@ -56,25 +56,6 @@ 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. @@ -194,227 +175,16 @@ 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; - } - const prose = stripMarkdownCodeForFeatureDetection(content); - if (!prose.includes('$') && !prose.includes('\\(') && !prose.includes('\\[')) { - return false; - } - - if (prose.includes('$$') || prose.includes('\\(') || prose.includes('\\[')) { + if (content.includes('$$') || content.includes('\\(') || content.includes('\\[')) { return true; } - return /(^|[^\\$])\$[^$\n]{1,240}\$(?!\d)/.test(prose); + return /(^|[^\\$])\$[^$\n]{1,240}\$(?!\d)/.test(content); } /** Catches render errors from react-markdown/remark-gfm (e.g. RegExp in transformGfmAutolinkLiterals) and shows plain text fallback. */ @@ -1087,7 +857,7 @@ const CodeBlockFallback: React.FC = ({ ); }; -const CopyButton = React.memo<{ code: string }>(({ code }) => { +const CopyButton: React.FC<{ code: string }> = ({ code }) => { const [copied, setCopied] = useState(false); const resetTimer = useRef | null>(null); const label = copied ? translateMarkdownLabel('markdown.copySuccess') : translateMarkdownLabel('markdown.copyCode'); @@ -1122,7 +892,7 @@ const CopyButton = React.memo<{ code: string }>(({ code }) => { /> ); -}); +}; export interface MarkdownRendererProps { content: string; @@ -1237,10 +1007,11 @@ const MarkdownSurface = React.memo ( - thinking || fileActionsViaCallbackOnly || Boolean(currentWorkspacePath || basePath) - ? false - : mayNeedWorkspacePathForMarkdownLinks(markdownContent) - ), - [basePath, currentWorkspacePath, fileActionsViaCallbackOnly, markdownContent, thinking], + () => mayNeedWorkspacePathForMarkdownLinks(markdownContent), + [markdownContent], ); useEffect(() => { @@ -1279,17 +1046,14 @@ const MarkdownSurface = React.memo (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 markdownFeatureProfile = useMemo(() => ({ + contentLength: markdownContent.length, + hasCodeBlock: /^[ \t]{0,3}(`{3,}|~{3,})/m.test(markdownContent), + hasTable: /^[ \t]*\|.+\|[ \t]*$/m.test(markdownContent), + }), [markdownContent]); const shouldUseMathRenderer = useMemo( - () => (thinking ? false : mayContainMarkdownMath(markdownContent)), - [markdownContent, thinking], + () => mayContainMarkdownMath(markdownContent), + [markdownContent], ); // Parse line ranges like #L42 / 1-20 @@ -1606,7 +1370,7 @@ const MarkdownSurface = React.memo 12_000 && code.length <= 24_000; return (
@@ -2001,7 +1765,7 @@ const MarkdownSurface = React.memo{fragment} @@ -2020,30 +1784,10 @@ 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 57386f55bd..bc17b48f1d 100644 --- a/src/web-ui/src/infrastructure/markdown/markdownSections.test.ts +++ b/src/web-ui/src/infrastructure/markdown/markdownSections.test.ts @@ -61,15 +61,4 @@ 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 3e7dc1afdd..bccdfc58ae 100644 --- a/src/web-ui/src/infrastructure/markdown/markdownSections.ts +++ b/src/web-ui/src/infrastructure/markdown/markdownSections.ts @@ -24,71 +24,6 @@ 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] = []; @@ -96,8 +31,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/footnote references outside code spans may bind beyond a top-level block. - || (token.type === 'inline' && hasCrossBlockInlineSyntax(token.content)))) { + // Inline HTML/reference definitions may bind beyond a top-level block. + || (token.type === 'inline' && /<|\]\s*\[|\[\^/.test(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 4110edc1db..c31408e502 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; skipHighlight?: boolean } +interface TextRun { node: Text; start: number; end: number } interface OwnedRange { registry: Map; name: string; @@ -20,8 +20,6 @@ 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); @@ -29,41 +27,16 @@ function pruneEmptyBuckets(entries: OwnedRange[]): void { } function readRuns(root: HTMLElement): { runs: TextRun[]; text: string } { - 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 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, + }); const runs: TextRun[] = []; let text = ''; for (let node = walker.nextNode(); node; node = walker.nextNode()) { const start = 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 }); + text += node.textContent ?? ''; + runs.push({ node: node as Text, start, end: text.length }); } return { runs, text }; } @@ -170,7 +143,6 @@ 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 d5df06ee3b..834e0414fb 100644 --- a/src/web-ui/src/infrastructure/services/business/workspaceManager.ts +++ b/src/web-ui/src/infrastructure/services/business/workspaceManager.ts @@ -1449,9 +1449,8 @@ class WorkspaceManager { return currentWorkspace; } - if (this.state.error !== null) { - this.setError(null); - } + this.setLoading(true); + this.setError(null); const isClosedAssistant = !this.state.openedWorkspaces.has(workspaceId) && this.state.assistantWorkspaces?.some(workspace => workspace.id === workspaceId); @@ -1467,15 +1466,11 @@ 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.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)]; + ? knownOpened : [workspace, ...knownOpened]; const switchSequence = ++this.activeWorkspaceSwitchSequence; this.updateWorkspaceState( workspace, - optimisticRecent, + this.state.recentWorkspaces, 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 e6d675b593..77d508e0d6 100644 --- a/src/web-ui/src/shared/utils/resourceBudget.ts +++ b/src/web-ui/src/shared/utils/resourceBudget.ts @@ -88,114 +88,15 @@ 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; - 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); + for (const [key, child] of Object.entries(value)) { + bytes += key.length * 2 + 8 + estimateRetainedBytes(child, 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 e311992b9c..89c95a5f1a 100644 --- a/src/web-ui/src/tools/git/services/WorkspaceGitInitializer.test.ts +++ b/src/web-ui/src/tools/git/services/WorkspaceGitInitializer.test.ts @@ -26,8 +26,6 @@ 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(); @@ -38,41 +36,4 @@ 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 91cb582bc3..5a904638d7 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 { - gitStateManager.invalidateCache({ workspaceId }, ['basic', 'status', 'detailed']); - if (this.currentWorkspaceId === workspaceId) { - this.currentWorkspaceId = null; + if (this.currentWorkspaceId) { + gitStateManager.invalidateCache({ workspaceId: this.currentWorkspaceId }, ['basic', 'status', 'detailed']); } + this.currentWorkspaceId = null; } catch (error) { log.error('Failed to cleanup Git state', { workspaceId, error }); } @@ -88,11 +88,14 @@ 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: false, + force: true, 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 eaa3b14fe6..196ac373f3 100644 --- a/src/web-ui/src/tools/snapshot_system/core/SnapshotStateManager.ts +++ b/src/web-ui/src/tools/snapshot_system/core/SnapshotStateManager.ts @@ -51,7 +51,6 @@ 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(); @@ -169,67 +168,52 @@ export class SnapshotStateManager { shouldRefreshSnapshotForSession(flowChatStore.getState().sessions.get(sessionId), sessionId); if (!canRefresh()) return; - const refreshKey = scope.key(scope.epoch, sessionId); - const existing = this.inFlightSessionRefreshes.get(refreshKey); - if (existing) { - return existing; - } + 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 promise = (async () => { + // NOTE: `getSessionStats` does not include file lists. Fetch files separately when available. try { - const stats = await this.snapshotService.getSessionStats(sessionId); + const { snapshotAPI } = await import('@/infrastructure/api'); + if (!canRefresh()) return; + const files = await snapshotAPI.getSessionFiles(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() - }; - - // 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 }); - } - this.sessions.set(sessionId, sessionState); - this.eventBus.emit(SNAPSHOT_EVENTS.SESSION_STATE_CHANGED, sessionState, sessionId); - } catch (error) { + 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.error('Failed to refresh session state', { sessionId, error }); + log.warn('Failed to get file list, using basic stats', { sessionId, error: fileListError }); } - })().finally(() => { - if (this.inFlightSessionRefreshes.get(refreshKey) === promise) { - this.inFlightSessionRefreshes.delete(refreshKey); - } - }); - this.inFlightSessionRefreshes.set(refreshKey, promise); - return promise; + 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 }); + } } 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 badb5094e0..aa5cb571cd 100644 --- a/src/web-ui/src/tools/snapshot_system/hooks/useSnapshotState.ts +++ b/src/web-ui/src/tools/snapshot_system/hooks/useSnapshotState.ts @@ -12,12 +12,6 @@ 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; @@ -41,11 +35,7 @@ interface UseSnapshotStateReturn { clearError: () => void; } -export const useSnapshotState = ( - sessionId?: string, - options?: UseSnapshotStateOptions, -): UseSnapshotStateReturn => { - const trackFiles = options?.trackFiles !== false; +export const useSnapshotState = (sessionId?: string): UseSnapshotStateReturn => { const { t } = useTranslation('flow-chat'); const surfaceScope = useSyncExternalStore( subscribeToSurfaceActivation, getActiveSurfaceScope, getActiveSurfaceScope, @@ -53,7 +43,7 @@ export const useSnapshotState = ( const identity = surfaceScope.key(surfaceScope.epoch, sessionId); const [boundIdentity, setBoundIdentity] = useState(identity); const snapshotsAvailable = useSyncExternalStore( - subscribeToFlowChatStore, + (callback) => flowChatStore.subscribe(() => callback()), () => hasSessionFileSnapshots(sessionId ? flowChatStore.getState().sessions.get(sessionId) : undefined, sessionId), () => false, ); @@ -263,7 +253,7 @@ export const useSnapshotState = ( setBoundIdentity(identity); setLoading(false); setError(null); - if (!sessionId || !trackFiles) { + if (!sessionId) { setFiles([]); setSessionState(null); activeSessionIdRef.current = undefined; @@ -336,7 +326,7 @@ export const useSnapshotState = ( unsubscribeFile(); unsubscribeFlowChat(); }; - }, [identity, sessionId, stateManager, refreshSession, surfaceScope, trackFiles]); + }, [identity, sessionId, stateManager, refreshSession, surfaceScope]); // An activation may render the same Session id before effects rebind. Never // paint the previous device's cached files during that render.