diff --git a/scripts/i18n-governance-baseline.json b/scripts/i18n-governance-baseline.json index 96515fe891..1679b820f1 100644 --- a/scripts/i18n-governance-baseline.json +++ b/scripts/i18n-governance-baseline.json @@ -6,13 +6,13 @@ "maxTotal": 0 }, "sharedTermDuplicates": { - "maxTotal": 176, + "maxTotal": 174, "bySurface": { "core": 15, "installer": 0, "mobile-web": 0, "relay-static-homepage": 0, - "web-ui": 161 + "web-ui": 159 }, "bySharedKey": { "agents.claw": 3, @@ -34,7 +34,7 @@ "statuses.done": 24, "statuses.failed": 42, "statuses.loading": 5, - "statuses.running": 12, + "statuses.running": 10, "tools.edit": 33, "tools.explore": 2, "tools.search": 12, diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index c5dd1c20da..b2f512f9c6 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -736,6 +736,13 @@ impl From for DeepReviewQueueControlAction { #[serde(rename_all = "camelCase")] pub struct CancelSessionRequest { pub session_id: String, + /// Tree cancellation opts out so a parent session does not stop its children. + #[serde(default = "default_cancel_descendants")] + pub cancel_descendants: bool, +} + +fn default_cancel_descendants() -> bool { + true } fn sanitize_create_session_review_metadata(request: &mut CreateSessionRequest) { @@ -2595,7 +2602,11 @@ pub async fn cancel_session( request: CancelSessionRequest, ) -> Result { let dialog_turn_id = coordinator - .cancel_active_turn_for_session(&request.session_id, std::time::Duration::from_secs(5)) + .cancel_active_turn_for_session_with_descendant_policy( + &request.session_id, + std::time::Duration::from_secs(5), + request.cancel_descendants, + ) .await .map_err(|e| { log::error!( diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 7025a448d0..7a28e610cd 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -660,6 +660,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "get_session_file_diff_stats", RemoteWorkspacePolicy::LegacyUnaudited, ), + ("get_session_lineage", RemoteWorkspacePolicy::RemoteRouted), ("get_session_files", RemoteWorkspacePolicy::LegacyUnaudited), ( "get_session_operations", diff --git a/src/apps/desktop/src/api/session_api.rs b/src/apps/desktop/src/api/session_api.rs index 41e42d584c..65fff92561 100644 --- a/src/apps/desktop/src/api/session_api.rs +++ b/src/apps/desktop/src/api/session_api.rs @@ -7,7 +7,9 @@ use crate::runtime::{ }; use crate::startup_trace::DesktopStartupTrace; use bitfun_core::agentic::coordination::get_global_scheduler; -use bitfun_core::agentic::persistence::{SessionBranchResult, SessionMetadataPage}; +use bitfun_core::agentic::persistence::{ + SessionBranchResult, SessionLineageSnapshot, SessionMetadataPage, +}; use bitfun_core::service::remote_ssh::normalize_remote_workspace_path; use bitfun_core::service::session::{ DialogTurnData, SessionKind, SessionMetadata, SessionStatus, SessionTranscriptExport, @@ -56,6 +58,16 @@ pub struct ListPersistedSessionsPageRequest { pub remote_ssh_host: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetSessionLineageRequest { + pub session_id: String, + pub workspace_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_connection_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_ssh_host: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LoadSessionTurnsRequest { pub session_id: String, @@ -359,6 +371,30 @@ pub async fn list_persisted_sessions_page( result } +#[tauri::command] +pub async fn get_session_lineage( + request: GetSessionLineageRequest, + runtime: State<'_, DesktopRuntimeContext>, +) -> Result, String> { + runtime + .session_application() + .get_session_lineage( + desktop_session_scope( + request.workspace_path, + request.remote_connection_id, + request.remote_ssh_host, + ), + &request.session_id, + ) + .await + .map_err(|error| { + format!( + "Failed to load session lineage: {}", + desktop_session_error(error) + ) + }) +} + #[tauri::command] pub async fn load_session_turns( request: LoadSessionTurnsRequest, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 3b2255b6bf..0ec02fbfa1 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1413,6 +1413,7 @@ pub async fn run() { list_persisted_sessions, search_referenceable_sessions, list_persisted_sessions_page, + get_session_lineage, load_session_turns, get_session_usage_report, save_session_turn, diff --git a/src/apps/desktop/src/runtime/session_application.rs b/src/apps/desktop/src/runtime/session_application.rs index eadb0c4e8f..7a68094b06 100644 --- a/src/apps/desktop/src/runtime/session_application.rs +++ b/src/apps/desktop/src/runtime/session_application.rs @@ -16,7 +16,9 @@ use bitfun_agent_runtime::sdk::{ }; use bitfun_core::agentic::coordination::{ConversationCoordinator, DialogScheduler}; use bitfun_core::agentic::core::Session; -use bitfun_core::agentic::persistence::{SessionBranchResult, SessionMetadataPage}; +use bitfun_core::agentic::persistence::{ + SessionBranchResult, SessionLineageSnapshot, SessionMetadataPage, +}; use bitfun_core::agentic::session::SessionViewRestoreTiming; use bitfun_core::product_runtime::{CoreAgentRuntimeCompatibility, CoreProductAgentRuntime}; use bitfun_core::service::remote_ssh::workspace_state::{ @@ -392,6 +394,19 @@ impl DesktopSessionApplication { .map_err(|error| DesktopSessionApplicationError::Core(error.to_string())) } + pub(crate) async fn get_session_lineage( + &self, + request: DesktopSessionScopeRequest, + anchor_session_id: &str, + ) -> DesktopSessionApplicationResult> { + let scope = self.resolved_scope(request).await; + let storage_path = self.storage_path(&scope); + self.compatibility + .get_persisted_session_lineage(&storage_path, anchor_session_id) + .await + .map_err(|error| DesktopSessionApplicationError::Core(error.to_string())) + } + pub(crate) async fn list_archived_sessions( &self, request: DesktopSessionScopeRequest, diff --git a/src/apps/server/src/rpc_dispatcher.rs b/src/apps/server/src/rpc_dispatcher.rs index 084f16a342..5d1416326c 100644 --- a/src/apps/server/src/rpc_dispatcher.rs +++ b/src/apps/server/src/rpc_dispatcher.rs @@ -482,9 +482,17 @@ pub async fn dispatch( "cancel_session" => { let request = extract_request(¶ms)?; let session_id = get_string(&request, "sessionId")?; + let cancel_descendants = request + .get("cancelDescendants") + .and_then(serde_json::Value::as_bool) + .unwrap_or(true); let dialog_turn_id = state .coordinator - .cancel_active_turn_for_session(&session_id, Duration::from_secs(5)) + .cancel_active_turn_for_session_with_descendant_policy( + &session_id, + Duration::from_secs(5), + cancel_descendants, + ) .await .map_err(|e| anyhow!("{}", e))?; Ok(serde_json::json!({ diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 29cc169c43..143cfb25e9 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -5918,10 +5918,20 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet &self, session_id: &str, dialog_turn_id: &str, + ) -> BitFunResult<()> { + self.cancel_dialog_turn_with_descendant_policy(session_id, dialog_turn_id, true) + .await + } + + async fn cancel_dialog_turn_with_descendant_policy( + &self, + session_id: &str, + dialog_turn_id: &str, + cancel_descendants: bool, ) -> BitFunResult<()> { info!( - "Received cancel request: dialog_turn_id={}, session_id={}", - dialog_turn_id, session_id + "Received cancel request: dialog_turn_id={}, session_id={}, cancel_descendants={}", + dialog_turn_id, session_id, cancel_descendants ); if let Some(control) = self.manual_compaction_controls.get(dialog_turn_id) { @@ -6000,8 +6010,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet warn!("Failed to cancel tool execution: {}", e); } - self.cancel_active_subagents_for_parent_turn(session_id, dialog_turn_id) - .await; + if cancel_descendants { + self.cancel_active_subagents_for_parent_turn(session_id, dialog_turn_id) + .await; + } // Step 4: Wait briefly for the spawn task that owns this turn to drain // its in-memory message writes before returning. Capped so the RPC @@ -6029,6 +6041,17 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet &self, session_id: &str, wait_timeout: Duration, + ) -> BitFunResult> { + self.cancel_active_turn_for_session_with_descendant_policy(session_id, wait_timeout, true) + .await + } + + /// Cancel only the target session when `cancel_descendants` is false. + pub async fn cancel_active_turn_for_session_with_descendant_policy( + &self, + session_id: &str, + wait_timeout: Duration, + cancel_descendants: bool, ) -> BitFunResult> { abort_thread_goal_continuation_for_session(session_id); @@ -6043,8 +6066,12 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet return Ok(None); }; - self.cancel_dialog_turn(session_id, ¤t_turn_id) - .await?; + self.cancel_dialog_turn_with_descendant_policy( + session_id, + ¤t_turn_id, + cancel_descendants, + ) + .await?; let deadline = Instant::now() + wait_timeout; while self.execution_engine.has_active_turn(¤t_turn_id) { diff --git a/src/crates/assembly/core/src/agentic/persistence/mod.rs b/src/crates/assembly/core/src/agentic/persistence/mod.rs index ae0598d50c..426ae57479 100644 --- a/src/crates/assembly/core/src/agentic/persistence/mod.rs +++ b/src/crates/assembly/core/src/agentic/persistence/mod.rs @@ -7,6 +7,6 @@ pub mod session_branch; pub use bitfun_runtime_ports::SessionTurnLoadTiming; pub use bitfun_services_core::session::{ - SessionBranchRequest, SessionBranchResult, SessionMetadataPage, + SessionBranchRequest, SessionBranchResult, SessionLineageSnapshot, SessionMetadataPage, }; pub use manager::{MaterializedSessionReferenceTranscript, PersistenceManager}; diff --git a/src/crates/assembly/core/src/product_runtime.rs b/src/crates/assembly/core/src/product_runtime.rs index 2b765fefb3..8a5254b3ff 100644 --- a/src/crates/assembly/core/src/product_runtime.rs +++ b/src/crates/assembly/core/src/product_runtime.rs @@ -28,7 +28,9 @@ use bitfun_runtime_ports::{ }; use bitfun_runtime_services::RuntimeServices; use bitfun_services_core::permission_store::ProjectPermissionSqliteStore; -use bitfun_services_core::session::SessionBranchBoundary; +use bitfun_services_core::session::{ + build_session_lineage_snapshot, SessionBranchBoundary, SessionLineageSnapshot, +}; use crate::agentic::coordination::{ ConversationCoordinator, DialogScheduler, SessionMaintenancePermit, @@ -853,6 +855,18 @@ impl CoreAgentRuntimeCompatibility { .await } + pub async fn get_persisted_session_lineage( + &self, + workspace_path: &Path, + anchor_session_id: &str, + ) -> BitFunResult> { + let metadata = self + .persistence + .list_session_metadata_including_internal(workspace_path) + .await?; + Ok(build_session_lineage_snapshot(metadata, anchor_session_id)) + } + pub async fn load_persisted_session_metadata( &self, workspace_path: &Path, diff --git a/src/crates/services/services-core/src/session/lineage.rs b/src/crates/services/services-core/src/session/lineage.rs index 5418c05d89..6b5ff289b9 100644 --- a/src/crates/services/services-core/src/session/lineage.rs +++ b/src/crates/services/services-core/src/session/lineage.rs @@ -27,6 +27,13 @@ struct SubagentRelationshipFacts { parent_dialog_turn_id: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionLineageSnapshot { + pub root_session_id: String, + pub sessions: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionBranchRequest { @@ -243,6 +250,139 @@ pub fn collect_hidden_subagent_cascade( ordered_session_ids } +/// Builds the complete subagent Session tree containing `anchor_session_id`. +/// +/// The snapshot stays flat so callers can project it for their own surface +/// without making recursive serialization depth part of the contract. +pub fn build_session_lineage_snapshot( + metadata_list: impl IntoIterator, + anchor_session_id: &str, +) -> Option { + let anchor_session_id = anchor_session_id.trim(); + if anchor_session_id.is_empty() { + return None; + } + + let metadata_by_id = metadata_list + .into_iter() + .map(|metadata| (metadata.session_id.clone(), metadata)) + .collect::>(); + if !metadata_by_id.contains_key(anchor_session_id) { + return None; + } + + let mut root_session_id = anchor_session_id.to_string(); + let mut ancestor_ids = HashSet::from([root_session_id.clone()]); + while let Some(parent_session_id) = metadata_by_id + .get(&root_session_id) + .and_then(subagent_parent_session_id) + .filter(|parent_session_id| metadata_by_id.contains_key(parent_session_id)) + { + if !ancestor_ids.insert(parent_session_id.clone()) { + root_session_id = anchor_session_id.to_string(); + break; + } + root_session_id = parent_session_id; + } + + let mut children_by_parent = HashMap::>::new(); + for metadata in metadata_by_id.values() { + let Some(parent_session_id) = subagent_parent_session_id(metadata) else { + continue; + }; + if metadata_by_id.contains_key(&parent_session_id) { + children_by_parent + .entry(parent_session_id) + .or_default() + .push(metadata.session_id.clone()); + } + } + for child_session_ids in children_by_parent.values_mut() { + child_session_ids.sort_by(|left, right| { + let left_metadata = metadata_by_id + .get(left) + .expect("lineage child metadata should exist"); + let right_metadata = metadata_by_id + .get(right) + .expect("lineage child metadata should exist"); + left_metadata + .created_at + .cmp(&right_metadata.created_at) + .then_with(|| left.cmp(right)) + }); + } + + let mut visited = HashSet::new(); + let mut ordered_session_ids = Vec::new(); + collect_subagent_pre_order( + &root_session_id, + &children_by_parent, + &mut visited, + &mut ordered_session_ids, + ); + + Some(SessionLineageSnapshot { + root_session_id, + sessions: ordered_session_ids + .into_iter() + .filter_map(|session_id| metadata_by_id.get(&session_id).cloned()) + .collect(), + }) +} + +fn subagent_parent_session_id(metadata: &SessionMetadata) -> Option { + let relationship = metadata.relationship.as_ref(); + let custom_metadata = metadata.custom_metadata.as_ref(); + let kind = relationship + .and_then(|value| value.kind.clone()) + .or_else(|| { + custom_metadata + .and_then(|value| value.get("kind")) + .and_then(|value| value.as_str()) + .and_then(|value| match value { + "subagent" => Some(SessionRelationshipKind::Subagent), + _ => None, + }) + })?; + if kind != SessionRelationshipKind::Subagent { + return None; + } + + relationship + .and_then(|value| value.parent_session_id.as_deref()) + .or_else(|| { + custom_metadata + .and_then(|value| value.get("parentSessionId")) + .and_then(|value| value.as_str()) + }) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn collect_subagent_pre_order( + session_id: &str, + child_session_ids_by_parent: &HashMap>, + visited: &mut HashSet, + ordered_session_ids: &mut Vec, +) { + if !visited.insert(session_id.to_string()) { + return; + } + + ordered_session_ids.push(session_id.to_string()); + if let Some(child_session_ids) = child_session_ids_by_parent.get(session_id) { + for child_session_id in child_session_ids { + collect_subagent_pre_order( + child_session_id, + child_session_ids_by_parent, + visited, + ordered_session_ids, + ); + } + } +} + fn collect_subagent_post_order( session_id: &str, child_session_ids_by_parent: &HashMap>, @@ -573,6 +713,87 @@ mod tests { ); } + #[test] + fn session_lineage_snapshot_resolves_root_and_orders_descendants() { + let root = metadata("root"); + let mut later_child = metadata("child-b"); + later_child.created_at = 30; + later_child.relationship = Some(SessionRelationship { + kind: Some(SessionRelationshipKind::Subagent), + parent_session_id: Some("root".to_string()), + ..Default::default() + }); + let mut earlier_child = metadata("child-a"); + earlier_child.created_at = 20; + earlier_child.relationship = Some(SessionRelationship { + kind: Some(SessionRelationshipKind::Subagent), + parent_session_id: Some("root".to_string()), + ..Default::default() + }); + let mut grandchild = metadata("grandchild"); + grandchild.created_at = 40; + grandchild.relationship = Some(SessionRelationship { + kind: Some(SessionRelationshipKind::Subagent), + parent_session_id: Some("child-a".to_string()), + ..Default::default() + }); + + let snapshot = build_session_lineage_snapshot( + vec![later_child, grandchild, root, earlier_child], + "grandchild", + ) + .expect("lineage should exist"); + + assert_eq!(snapshot.root_session_id, "root"); + assert_eq!( + snapshot + .sessions + .iter() + .map(|metadata| metadata.session_id.as_str()) + .collect::>(), + vec!["root", "child-a", "grandchild", "child-b"] + ); + } + + #[test] + fn session_lineage_snapshot_keeps_non_subagent_children_out() { + let root = metadata("root"); + let mut review = metadata("review"); + review.relationship = Some(SessionRelationship { + kind: Some(SessionRelationshipKind::Review), + parent_session_id: Some("root".to_string()), + ..Default::default() + }); + + let snapshot = + build_session_lineage_snapshot(vec![root, review], "root").expect("root should exist"); + + assert_eq!(snapshot.sessions.len(), 1); + assert_eq!(snapshot.sessions[0].session_id, "root"); + } + + #[test] + fn session_lineage_snapshot_tolerates_cycles() { + let mut first = metadata("first"); + first.relationship = Some(SessionRelationship { + kind: Some(SessionRelationshipKind::Subagent), + parent_session_id: Some("second".to_string()), + ..Default::default() + }); + let mut second = metadata("second"); + second.relationship = Some(SessionRelationship { + kind: Some(SessionRelationshipKind::Subagent), + parent_session_id: Some("first".to_string()), + ..Default::default() + }); + + let snapshot = build_session_lineage_snapshot(vec![first, second], "first") + .expect("cyclic lineage should remain inspectable"); + + assert_eq!(snapshot.root_session_id, "first"); + assert_eq!(snapshot.sessions.len(), 2); + } + #[test] fn build_branched_session_metadata_resets_child_state_and_counts_turns() { let mut source = metadata("source"); diff --git a/src/crates/services/services-core/src/session/mod.rs b/src/crates/services/services-core/src/session/mod.rs index 1ac22c4b14..bb3fb95103 100644 --- a/src/crates/services/services-core/src/session/mod.rs +++ b/src/crates/services/services-core/src/session/mod.rs @@ -12,9 +12,10 @@ mod write_lock; pub use bitfun_core_types::SessionKind; pub use layout::SessionStorageLayout; pub use lineage::{ - apply_session_lineage, build_branched_session_metadata, collect_hidden_subagent_cascade, - format_branch_session_name, resolve_branch_session_lineage, BranchSessionLineage, - BranchSessionMetadataFacts, SessionBranchBoundary, SessionBranchRequest, SessionBranchResult, + apply_session_lineage, build_branched_session_metadata, build_session_lineage_snapshot, + collect_hidden_subagent_cascade, format_branch_session_name, resolve_branch_session_lineage, + BranchSessionLineage, BranchSessionMetadataFacts, SessionBranchBoundary, SessionBranchRequest, + SessionBranchResult, SessionLineageSnapshot, }; #[cfg(feature = "session-git")] pub use memory_workspace::{ diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss index 991198219a..281ce4eb93 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss @@ -55,13 +55,13 @@ } } - &__background-activity-nav { + &__background-command-nav { position: relative; display: flex; align-items: center; } - &__background-activity-nav-button { + &__background-command-nav-button { flex: 0 0 auto; &:not(:disabled):hover, @@ -69,25 +69,25 @@ background: color-mix(in srgb, var(--element-bg-soft) 82%, transparent); } - &--has-activity { + &--has-commands { color: color-mix(in srgb, var(--color-success) 86%, var(--color-text-primary)); &:not(:disabled):hover, - &.flowchat-header__background-activity-nav-button--active { + &.flowchat-header__background-command-nav-button--active { color: var(--color-success); background: color-mix(in srgb, var(--color-success) 12%, transparent); } } } - &__background-activity-nav-button-inner { + &__background-command-nav-button-inner { position: relative; display: inline-flex; align-items: center; justify-content: center; } - &__background-activity-status-dot { + &__background-command-status-dot { position: absolute; right: -2px; bottom: -1px; @@ -98,10 +98,10 @@ box-shadow: 0 0 0 1px var(--color-bg-elevated), 0 0 0 0 color-mix(in srgb, var(--color-success) 42%, transparent); - animation: flowchat-header-background-activity-pulse 1.45s ease-out infinite; + animation: flowchat-header-background-command-pulse 1.45s ease-out infinite; } - @keyframes flowchat-header-background-activity-pulse { + @keyframes flowchat-header-background-command-pulse { 0%, 100% { transform: scale(1); @@ -121,7 +121,7 @@ } @media (prefers-reduced-motion: reduce) { - &__background-activity-status-dot { + &__background-command-status-dot { animation: none; } } @@ -224,7 +224,7 @@ } } - &__background-activity-panel { + &__background-command-panel { position: absolute; top: calc(100% + 8px); right: 0; @@ -242,7 +242,7 @@ z-index: 30; } - &__background-activity-panel-header { + &__background-command-panel-header { display: flex; align-items: center; justify-content: space-between; @@ -253,69 +253,23 @@ color: var(--color-text-secondary); } - &__background-activity-list { - display: flex; - flex-direction: column; - overflow-y: auto; - padding: $size-gap-1; - max-height: calc(6 * 48px); - scrollbar-gutter: stable; - } - - &__background-section { - display: flex; - flex-direction: column; - gap: 2px; - - & + & { - margin-top: $size-gap-1; - padding-top: $size-gap-1; - border-top: 1px solid var(--border-base); - } - } - - &__background-section-title { + &__background-command-panel-header-actions { display: flex; align-items: center; - justify-content: space-between; gap: $size-gap-1; - padding: 2px 2px 2px $size-gap-2; - color: var(--color-text-muted); - font-size: var(--flowchat-font-size-xs); - } - - &__background-section-title-label { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - &__background-section-actions { - position: relative; flex: 0 0 auto; } - &__background-section-action { - flex: 0 0 auto; - color: var(--color-text-secondary); - - &:not(:disabled):hover { - color: var(--color-text-primary); - background: color-mix(in srgb, var(--element-bg-soft) 88%, transparent); - } - - &--danger:not(:disabled):hover { - color: var(--color-error); - background: color-mix(in srgb, var(--color-error) 12%, transparent); - - svg { - color: var(--color-error); - } - } + &__background-command-list { + display: flex; + flex-direction: column; + overflow-y: auto; + padding: $size-gap-1; + max-height: calc(6 * 48px); + scrollbar-gutter: stable; } - &__background-activity-list-item { + &__background-command-list-item-button { display: flex; flex-direction: column; align-items: flex-start; @@ -348,7 +302,7 @@ } } - &__background-command-list-item &__background-activity-list-item { + &__background-command-list-item &__background-command-list-item-button { &:hover { background: transparent; } @@ -454,7 +408,7 @@ } } - &__background-activity-list-title { + &__background-command-list-title { display: flex; align-items: center; gap: $size-gap-1; @@ -478,7 +432,7 @@ } } - &__background-activity-list-meta { + &__background-command-list-meta { width: 100%; font-size: var(--flowchat-font-size-xs); color: var(--color-text-secondary); diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx index 1afd65f524..de2a3ce361 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx @@ -43,6 +43,7 @@ vi.mock('@/component-library', async () => { Input: ReactModule.forwardRef>((props, ref) => ( )), + DotMatrixLoader: () => , }; }); @@ -60,6 +61,14 @@ vi.mock('./SessionFilesBadge', () => ({ SessionFilesBadge: () =>
, })); +vi.mock('./SessionTreePopover', () => ({ + SessionTreePopover: () => ( +
+
+ ), +})); + function createProps(overrides: Partial = {}): FlowChatHeaderProps { return { currentTurn: 1, @@ -135,8 +144,22 @@ describe('FlowChatHeader', () => { expect(container.querySelector('[data-testid="flowchat-header-turn-next"]')).toBeNull(); }); - it('renders background activity menus in a portal outside the scrollable panel', () => { + it('places the Agent tree entry immediately before background commands', () => { + act(() => { + root.render(); + }); + + const treeButton = container.querySelector('[data-testid="flowchat-header-session-tree"]'); + const commandButton = container.querySelector('[data-testid="flowchat-header-background-commands"]'); + const treeContainer = treeButton?.closest('.session-tree-popover'); + const commandContainer = commandButton?.closest('.flowchat-header__background-command-nav'); + + expect(treeContainer?.nextElementSibling).toBe(commandContainer); + }); + + it('renders background command menus in a portal outside the scrollable panel', () => { const onStopBackgroundCommand = vi.fn(); + const onStopAllBackgroundCommands = vi.fn(); act(() => { root.render( @@ -150,22 +173,25 @@ describe('FlowChatHeader', () => { status: 'running', }], onStopBackgroundCommand, + onStopAllBackgroundCommands, })} />, ); }); - const activityButton = container.querySelector( - '[data-testid="flowchat-header-background-activities"]', + const commandButton = container.querySelector( + '[data-testid="flowchat-header-background-commands"]', ); act(() => { - activityButton?.click(); + commandButton?.click(); }); - const panel = container.querySelector('.flowchat-header__background-activity-panel'); + const panel = container.querySelector('.flowchat-header__background-command-panel'); const menuButton = panel?.querySelector( - '[aria-label="flowChatHeader.backgroundCommandActions"]', + '.flowchat-header__background-command-panel-header-actions [aria-label="flowChatHeader.backgroundCommandActions"]', ); + expect(panel?.querySelector('.flowchat-header__background-section-title')).toBeNull(); + expect(menuButton?.closest('.flowchat-header__background-command-panel-header')).not.toBeNull(); act(() => { menuButton?.click(); }); @@ -177,13 +203,13 @@ describe('FlowChatHeader', () => { act(() => { menu?.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); }); - expect(container.querySelector('.flowchat-header__background-activity-panel')).not.toBeNull(); + expect(container.querySelector('.flowchat-header__background-command-panel')).not.toBeNull(); const stopButton = menu?.querySelector('[role="menuitem"]'); act(() => { stopButton?.click(); }); - expect(onStopBackgroundCommand).toHaveBeenCalledTimes(1); + expect(onStopAllBackgroundCommands).toHaveBeenCalledTimes(1); }); }); diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx index 00c76b6acd..d5554d0c95 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx @@ -6,23 +6,16 @@ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from 'react'; import { createPortal } from 'react-dom'; -import { Activity, Bot, ChevronDown, ChevronUp, GitPullRequest, Keyboard, MoreHorizontal, Search, Square, Terminal, X } from 'lucide-react'; +import { ChevronDown, ChevronUp, GitPullRequest, Keyboard, MoreHorizontal, Search, Square, SquareTerminal, Terminal, X } from 'lucide-react'; import { Tooltip, IconButton, Input } from '@/component-library'; import { useTranslation } from 'react-i18next'; import { SessionFilesBadge } from './SessionFilesBadge'; +import { SessionTreePopover, type SessionTreeSelection } from './SessionTreePopover'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; import { computeFixedPopoverPosition } from '@/shared/utils/fixedPopoverViewport'; import { createReviewPlatformTab } from '@/shared/utils/tabUtils'; import './FlowChatHeader.scss'; -export interface FlowChatHeaderSubagentSummary { - sessionId: string; - title: string; - agentType?: string; - status: 'processing' | 'finishing'; - isStopping?: boolean; -} - export interface FlowChatHeaderCommandSummary { execSessionKey: string; execSessionId: number; @@ -65,16 +58,14 @@ export interface FlowChatHeaderProps { onSearchClose?: () => void; /** Increments each time the parent requests to open the search bar. */ searchOpenRequest?: number; - /** Running background subagents launched by the active parent session. */ - backgroundSubagents?: FlowChatHeaderSubagentSummary[]; + /** Open a Session from the active Agent tree. */ + onOpenSessionTreeSession?: (selection: SessionTreeSelection) => void; + /** Whether the active Agent tree contains running descendants. */ + hasActiveSessionTreeDescendants?: boolean; + /** Cancel one running session from the active Agent tree without cancelling descendants. */ + onCancelSessionTreeSession?: (selection: SessionTreeSelection) => Promise; /** Long-running background commands launched by the active parent session. */ backgroundCommands?: FlowChatHeaderCommandSummary[]; - /** Open a background subagent in the right-side panel. */ - onOpenBackgroundSubagent?: (sessionId: string) => void; - /** Stop a running background subagent. */ - onStopBackgroundSubagent?: (subagent: FlowChatHeaderSubagentSummary) => void; - /** Stop all running background subagents. */ - onStopAllBackgroundSubagents?: () => void; /** Open a read-only output panel for a background command. */ onOpenBackgroundCommandOutput?: (command: FlowChatHeaderCommandSummary) => void; /** Request user-provided stdin for an interactive background command. */ @@ -99,11 +90,10 @@ export const FlowChatHeader: React.FC = ({ onSearchPrev, onSearchClose, searchOpenRequest = 0, - backgroundSubagents = [], + onOpenSessionTreeSession, + hasActiveSessionTreeDescendants = false, + onCancelSessionTreeSession, backgroundCommands = [], - onOpenBackgroundSubagent, - onStopBackgroundSubagent, - onStopAllBackgroundSubagents, onOpenBackgroundCommandOutput, onRequestBackgroundCommandInput, onStopBackgroundCommand, @@ -111,19 +101,18 @@ export const FlowChatHeader: React.FC = ({ }) => { const { t } = useTranslation('flow-chat'); const { currentWorkspace } = useWorkspaceContext(); - const [isBackgroundActivityPanelOpen, setIsBackgroundActivityPanelOpen] = useState(false); - const [openBackgroundSectionMenuId, setOpenBackgroundSectionMenuId] = useState<'subagents' | 'commands' | null>(null); - const [openBackgroundSubagentMenuId, setOpenBackgroundSubagentMenuId] = useState(null); + const [isBackgroundCommandPanelOpen, setIsBackgroundCommandPanelOpen] = useState(false); + const [isBackgroundCommandSectionMenuOpen, setIsBackgroundCommandSectionMenuOpen] = useState(false); const [openBackgroundCommandMenuId, setOpenBackgroundCommandMenuId] = useState(null); const [isSearchOpen, setIsSearchOpen] = useState(false); const headerRef = useRef(null); const leftActionsRef = useRef(null); const rightActionsRef = useRef(null); - const backgroundActivityPanelRef = useRef(null); - const backgroundActivityMenuAnchorRef = useRef(null); - const backgroundActivityMenuRef = useRef(null); + const backgroundCommandPanelRef = useRef(null); + const backgroundCommandMenuAnchorRef = useRef(null); + const backgroundCommandMenuRef = useRef(null); const searchInputRef = useRef(null); - const [backgroundActivityMenuPosition, setBackgroundActivityMenuPosition] = useState<{ + const [backgroundCommandMenuPosition, setBackgroundCommandMenuPosition] = useState<{ top: number; left: number; } | null>(null); @@ -135,16 +124,8 @@ export const FlowChatHeader: React.FC = ({ const turnBadgeLabel = t('flowChatHeader.turnBadge', { current: currentTurn }); - const hasBackgroundSubagents = backgroundSubagents.length > 0; const hasBackgroundCommands = backgroundCommands.length > 0; - const hasBackgroundActivities = hasBackgroundSubagents || hasBackgroundCommands; - const backgroundActivityCount = backgroundSubagents.length + backgroundCommands.length; - const displayBackgroundSubagents = useMemo(() => ( - backgroundSubagents.map((subagent) => ({ - ...subagent, - title: subagent.title.trim() || t('flowChatHeader.backgroundSubagentUntitled'), - })) - ), [backgroundSubagents, t]); + const backgroundCommandCount = backgroundCommands.length; const displayBackgroundCommands = useMemo(() => ( backgroundCommands.map((command) => ({ ...command, @@ -152,16 +133,15 @@ export const FlowChatHeader: React.FC = ({ })) ), [backgroundCommands, t]); const hasNoResults = searchQuery.trim().length > 0 && searchMatchCount === 0; - const hasOpenBackgroundActivityMenu = - openBackgroundSectionMenuId !== null || - openBackgroundSubagentMenuId !== null || + const hasOpenBackgroundCommandMenu = + isBackgroundCommandSectionMenuOpen || openBackgroundCommandMenuId !== null; - const updateBackgroundActivityMenuPosition = useCallback(() => { - const anchor = backgroundActivityMenuAnchorRef.current; + const updateBackgroundCommandMenuPosition = useCallback(() => { + const anchor = backgroundCommandMenuAnchorRef.current; if (!anchor) return; - const menu = backgroundActivityMenuRef.current; + const menu = backgroundCommandMenuRef.current; const { top, left } = computeFixedPopoverPosition( anchor.getBoundingClientRect(), menu?.offsetWidth ?? 200, @@ -169,35 +149,33 @@ export const FlowChatHeader: React.FC = ({ 4, 8, ); - setBackgroundActivityMenuPosition({ top, left }); + setBackgroundCommandMenuPosition({ top, left }); }, []); - const prepareBackgroundActivityMenu = useCallback((anchor: HTMLButtonElement) => { - backgroundActivityMenuAnchorRef.current = anchor; - updateBackgroundActivityMenuPosition(); - }, [updateBackgroundActivityMenuPosition]); + const prepareBackgroundCommandMenu = useCallback((anchor: HTMLButtonElement) => { + backgroundCommandMenuAnchorRef.current = anchor; + updateBackgroundCommandMenuPosition(); + }, [updateBackgroundCommandMenuPosition]); useEffect(() => { - if (!isBackgroundActivityPanelOpen) return; + if (!isBackgroundCommandPanelOpen) return; const handlePointerDown = (event: MouseEvent) => { const target = event.target as Node; if ( - !backgroundActivityPanelRef.current?.contains(target) && - !backgroundActivityMenuRef.current?.contains(target) + !backgroundCommandPanelRef.current?.contains(target) && + !backgroundCommandMenuRef.current?.contains(target) ) { - setIsBackgroundActivityPanelOpen(false); - setOpenBackgroundSectionMenuId(null); - setOpenBackgroundSubagentMenuId(null); + setIsBackgroundCommandPanelOpen(false); + setIsBackgroundCommandSectionMenuOpen(false); setOpenBackgroundCommandMenuId(null); } }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { - setIsBackgroundActivityPanelOpen(false); - setOpenBackgroundSectionMenuId(null); - setOpenBackgroundSubagentMenuId(null); + setIsBackgroundCommandPanelOpen(false); + setIsBackgroundCommandSectionMenuOpen(false); setOpenBackgroundCommandMenuId(null); } }; @@ -209,28 +187,27 @@ export const FlowChatHeader: React.FC = ({ document.removeEventListener('mousedown', handlePointerDown); document.removeEventListener('keydown', handleKeyDown); }; - }, [isBackgroundActivityPanelOpen]); + }, [isBackgroundCommandPanelOpen]); useLayoutEffect(() => { - if (!hasOpenBackgroundActivityMenu) { - setBackgroundActivityMenuPosition(null); + if (!hasOpenBackgroundCommandMenu) { + setBackgroundCommandMenuPosition(null); return; } - updateBackgroundActivityMenuPosition(); - window.addEventListener('resize', updateBackgroundActivityMenuPosition); - window.addEventListener('scroll', updateBackgroundActivityMenuPosition, true); + updateBackgroundCommandMenuPosition(); + window.addEventListener('resize', updateBackgroundCommandMenuPosition); + window.addEventListener('scroll', updateBackgroundCommandMenuPosition, true); return () => { - window.removeEventListener('resize', updateBackgroundActivityMenuPosition); - window.removeEventListener('scroll', updateBackgroundActivityMenuPosition, true); + window.removeEventListener('resize', updateBackgroundCommandMenuPosition); + window.removeEventListener('scroll', updateBackgroundCommandMenuPosition, true); }; }, [ - hasOpenBackgroundActivityMenu, + hasOpenBackgroundCommandMenu, + isBackgroundCommandSectionMenuOpen, openBackgroundCommandMenuId, - openBackgroundSectionMenuId, - openBackgroundSubagentMenuId, - updateBackgroundActivityMenuPosition, + updateBackgroundCommandMenuPosition, ]); const prevSearchOpenRequestRef = useRef(0); @@ -242,10 +219,13 @@ export const FlowChatHeader: React.FC = ({ }, [searchOpenRequest]); useEffect(() => { - if (!hasBackgroundActivities) { - setIsBackgroundActivityPanelOpen(false); + if (!hasBackgroundCommands) { + setIsBackgroundCommandPanelOpen(false); + setIsBackgroundCommandSectionMenuOpen(false); + setOpenBackgroundCommandMenuId(null); + setBackgroundCommandMenuPosition(null); } - }, [hasBackgroundActivities]); + }, [hasBackgroundCommands]); useEffect(() => { if (!isSearchOpen) return; @@ -317,86 +297,32 @@ export const FlowChatHeader: React.FC = ({ [handleCloseSearch, onSearchNext, onSearchPrev], ); - const handleToggleBackgroundActivityPanel = () => { - if (!hasBackgroundActivities) return; - setOpenBackgroundSectionMenuId(null); - setOpenBackgroundSubagentMenuId(null); + const handleToggleBackgroundCommandPanel = () => { + if (!hasBackgroundCommands) return; + setIsBackgroundCommandSectionMenuOpen(false); setOpenBackgroundCommandMenuId(null); - setIsBackgroundActivityPanelOpen(prev => !prev); + setIsBackgroundCommandPanelOpen(prev => !prev); }; const handleOpenPullRequests = useCallback(() => { createReviewPlatformTab(currentWorkspace?.rootPath); }, [currentWorkspace?.rootPath]); - const handleSubagentSelect = (sessionId: string) => { - onOpenBackgroundSubagent?.(sessionId); - setIsBackgroundActivityPanelOpen(false); - }; - - const handleSubagentMenuToggle = ( - event: React.MouseEvent, - subagent: FlowChatHeaderSubagentSummary, - ) => { - event.preventDefault(); - event.stopPropagation(); - if (openBackgroundSubagentMenuId === subagent.sessionId) { - setBackgroundActivityMenuPosition(null); - } else { - prepareBackgroundActivityMenu(event.currentTarget); - } - setOpenBackgroundSectionMenuId(null); - setOpenBackgroundCommandMenuId(null); - setOpenBackgroundSubagentMenuId(previous => previous === subagent.sessionId ? null : subagent.sessionId); - }; - - const handleSubagentStop = ( - event: React.MouseEvent, - subagent: FlowChatHeaderSubagentSummary, - ) => { - event.preventDefault(); - event.stopPropagation(); - onStopBackgroundSubagent?.(subagent); - setOpenBackgroundSubagentMenuId(null); - }; - - const handleStopAllSubagents = (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - onStopAllBackgroundSubagents?.(); - setOpenBackgroundSectionMenuId(null); - setOpenBackgroundSubagentMenuId(null); - }; - const handleCommandSectionMenuToggle = (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); - if (openBackgroundSectionMenuId === 'commands') { - setBackgroundActivityMenuPosition(null); + if (isBackgroundCommandSectionMenuOpen) { + setBackgroundCommandMenuPosition(null); } else { - prepareBackgroundActivityMenu(event.currentTarget); + prepareBackgroundCommandMenu(event.currentTarget); } - setOpenBackgroundSubagentMenuId(null); setOpenBackgroundCommandMenuId(null); - setOpenBackgroundSectionMenuId(previous => previous === 'commands' ? null : 'commands'); - }; - - const handleSubagentSectionMenuToggle = (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - if (openBackgroundSectionMenuId === 'subagents') { - setBackgroundActivityMenuPosition(null); - } else { - prepareBackgroundActivityMenu(event.currentTarget); - } - setOpenBackgroundSubagentMenuId(null); - setOpenBackgroundCommandMenuId(null); - setOpenBackgroundSectionMenuId(previous => previous === 'subagents' ? null : 'subagents'); + setIsBackgroundCommandSectionMenuOpen(open => !open); }; const handleCommandSelect = (command: FlowChatHeaderCommandSummary) => { onOpenBackgroundCommandOutput?.(command); - setIsBackgroundActivityPanelOpen(false); + setIsBackgroundCommandPanelOpen(false); }; const handleCommandMenuToggle = ( @@ -406,12 +332,11 @@ export const FlowChatHeader: React.FC = ({ event.preventDefault(); event.stopPropagation(); if (openBackgroundCommandMenuId === command.execSessionKey) { - setBackgroundActivityMenuPosition(null); + setBackgroundCommandMenuPosition(null); } else { - prepareBackgroundActivityMenu(event.currentTarget); + prepareBackgroundCommandMenu(event.currentTarget); } - setOpenBackgroundSectionMenuId(null); - setOpenBackgroundSubagentMenuId(null); + setIsBackgroundCommandSectionMenuOpen(false); setOpenBackgroundCommandMenuId(previous => previous === command.execSessionKey ? null : command.execSessionKey); }; @@ -423,7 +348,7 @@ export const FlowChatHeader: React.FC = ({ event.stopPropagation(); onRequestBackgroundCommandInput?.(command); setOpenBackgroundCommandMenuId(null); - setIsBackgroundActivityPanelOpen(false); + setIsBackgroundCommandPanelOpen(false); }; const handleCommandStop = ( @@ -440,56 +365,7 @@ export const FlowChatHeader: React.FC = ({ event.preventDefault(); event.stopPropagation(); onStopAllBackgroundCommands?.(); - setOpenBackgroundSectionMenuId(null); - }; - - const renderBackgroundSubagentActions = (subagent: FlowChatHeaderSubagentSummary) => { - if (!onStopBackgroundSubagent) { - return null; - } - - return ( -
- handleSubagentMenuToggle(event, subagent)} - tooltip={t('flowChatHeader.backgroundSubagentActions')} - aria-label={t('flowChatHeader.backgroundSubagentActions')} - aria-haspopup="menu" - aria-expanded={openBackgroundSubagentMenuId === subagent.sessionId} - > - - {openBackgroundSubagentMenuId === subagent.sessionId && backgroundActivityMenuPosition ? createPortal( -
- -
, - document.body, - ) : null} -
- ); + setIsBackgroundCommandSectionMenuOpen(false); }; const renderBackgroundCommandActions = (command: FlowChatHeaderCommandSummary) => { @@ -519,13 +395,13 @@ export const FlowChatHeader: React.FC = ({ >