From 84c9fba5f2169c5bc85cf239cae464a6a1b034a4 Mon Sep 17 00:00:00 2001 From: wsp Date: Sun, 2 Aug 2026 23:32:54 +0800 Subject: [PATCH 1/2] feat(flow-chat): add recursive agent session tree - expose persisted session lineage through desktop and remote session APIs - merge lineage snapshots with live state in a recursive Flow Chat tree - add localized tree controls, lifecycle states, and focused coverage --- .../src/api/remote_workspace_policy.rs | 2 + src/apps/desktop/src/api/session_api.rs | 38 ++- src/apps/desktop/src/lib.rs | 1 + .../src/runtime/session_application.rs | 17 +- .../core/src/agentic/persistence/mod.rs | 2 +- .../assembly/core/src/product_runtime.rs | 16 +- .../services-core/src/session/lineage.rs | 221 +++++++++++++ .../services/services-core/src/session/mod.rs | 7 +- .../components/modern/FlowChatHeader.test.tsx | 22 ++ .../components/modern/FlowChatHeader.tsx | 10 + .../modern/ModernFlowChatContainer.tsx | 27 ++ .../components/modern/SessionTreePopover.scss | 176 ++++++++++ .../components/modern/SessionTreePopover.tsx | 313 ++++++++++++++++++ .../flow_chat/utils/sessionLineage.test.ts | 96 ++++++ .../src/flow_chat/utils/sessionLineage.ts | 196 +++++++++++ .../api/adapters/peer-device-adapter.ts | 2 + .../api/service-api/SessionAPI.test.ts | 21 ++ .../api/service-api/SessionAPI.ts | 31 ++ src/web-ui/src/locales/en-US/flow-chat.json | 16 + src/web-ui/src/locales/zh-CN/flow-chat.json | 16 + src/web-ui/src/locales/zh-TW/flow-chat.json | 16 + 21 files changed, 1239 insertions(+), 7 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/modern/SessionTreePopover.scss create mode 100644 src/web-ui/src/flow_chat/components/modern/SessionTreePopover.tsx create mode 100644 src/web-ui/src/flow_chat/utils/sessionLineage.test.ts create mode 100644 src/web-ui/src/flow_chat/utils/sessionLineage.ts diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 7025a448d0..59eb760c24 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", @@ -2257,6 +2258,7 @@ mod tests { "get_runtime_capabilities", "get_runtime_logging_info", "get_session_file_diff_stats", + "get_session_lineage", "get_session_files", "get_session_operations", "get_session_stats", 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/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.test.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx index 1afd65f524..6223fb41ee 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,6 +144,19 @@ describe('FlowChatHeader', () => { expect(container.querySelector('[data-testid="flowchat-header-turn-next"]')).toBeNull(); }); + it('places the Agent tree entry immediately before background activity', () => { + act(() => { + root.render(); + }); + + const treeButton = container.querySelector('[data-testid="flowchat-header-session-tree"]'); + const activityButton = container.querySelector('[data-testid="flowchat-header-background-activities"]'); + const treeContainer = treeButton?.closest('.session-tree-popover'); + const activityContainer = activityButton?.closest('.flowchat-header__background-activity-nav'); + + expect(treeContainer?.nextElementSibling).toBe(activityContainer); + }); + it('renders background activity menus in a portal outside the scrollable panel', () => { const onStopBackgroundCommand = vi.fn(); 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..f8c915af8e 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx @@ -10,6 +10,7 @@ import { Activity, Bot, ChevronDown, ChevronUp, GitPullRequest, Keyboard, MoreHo 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'; @@ -67,6 +68,8 @@ export interface FlowChatHeaderProps { 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; /** Long-running background commands launched by the active parent session. */ backgroundCommands?: FlowChatHeaderCommandSummary[]; /** Open a background subagent in the right-side panel. */ @@ -100,6 +103,7 @@ export const FlowChatHeader: React.FC = ({ onSearchClose, searchOpenRequest = 0, backgroundSubagents = [], + onOpenSessionTreeSession, backgroundCommands = [], onOpenBackgroundSubagent, onStopBackgroundSubagent, @@ -605,6 +609,12 @@ export const FlowChatHeader: React.FC = ({
+
= ( }); }, [activeSession, backgroundSubagents]); + const handleOpenSessionTreeSession = useCallback((selection: SessionTreeSelection) => { + if ( + !activeSession?.sessionId || + selection.isRoot || + selection.sessionId === activeSession.sessionId || + !selection.parentSessionId + ) { + return; + } + + openBtwSessionInAuxPane({ + childSessionId: selection.sessionId, + parentSessionId: selection.parentSessionId, + workspacePath: selection.workspacePath || activeSession.workspacePath, + sessionKind: 'subagent', + sessionTitle: selection.title, + agentType: selection.agentType, + parentToolCallId: selection.parentToolCallId, + subagentType: selection.subagentType, + remoteConnectionId: selection.remoteConnectionId || activeSession.remoteConnectionId, + remoteSshHost: selection.remoteSshHost || activeSession.remoteSshHost, + includeInternal: true, + }); + }, [activeSession]); + const handleStopBackgroundSubagent = useCallback(async (subagent: FlowChatHeaderSubagentSummary) => { if (stoppingBackgroundSubagentIds.has(subagent.sessionId)) { return; @@ -2376,6 +2402,7 @@ export const ModernFlowChatContainer: React.FC = ( searchOpenRequest={searchOpenRequest} backgroundSubagents={headerBackgroundSubagents} backgroundCommands={headerBackgroundCommands} + onOpenSessionTreeSession={handleOpenSessionTreeSession} onOpenBackgroundSubagent={handleOpenBackgroundSubagent} onStopBackgroundSubagent={handleStopBackgroundSubagent} onStopAllBackgroundSubagents={handleStopAllBackgroundSubagents} diff --git a/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.scss b/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.scss new file mode 100644 index 0000000000..8d22b9a635 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.scss @@ -0,0 +1,176 @@ +@use '../../../component-library/styles/tokens' as *; + +.session-tree-popover { + position: relative; + display: flex; + align-items: center; + + &__trigger { + flex: 0 0 auto; + + &:not(:disabled):hover, + &--active { + background: color-mix(in srgb, var(--element-bg-soft) 82%, transparent); + } + } + + &__panel { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: min(380px, calc(100vw - 32px)); + max-height: min(440px, calc(100vh - 96px)); + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--border-base); + border-radius: $size-radius-lg; + background: color-mix(in srgb, var(--color-bg-elevated) 94%, transparent); + box-shadow: var(--shadow-lg); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + z-index: 30; + } + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: $size-gap-2; + padding: $size-gap-2 $size-gap-3; + border-bottom: 1px solid var(--border-base); + color: var(--color-text-secondary); + font-size: var(--flowchat-font-size-xs); + } + + &__body { + min-height: 42px; + max-height: min(390px, calc(100vh - 144px)); + padding: $size-gap-1; + overflow-y: auto; + scrollbar-gutter: stable; + } + + &__node { + display: flex; + align-items: center; + min-height: 34px; + border-radius: $size-radius-base; + + &:hover { + background: color-mix(in srgb, var(--element-bg-soft) 88%, transparent); + } + + &--root { + font-weight: 500; + } + } + + &__expand, + &__expand-spacer { + width: 20px; + height: 28px; + flex: 0 0 20px; + } + + &__expand { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + + &:hover { + color: var(--color-text-primary); + } + } + + &__node-main { + min-width: 0; + min-height: 32px; + flex: 1; + display: flex; + align-items: center; + gap: $size-gap-2; + padding: 3px $size-gap-2 3px 0; + border: 0; + background: transparent; + color: var(--color-text-primary); + text-align: left; + cursor: pointer; + + > svg { + flex: 0 0 auto; + color: var(--color-text-secondary); + } + } + + &__node-copy { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + gap: 1px; + } + + &__node-title, + &__node-meta { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__node-title { + font-size: var(--flowchat-font-size-sm); + } + + &__node-meta { + color: var(--color-text-muted); + font-size: var(--flowchat-font-size-xs); + font-weight: 400; + } + + &__status { + width: 7px; + height: 7px; + flex: 0 0 7px; + border-radius: 50%; + background: var(--color-text-muted); + + &--running, + &--finishing { + background: var(--color-success); + } + + &--waiting { + background: var(--color-warning); + } + + &--error, + &--cancelled { + background: var(--color-error); + } + + &--completed { + background: var(--color-info); + } + } + + &__state { + min-height: 44px; + display: flex; + align-items: center; + justify-content: center; + gap: $size-gap-2; + padding: $size-gap-3; + color: var(--color-text-muted); + font-size: var(--flowchat-font-size-xs); + + &--error { + color: var(--color-error); + } + } +} diff --git a/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.tsx b/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.tsx new file mode 100644 index 0000000000..b6f41def5c --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.tsx @@ -0,0 +1,313 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Bot, + ChevronDown, + ChevronRight, + ListTree, + MessageSquare, + RefreshCw, +} from 'lucide-react'; +import { DotMatrixLoader, IconButton } from '@/component-library'; +import { sessionAPI, type SessionLineageSnapshot } from '@/infrastructure/api/service-api/SessionAPI'; +import { flowChatStore } from '../../store/FlowChatStore'; +import { + buildSessionLineageTree, + collectExpandedRunningBranches, + countSessionLineageDescendants, + type SessionLineageLifecycle, + type SessionLineageNode, +} from '../../utils/sessionLineage'; +import './SessionTreePopover.scss'; + +export interface SessionTreeSelection { + sessionId: string; + parentSessionId?: string; + parentToolCallId?: string; + title: string; + agentType?: string; + subagentType?: string; + workspacePath?: string; + remoteConnectionId?: string; + remoteSshHost?: string; + isRoot: boolean; +} + +interface SessionTreePopoverProps { + sessionId?: string; + fallbackWorkspacePath?: string; + onSelectSession?: (selection: SessionTreeSelection) => void; + t: (key: string, options?: Record) => string; +} + +function lifecycleLabel( + lifecycle: SessionLineageLifecycle, + t: SessionTreePopoverProps['t'], +): string { + return t(`flowChatHeader.agentTreeStatus.${lifecycle}`); +} + +function nodeHasActiveWork(node: SessionLineageNode): boolean { + return node.lifecycle === 'running' || node.lifecycle === 'finishing'; +} + +export const SessionTreePopover: React.FC = ({ + sessionId, + fallbackWorkspacePath, + onSelectSession, + t, +}) => { + const [isOpen, setIsOpen] = useState(false); + const [snapshot, setSnapshot] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [loadFailed, setLoadFailed] = useState(false); + const [liveRevision, setLiveRevision] = useState(0); + const [expandedSessionIds, setExpandedSessionIds] = useState>(new Set()); + const containerRef = useRef(null); + const requestGenerationRef = useRef(0); + + const refreshSnapshot = useCallback(async () => { + if (!sessionId) return; + const requestGeneration = requestGenerationRef.current + 1; + requestGenerationRef.current = requestGeneration; + const session = flowChatStore.getState().sessions.get(sessionId); + const workspacePath = session?.workspacePath || fallbackWorkspacePath; + if (!workspacePath) { + if (requestGeneration === requestGenerationRef.current) setLoadFailed(true); + return; + } + + setIsLoading(true); + setLoadFailed(false); + try { + const nextSnapshot = await sessionAPI.getSessionLineage({ + sessionId, + workspacePath, + remoteConnectionId: session?.remoteConnectionId, + remoteSshHost: session?.remoteSshHost, + }); + if (requestGeneration === requestGenerationRef.current) { + setSnapshot(nextSnapshot); + } + } catch { + if (requestGeneration === requestGenerationRef.current) { + setLoadFailed(true); + } + } finally { + if (requestGeneration === requestGenerationRef.current) { + setIsLoading(false); + } + } + }, [fallbackWorkspacePath, sessionId]); + + useEffect(() => { + requestGenerationRef.current += 1; + setIsOpen(false); + setSnapshot(null); + setLoadFailed(false); + setExpandedSessionIds(new Set()); + }, [sessionId]); + + useEffect(() => { + if (!isOpen) return; + void refreshSnapshot(); + + let frameId: number | null = null; + const unsubscribe = flowChatStore.subscribe(() => { + if (frameId !== null) return; + frameId = requestAnimationFrame(() => { + frameId = null; + setLiveRevision(revision => revision + 1); + }); + }); + return () => { + unsubscribe(); + if (frameId !== null) cancelAnimationFrame(frameId); + }; + }, [isOpen, refreshSnapshot]); + + useEffect(() => { + if (!isOpen) return; + const handlePointerDown = (event: MouseEvent) => { + if (!containerRef.current?.contains(event.target as Node)) { + setIsOpen(false); + } + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setIsOpen(false); + }; + document.addEventListener('mousedown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isOpen]); + + const tree = useMemo(() => { + void liveRevision; + if (!sessionId) return null; + return buildSessionLineageTree( + sessionId, + snapshot, + flowChatStore.getState().sessions, + ); + }, [liveRevision, sessionId, snapshot]); + const descendantCount = countSessionLineageDescendants(tree); + + useEffect(() => { + if (!tree) return; + const defaults = collectExpandedRunningBranches(tree); + setExpandedSessionIds(previous => { + const next = new Set(previous); + defaults.forEach(sessionId => next.add(sessionId)); + return next.size === previous.size ? previous : next; + }); + }, [tree]); + + const toggleExpanded = useCallback((targetSessionId: string) => { + setExpandedSessionIds(previous => { + const next = new Set(previous); + if (next.has(targetSessionId)) next.delete(targetSessionId); + else next.add(targetSessionId); + return next; + }); + }, []); + + const handleSelect = useCallback((node: SessionLineageNode) => { + onSelectSession?.({ + sessionId: node.sessionId, + parentSessionId: node.parentSessionId, + parentToolCallId: node.parentToolCallId, + title: node.title, + agentType: node.agentType, + subagentType: node.subagentType, + workspacePath: node.workspacePath, + remoteConnectionId: node.remoteConnectionId, + remoteSshHost: node.remoteSshHost, + isRoot: node.isRoot, + }); + setIsOpen(false); + }, [onSelectSession]); + + const renderNode = (node: SessionLineageNode, depth: number): React.ReactNode => { + const hasChildren = node.children.length > 0; + const isExpanded = expandedSessionIds.has(node.sessionId); + const statusLabel = lifecycleLabel(node.lifecycle, t); + const secondaryLabel = node.subagentType || node.agentType; + + return ( + +
+ {hasChildren ? ( + + ) : ( +
+ {hasChildren && isExpanded ? node.children.map(child => renderNode(child, depth + 1)) : null} +
+ ); + }; + + const panelLabel = t('flowChatHeader.agentTree'); + + return ( +
+ setIsOpen(open => !open)} + tooltip={panelLabel} + aria-label={panelLabel} + aria-expanded={isOpen} + aria-haspopup="dialog" + disabled={!sessionId} + data-testid="flowchat-header-session-tree" + > + + + + {isOpen ? ( +
+
+ {panelLabel} + {descendantCount + (tree ? 1 : 0)} +
+
+ {tree ?
{renderNode(tree, 0)}
: null} + {isLoading && !tree ? ( +
+ + {t('flowChatHeader.agentTreeLoading')} +
+ ) : null} + {!isLoading && !loadFailed && tree && descendantCount === 0 ? ( +
+ {t('flowChatHeader.agentTreeEmpty')} +
+ ) : null} + {loadFailed ? ( +
+ {t('flowChatHeader.agentTreeLoadFailed')} + void refreshSnapshot()} + tooltip={t('flowChatHeader.agentTreeRetry')} + aria-label={t('flowChatHeader.agentTreeRetry')} + > + + +
+ ) : null} +
+
+ ) : null} +
+ ); +}; + +SessionTreePopover.displayName = 'SessionTreePopover'; diff --git a/src/web-ui/src/flow_chat/utils/sessionLineage.test.ts b/src/web-ui/src/flow_chat/utils/sessionLineage.test.ts new file mode 100644 index 0000000000..4ec300b4fb --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/sessionLineage.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import type { SessionLineageSnapshot } from '@/infrastructure/api/service-api/SessionAPI'; +import type { SessionMetadata } from '@/shared/types/session-history'; +import type { Session } from '../types/flow-chat'; +import { + buildSessionLineageTree, + collectExpandedRunningBranches, + countSessionLineageDescendants, +} from './sessionLineage'; + +function metadata( + sessionId: string, + parentSessionId?: string, + createdAt = 1, +): SessionMetadata { + return { + sessionId, + sessionName: sessionId, + agentType: parentSessionId ? 'Explore' : 'agentic', + modelName: 'model', + createdAt, + lastActiveAt: createdAt, + turnCount: 0, + messageCount: 0, + toolCallCount: 0, + status: parentSessionId ? 'completed' : 'active', + tags: [], + relationship: parentSessionId + ? { kind: 'subagent', parentSessionId, parentToolCallId: `tool-${sessionId}` } + : undefined, + }; +} + +function liveSession(sessionId: string, parentSessionId: string): Session { + return { + sessionId, + title: sessionId, + dialogTurns: [{ + id: `turn-${sessionId}`, + turnIndex: 0, + userMessage: { id: 'user', content: 'work', timestamp: 1 }, + modelRounds: [], + status: 'processing', + startTime: 1, + }], + status: 'idle', + config: {}, + createdAt: 3, + lastActiveAt: 3, + error: null, + parentSessionId, + sessionKind: 'subagent', + }; +} + +describe('sessionLineage', () => { + it('builds a stable recursive tree from persisted metadata', () => { + const snapshot: SessionLineageSnapshot = { + rootSessionId: 'root', + sessions: [ + metadata('root'), + metadata('child-b', 'root', 3), + metadata('child-a', 'root', 2), + metadata('grandchild', 'child-a', 4), + ], + }; + + const tree = buildSessionLineageTree('root', snapshot, new Map()); + + expect(tree?.children.map(node => node.sessionId)).toEqual(['child-a', 'child-b']); + expect(tree?.children[0].children[0].sessionId).toBe('grandchild'); + expect(countSessionLineageDescendants(tree)).toBe(3); + }); + + it('overlays live descendants and expands their ancestor branches', () => { + const snapshot: SessionLineageSnapshot = { + rootSessionId: 'root', + sessions: [metadata('root'), metadata('child', 'root', 2)], + }; + const live = liveSession('grandchild', 'child'); + + const tree = buildSessionLineageTree( + 'root', + snapshot, + new Map([[live.sessionId, live]]), + ); + + expect(tree?.children[0].children[0]).toMatchObject({ + sessionId: 'grandchild', + lifecycle: 'running', + }); + expect([...collectExpandedRunningBranches(tree)]).toEqual( + expect.arrayContaining(['root', 'child']), + ); + }); +}); diff --git a/src/web-ui/src/flow_chat/utils/sessionLineage.ts b/src/web-ui/src/flow_chat/utils/sessionLineage.ts new file mode 100644 index 0000000000..72a121fb4b --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/sessionLineage.ts @@ -0,0 +1,196 @@ +import type { SessionLineageSnapshot } from '@/infrastructure/api/service-api/SessionAPI'; +import type { SessionMetadata } from '@/shared/types/session-history'; +import type { Session } from '../types/flow-chat'; +import { deriveSessionRelationshipFromMetadata } from './sessionMetadata'; + +export type SessionLineageLifecycle = + | 'running' + | 'finishing' + | 'waiting' + | 'completed' + | 'cancelled' + | 'error' + | 'idle'; + +export interface SessionLineageNode { + sessionId: string; + parentSessionId?: string; + parentToolCallId?: string; + title: string; + agentType?: string; + subagentType?: string; + lifecycle: SessionLineageLifecycle; + createdAt: number; + workspacePath?: string; + remoteConnectionId?: string; + remoteSshHost?: string; + isRoot: boolean; + children: SessionLineageNode[]; +} + +type FlatSessionLineageNode = Omit; + +function metadataLifecycle(metadata: SessionMetadata): SessionLineageLifecycle { + if (metadata.needsUserAttention) return 'waiting'; + if (metadata.unreadCompletion === 'error' || metadata.unreadCompletion === 'interrupted') { + return 'error'; + } + return metadata.status === 'completed' ? 'completed' : 'idle'; +} + +function sessionLifecycle(session: Session): SessionLineageLifecycle { + if (session.needsUserAttention) return 'waiting'; + if (session.status === 'error' || session.hasUnreadCompletion === 'error') return 'error'; + + const latestTurn = session.dialogTurns[session.dialogTurns.length - 1]; + switch (latestTurn?.status) { + case 'pending': + case 'image_analyzing': + case 'processing': + return 'running'; + case 'finishing': + case 'cancelling': + return 'finishing'; + case 'cancelled': + return 'cancelled'; + case 'error': + return 'error'; + case 'completed': + return 'completed'; + default: + return session.persistedStatus === 'completed' ? 'completed' : 'idle'; + } +} + +function nodeFromMetadata(metadata: SessionMetadata): FlatSessionLineageNode { + const relationship = deriveSessionRelationshipFromMetadata(metadata); + return { + sessionId: metadata.sessionId, + parentSessionId: relationship.parentSessionId, + parentToolCallId: relationship.parentToolCallId, + title: metadata.sessionName, + agentType: metadata.agentType, + subagentType: relationship.subagentType, + lifecycle: metadataLifecycle(metadata), + createdAt: metadata.createdAt, + workspacePath: metadata.workspacePath, + remoteConnectionId: metadata.remoteConnectionId, + remoteSshHost: metadata.remoteSshHost, + isRoot: false, + }; +} + +function nodeFromSession(session: Session): FlatSessionLineageNode { + return { + sessionId: session.sessionId, + parentSessionId: session.parentSessionId, + parentToolCallId: session.parentToolCallId, + title: session.title?.trim() || session.subagentType || session.mode || 'Agent', + agentType: session.mode || session.config.agentType, + subagentType: session.subagentType, + lifecycle: sessionLifecycle(session), + createdAt: session.createdAt, + workspacePath: session.workspacePath, + remoteConnectionId: session.remoteConnectionId, + remoteSshHost: session.remoteSshHost, + isRoot: false, + }; +} + +function resolveRootSessionId( + nodes: Map, + anchorSessionId: string, + snapshotRootSessionId?: string, +): string | null { + if (snapshotRootSessionId && nodes.has(snapshotRootSessionId)) { + return snapshotRootSessionId; + } + if (!nodes.has(anchorSessionId)) { + return null; + } + + let currentSessionId = anchorSessionId; + const visited = new Set([currentSessionId]); + while (true) { + const parentSessionId = nodes.get(currentSessionId)?.parentSessionId; + if (!parentSessionId || !nodes.has(parentSessionId) || visited.has(parentSessionId)) { + return currentSessionId; + } + visited.add(parentSessionId); + currentSessionId = parentSessionId; + } +} + +export function buildSessionLineageTree( + anchorSessionId: string, + snapshot: SessionLineageSnapshot | null, + liveSessions: Map, +): SessionLineageNode | null { + const nodes = new Map(); + for (const metadata of snapshot?.sessions ?? []) { + nodes.set(metadata.sessionId, nodeFromMetadata(metadata)); + } + + for (const session of liveSessions.values()) { + if ( + session.sessionId === anchorSessionId || + session.sessionKind === 'subagent' || + nodes.has(session.sessionId) + ) { + nodes.set(session.sessionId, nodeFromSession(session)); + } + } + + const rootSessionId = resolveRootSessionId(nodes, anchorSessionId, snapshot?.rootSessionId); + if (!rootSessionId) return null; + + const childrenByParent = new Map(); + for (const node of nodes.values()) { + if (!node.parentSessionId || !nodes.has(node.parentSessionId)) continue; + const children = childrenByParent.get(node.parentSessionId) ?? []; + children.push(node); + childrenByParent.set(node.parentSessionId, children); + } + for (const children of childrenByParent.values()) { + children.sort((left, right) => + left.createdAt - right.createdAt || left.sessionId.localeCompare(right.sessionId) + ); + } + + const visited = new Set(); + const buildNode = (sessionId: string): SessionLineageNode | null => { + if (visited.has(sessionId)) return null; + const node = nodes.get(sessionId); + if (!node) return null; + visited.add(sessionId); + return { + ...node, + isRoot: sessionId === rootSessionId, + children: (childrenByParent.get(sessionId) ?? []) + .map(child => buildNode(child.sessionId)) + .filter((child): child is SessionLineageNode => child !== null), + }; + }; + + return buildNode(rootSessionId); +} + +export function countSessionLineageDescendants(root: SessionLineageNode | null): number { + if (!root) return 0; + return root.children.reduce( + (count, child) => count + 1 + countSessionLineageDescendants(child), + 0, + ); +} + +export function collectExpandedRunningBranches(root: SessionLineageNode | null): Set { + const expanded = new Set(); + const visit = (node: SessionLineageNode): boolean => { + const hasActiveDescendant = node.children.some(visit); + const isActive = node.lifecycle === 'running' || node.lifecycle === 'finishing'; + if (node.isRoot || hasActiveDescendant) expanded.add(node.sessionId); + return isActive || hasActiveDescendant; + }; + if (root) visit(root); + return expanded; +} diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts index ab96f27fb4..3493f7f899 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts @@ -126,6 +126,7 @@ const HIGH_PRIORITY_COMMANDS = new Set([ 'list_persisted_sessions', 'list_persisted_sessions_page', 'list_persisted_sessions_count', + 'get_session_lineage', 'get_session_thread_goal', 'touch_session_activity', 'create_session', @@ -174,6 +175,7 @@ const RETRYABLE_READ_COMMANDS = new Set([ 'list_persisted_sessions', 'list_persisted_sessions_page', 'list_persisted_sessions_count', + 'get_session_lineage', 'get_session_thread_goal', 'get_opened_workspaces', 'get_recent_workspaces', diff --git a/src/web-ui/src/infrastructure/api/service-api/SessionAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/SessionAPI.test.ts index 94877e622e..0704b63e44 100644 --- a/src/web-ui/src/infrastructure/api/service-api/SessionAPI.test.ts +++ b/src/web-ui/src/infrastructure/api/service-api/SessionAPI.test.ts @@ -121,6 +121,27 @@ describe('SessionAPI paged metadata reads', () => { }); }); + it('loads the scoped hidden Session lineage without listing all internal Sessions', async () => { + const snapshot = { rootSessionId: 'root', sessions: [] }; + invokeMock.mockResolvedValueOnce(snapshot); + + await expect(sessionAPI.getSessionLineage({ + sessionId: 'child', + workspacePath: '/repo', + remoteConnectionId: 'remote-1', + remoteSshHost: 'host', + })).resolves.toBe(snapshot); + + expect(invokeMock).toHaveBeenCalledWith('get_session_lineage', { + request: { + session_id: 'child', + workspace_path: '/repo', + remote_connection_id: 'remote-1', + remote_ssh_host: 'host', + }, + }); + }); + it('requests usage reports with explicit hidden subagent scope', async () => { const report = { reportId: 'usage-report-1', diff --git a/src/web-ui/src/infrastructure/api/service-api/SessionAPI.ts b/src/web-ui/src/infrastructure/api/service-api/SessionAPI.ts index b630914134..17cf376bef 100644 --- a/src/web-ui/src/infrastructure/api/service-api/SessionAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/SessionAPI.ts @@ -29,6 +29,18 @@ export interface SessionMetadataPage { hasMore: boolean; } +export interface SessionLineageRequest { + sessionId: string; + workspacePath: string; + remoteConnectionId?: string; + remoteSshHost?: string; +} + +export interface SessionLineageSnapshot { + rootSessionId: string; + sessions: SessionMetadata[]; +} + export interface SessionReferenceCandidate { sessionId: string; sessionName: string; @@ -282,6 +294,25 @@ export class SessionAPI { } } + async getSessionLineage( + request: SessionLineageRequest + ): Promise { + try { + return await api.invoke('get_session_lineage', { + request: { + session_id: request.sessionId, + workspace_path: request.workspacePath, + ...remoteSessionFields(request.remoteConnectionId, request.remoteSshHost), + } + }); + } catch (error) { + throw createTauriCommandError('get_session_lineage', error, { + sessionId: request.sessionId, + workspacePath: request.workspacePath, + }); + } + } + async loadSessionTurns( sessionId: string, workspacePath: string, diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 1cb1b8a404..b137d06c61 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -1236,6 +1236,22 @@ "searchClose": "Close search", "searchOpen": "Search messages", "jumpToCurrentTurn": "Jump to Turn {{turn}}", + "agentTree": "Agent tree", + "agentTreeLoading": "Loading Agent tree...", + "agentTreeEmpty": "No subagents in this session", + "agentTreeLoadFailed": "Unable to load the Agent tree.", + "agentTreeRetry": "Retry", + "agentTreeExpand": "Expand branch", + "agentTreeCollapse": "Collapse branch", + "agentTreeStatus": { + "running": "$t(shared:statuses.running)", + "finishing": "Finishing", + "waiting": "Waiting for input", + "completed": "$t(shared:statuses.done)", + "cancelled": "$t(shared:statuses.cancelled)", + "error": "$t(shared:statuses.failed)", + "idle": "Idle" + }, "backgroundActivities": "Background activity ({{count}})", "backgroundSubagentUntitled": "Background subagent", "backgroundSubagentSection": "Background subagents ({{count}})", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 59300c2681..00bffd13e6 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -1236,6 +1236,22 @@ "searchClose": "关闭搜索", "searchOpen": "搜索消息", "jumpToCurrentTurn": "跳转到第 {{turn}} 轮", + "agentTree": "Agent 会话树", + "agentTreeLoading": "正在加载 Agent 会话树...", + "agentTreeEmpty": "当前会话没有子 Agent", + "agentTreeLoadFailed": "无法加载 Agent 会话树。", + "agentTreeRetry": "重试", + "agentTreeExpand": "展开分支", + "agentTreeCollapse": "收起分支", + "agentTreeStatus": { + "running": "$t(shared:statuses.running)", + "finishing": "收尾中", + "waiting": "等待输入", + "completed": "$t(shared:statuses.done)", + "cancelled": "$t(shared:statuses.cancelled)", + "error": "$t(shared:statuses.failed)", + "idle": "空闲" + }, "backgroundActivities": "后台活动({{count}})", "backgroundSubagentUntitled": "后台子 Agent", "backgroundSubagentSection": "后台子 Agent({{count}})", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 9096abc952..effe908f1d 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -1236,6 +1236,22 @@ "searchClose": "關閉搜尋", "searchOpen": "搜尋消息", "jumpToCurrentTurn": "跳轉到第 {{turn}} 輪", + "agentTree": "Agent 會話樹", + "agentTreeLoading": "正在載入 Agent 會話樹...", + "agentTreeEmpty": "目前會話沒有子 Agent", + "agentTreeLoadFailed": "無法載入 Agent 會話樹。", + "agentTreeRetry": "重試", + "agentTreeExpand": "展開分支", + "agentTreeCollapse": "收起分支", + "agentTreeStatus": { + "running": "$t(shared:statuses.running)", + "finishing": "收尾中", + "waiting": "等待輸入", + "completed": "$t(shared:statuses.done)", + "cancelled": "$t(shared:statuses.cancelled)", + "error": "$t(shared:statuses.failed)", + "idle": "閒置" + }, "backgroundActivities": "背景活動({{count}})", "backgroundSubagentUntitled": "背景子 Agent", "backgroundSubagentSection": "背景子 Agent({{count}})", From f12119a3456e895f0f89384abb5edeab4f1efae3 Mon Sep 17 00:00:00 2001 From: wsp Date: Mon, 3 Aug 2026 01:10:46 +0800 Subject: [PATCH 2/2] feat(flow-chat): add recursive agent session tree - Add recursive Agents session lineage tree with live status tracking. - Replace the background activity panel with a background command panel. - Support non-cascading cancellation for individual agent sessions. - Highlight the Agents entry for active foreground and background descendants. - Preserve persisted session titles and manual tree collapse state. - Update desktop/server APIs, localization, styles, and focused tests. --- scripts/i18n-governance-baseline.json | 6 +- src/apps/desktop/src/api/agentic_api.rs | 13 +- .../src/api/remote_workspace_policy.rs | 1 - src/apps/server/src/rpc_dispatcher.rs | 10 +- .../src/agentic/coordination/coordinator.rs | 39 +- .../components/modern/FlowChatHeader.scss | 92 +--- .../components/modern/FlowChatHeader.test.tsx | 28 +- .../components/modern/FlowChatHeader.tsx | 515 ++++++------------ .../modern/ModernFlowChatContainer.tsx | 173 ++---- .../components/modern/SessionTreePopover.scss | 113 ++++ .../modern/SessionTreePopover.test.tsx | 135 +++++ .../components/modern/SessionTreePopover.tsx | 203 ++++++- .../flow_chat/utils/sessionLineage.test.ts | 51 ++ .../src/flow_chat/utils/sessionLineage.ts | 36 +- .../api/service-api/AgentAPI.test.ts | 16 + .../api/service-api/AgentAPI.ts | 13 +- src/web-ui/src/locales/en-US/flow-chat.json | 18 +- src/web-ui/src/locales/zh-CN/flow-chat.json | 18 +- src/web-ui/src/locales/zh-TW/flow-chat.json | 18 +- 19 files changed, 849 insertions(+), 649 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/modern/SessionTreePopover.test.tsx 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 59eb760c24..7a28e610cd 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -2258,7 +2258,6 @@ mod tests { "get_runtime_capabilities", "get_runtime_logging_info", "get_session_file_diff_stats", - "get_session_lineage", "get_session_files", "get_session_operations", "get_session_stats", 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/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 6223fb41ee..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 @@ -144,21 +144,22 @@ describe('FlowChatHeader', () => { expect(container.querySelector('[data-testid="flowchat-header-turn-next"]')).toBeNull(); }); - it('places the Agent tree entry immediately before background activity', () => { + it('places the Agent tree entry immediately before background commands', () => { act(() => { root.render(); }); const treeButton = container.querySelector('[data-testid="flowchat-header-session-tree"]'); - const activityButton = container.querySelector('[data-testid="flowchat-header-background-activities"]'); + const commandButton = container.querySelector('[data-testid="flowchat-header-background-commands"]'); const treeContainer = treeButton?.closest('.session-tree-popover'); - const activityContainer = activityButton?.closest('.flowchat-header__background-activity-nav'); + const commandContainer = commandButton?.closest('.flowchat-header__background-command-nav'); - expect(treeContainer?.nextElementSibling).toBe(activityContainer); + expect(treeContainer?.nextElementSibling).toBe(commandContainer); }); - it('renders background activity menus in a portal outside the scrollable panel', () => { + it('renders background command menus in a portal outside the scrollable panel', () => { const onStopBackgroundCommand = vi.fn(); + const onStopAllBackgroundCommands = vi.fn(); act(() => { root.render( @@ -172,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(); }); @@ -199,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 f8c915af8e..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,7 +6,7 @@ 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'; @@ -16,14 +16,6 @@ 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; @@ -66,18 +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. */ @@ -102,12 +90,10 @@ export const FlowChatHeader: React.FC = ({ onSearchPrev, onSearchClose, searchOpenRequest = 0, - backgroundSubagents = [], onOpenSessionTreeSession, + hasActiveSessionTreeDescendants = false, + onCancelSessionTreeSession, backgroundCommands = [], - onOpenBackgroundSubagent, - onStopBackgroundSubagent, - onStopAllBackgroundSubagents, onOpenBackgroundCommandOutput, onRequestBackgroundCommandInput, onStopBackgroundCommand, @@ -115,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); @@ -139,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, @@ -156,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, @@ -173,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); } }; @@ -213,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); @@ -246,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; @@ -321,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 = ( @@ -410,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); }; @@ -427,7 +348,7 @@ export const FlowChatHeader: React.FC = ({ event.stopPropagation(); onRequestBackgroundCommandInput?.(command); setOpenBackgroundCommandMenuId(null); - setIsBackgroundActivityPanelOpen(false); + setIsBackgroundCommandPanelOpen(false); }; const handleCommandStop = ( @@ -444,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) => { @@ -523,13 +395,13 @@ export const FlowChatHeader: React.FC = ({ >
- {openBackgroundCommandMenuId === command.execSessionKey && backgroundActivityMenuPosition ? createPortal( + {openBackgroundCommandMenuId === command.execSessionKey && backgroundCommandMenuPosition ? createPortal(
{canSendBackgroundCommandInput ? ( @@ -566,8 +438,8 @@ export const FlowChatHeader: React.FC = ({ ); }; - const backgroundActivityLabel = t('flowChatHeader.backgroundActivities', { - count: backgroundActivityCount, + const backgroundCommandLabel = t('flowChatHeader.backgroundCommands', { + count: backgroundCommandCount, }); if (!visible || totalTurns === 0) { @@ -613,197 +485,114 @@ export const FlowChatHeader: React.FC = ({ sessionId={sessionId} fallbackWorkspacePath={currentWorkspace?.rootPath} onSelectSession={onOpenSessionTreeSession} + hasActiveDescendants={hasActiveSessionTreeDescendants} + onCancelSession={onCancelSessionTreeSession} t={t} /> -
+
- - - {hasBackgroundActivities ? ( + + + {hasBackgroundCommands ? ( - {isBackgroundActivityPanelOpen && hasBackgroundActivities && ( + {isBackgroundCommandPanelOpen && hasBackgroundCommands && (
-
- {backgroundActivityLabel} - {backgroundActivityCount} +
+ {backgroundCommandLabel} +
+ {onStopAllBackgroundCommands ? ( + ( + command.status !== 'running' || command.isStopping === true + ))} + > + + ) : null} + {isBackgroundCommandSectionMenuOpen && backgroundCommandMenuPosition ? createPortal( +
+ +
, + document.body, + ) : null} +
-
- {hasBackgroundSubagents && ( -
-
- - {t('flowChatHeader.backgroundSubagentSection', { count: backgroundSubagents.length })} +
+ {displayBackgroundCommands.map((command) => ( +
+ -
, - document.body, - ) : null} -
- ) : null} -
- {displayBackgroundSubagents.map((subagent) => ( -
- - {renderBackgroundSubagentActions(subagent)} -
- ))} -
- )} - {hasBackgroundCommands && ( -
-
- - {t('flowChatHeader.backgroundCommandSection', { count: backgroundCommands.length })} + + {[ + t('flowChatHeader.backgroundCommandSession', { id: command.execSessionId }), + command.status === 'running' + ? t('flowChatHeader.backgroundCommandStatusRunning') + : t('flowChatHeader.backgroundCommandStatusFinished'), + ].filter(Boolean).join(' · ')} - {onStopAllBackgroundCommands ? ( -
- ( - command.status !== 'running' || command.isStopping === true - ))} - > - - {openBackgroundSectionMenuId === 'commands' && backgroundActivityMenuPosition ? createPortal( -
- -
, - document.body, - ) : null} -
- ) : null} -
- {displayBackgroundCommands.map((command) => ( -
- - {renderBackgroundCommandActions(command)} -
- ))} + + {renderBackgroundCommandActions(command)}
- )} + ))}
)} 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 6b75274750..d657222b7c 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -18,7 +18,6 @@ import { import { FlowChatHeader, type FlowChatHeaderCommandSummary, - type FlowChatHeaderSubagentSummary, } from './FlowChatHeader'; import type { SessionTreeSelection } from './SessionTreePopover'; import { FlowChatTurnRail, type FlowChatTurnRailItem } from './FlowChatTurnRail'; @@ -48,6 +47,7 @@ import { import type { FlowChatConfig, DialogTurn, + Session, SessionHistoryPresentation, } from '../../types/flow-chat'; import type { SessionHistoryWindowDirection } from '../../store/FlowChatStore'; @@ -62,7 +62,6 @@ import { } from '../../store/backgroundCommandActivityStore'; import { useBackgroundSubagentActivityStore, - visibleBackgroundSubagentActivitiesForSession, } from '../../store/backgroundSubagentActivityStore'; import type { LineRange } from '@/component-library'; import { isChatPopupActive, subscribeChatPopupChange } from '../chatPopupState'; @@ -75,6 +74,7 @@ import { isAcpFlowSession } from '../../utils/acpSession'; import { flowChatStore } from '../../store/FlowChatStore'; import { openBtwSessionInAuxPane } from '../../services/btwSessionPane'; import { resolveThreadGoalHeaderTitle } from '../../utils/threadGoalDisplay'; +import { hasActiveSessionLineageDescendants } from '../../utils/sessionLineage'; import { findDialogTurn, shouldUseStickyLatestPin, @@ -95,9 +95,6 @@ import { recordHistorySessionDiagnosticEvent, warnHistorySessionLoadingLayerStalled, } from '../../services/historySessionDiagnostics'; -import { - type BackgroundSubagentActivityItem, -} from '../../utils/backgroundSubagentActivity'; import './ModernFlowChatContainer.scss'; import { PermissionRequestPanel } from './PermissionRequestPanel'; import { pendingPermissionToolCallIdsForSession } from './permissionRequestRouting'; @@ -125,8 +122,6 @@ interface ModernFlowChatContainerProps { onSwitchToChatPanel?: () => void; } -type BackgroundSubagentSummary = BackgroundSubagentActivityItem; - interface FlowChatTurnSummary { turnId: string; turnIndex: number; @@ -184,28 +179,7 @@ const LATEST_TURN_AUTO_PIN_MAX_ATTEMPTS = 8; const HISTORY_INITIAL_CONTENT_PAINT_MAX_ATTEMPTS = 30; const HISTORY_LOADING_LAYER_STALL_WARN_MS = 800; const TURN_PIN_RETRY_MAX_ATTEMPTS = 120; -const MOCK_BACKGROUND_ACTIVITIES_STORAGE_KEY = 'bitfun.flowChat.mockBackgroundActivities'; - -const MOCK_BACKGROUND_SUBAGENTS: BackgroundSubagentSummary[] = [ - { - sessionId: 'mock-background-subagent-review', - parentSessionId: 'mock-parent-session', - title: 'Reviewing auth boundary changes', - agentType: 'ReviewWorker', - status: 'processing', - createdAt: Date.now() - 36_000, - updatedAt: Date.now() - 4_000, - }, - { - sessionId: 'mock-background-subagent-docs', - parentSessionId: 'mock-parent-session', - title: 'Preparing migration notes for command lifecycle events', - agentType: 'GeneralPurpose', - status: 'finishing', - createdAt: Date.now() - 58_000, - updatedAt: Date.now() - 6_000, - }, -]; +const MOCK_BACKGROUND_COMMANDS_STORAGE_KEY = 'bitfun.flowChat.mockBackgroundCommands'; const MOCK_BACKGROUND_COMMANDS: BackgroundCommandSummary[] = [ { @@ -255,15 +229,15 @@ const MOCK_BACKGROUND_COMMANDS: BackgroundCommandSummary[] = [ }, ]; -function shouldShowMockBackgroundActivities(): boolean { +function shouldShowMockBackgroundCommands(): boolean { if (!import.meta.env.DEV || typeof window === 'undefined') { return false; } const params = new URLSearchParams(window.location.search); return ( - params.get('mockBackgroundActivities') === '1' || - window.localStorage?.getItem(MOCK_BACKGROUND_ACTIVITIES_STORAGE_KEY) === '1' + (params.get('mockBackgroundCommands') === '1' || params.get('mockBackgroundActivities') === '1') || + window.localStorage?.getItem(MOCK_BACKGROUND_COMMANDS_STORAGE_KEY) === '1' ); } @@ -449,14 +423,12 @@ export const ModernFlowChatContainer: React.FC = ( // popup can be closed with Escape instead of cancelling the current task. const [chatPopupActive, setChatPopupActive] = useState(() => isChatPopupActive()); const backgroundCommandActivities = useBackgroundCommandActivityStore(state => state.activities); - const backgroundSubagentActivities = useBackgroundSubagentActivityStore(state => state.activities); useEffect(() => { return subscribeChatPopupChange(() => { setChatPopupActive(isChatPopupActive()); }); }, []); - const [stoppingBackgroundSubagentIds, setStoppingBackgroundSubagentIds] = useState>(() => new Set()); const [stoppingBackgroundCommandIds, setStoppingBackgroundCommandIds] = useState>(() => new Set()); const [backgroundCommandInputTarget, setBackgroundCommandInputTarget] = useState(null); const [isSendingBackgroundCommandInput, setIsSendingBackgroundCommandInput] = useState(false); @@ -2039,14 +2011,25 @@ export const ModernFlowChatContainer: React.FC = ( ).map(backgroundCommandSummaryFromActivity), [activeSession?.sessionId, backgroundCommandActivities], ); - const backgroundSubagents = useMemo( - () => visibleBackgroundSubagentActivitiesForSession( - backgroundSubagentActivities, - activeSession?.sessionId, - ), - [activeSession?.sessionId, backgroundSubagentActivities], + const [hasActiveSessionTreeDescendants, setHasActiveSessionTreeDescendants] = useState(() => + hasActiveSessionLineageDescendants(activeSession?.sessionId, flowChatStore.getState().sessions), ); + useEffect(() => { + const rootSessionId = activeSession?.sessionId; + const updateActivity = (sessions: Map) => { + setHasActiveSessionTreeDescendants( + hasActiveSessionLineageDescendants(rootSessionId, sessions), + ); + }; + + updateActivity(flowChatStore.getState().sessions); + return flowChatStore.subscribeSelector( + state => hasActiveSessionLineageDescendants(rootSessionId, state.sessions), + setHasActiveSessionTreeDescendants, + ); + }, [activeSession?.sessionId]); + useEffect(() => { if (stoppingBackgroundCommandIds.size === 0) { return; @@ -2057,7 +2040,7 @@ export const ModernFlowChatContainer: React.FC = ( .filter(command => command.status === 'running') .map(command => command.execSessionKey), ); - if (import.meta.env.DEV && shouldShowMockBackgroundActivities()) { + if (import.meta.env.DEV && shouldShowMockBackgroundCommands()) { for (const command of MOCK_BACKGROUND_COMMANDS) { if (command.status === 'running') { runningCommandIds.add(command.execSessionKey); @@ -2070,45 +2053,6 @@ export const ModernFlowChatContainer: React.FC = ( }); }, [backgroundCommands, stoppingBackgroundCommandIds.size]); - useEffect(() => { - if (stoppingBackgroundSubagentIds.size === 0) { - return; - } - - const runningSubagentIds = new Set(backgroundSubagents.map(subagent => subagent.sessionId)); - if (import.meta.env.DEV && shouldShowMockBackgroundActivities()) { - for (const subagent of MOCK_BACKGROUND_SUBAGENTS) { - runningSubagentIds.add(subagent.sessionId); - } - } - - setStoppingBackgroundSubagentIds((previous) => { - const next = new Set([...previous].filter(sessionId => runningSubagentIds.has(sessionId))); - return next.size === previous.size ? previous : next; - }); - }, [backgroundSubagents, stoppingBackgroundSubagentIds.size]); - - const handleOpenBackgroundSubagent = useCallback((childSessionId: string) => { - const subagent = backgroundSubagents.find(item => item.sessionId === childSessionId); - if (!subagent || !activeSession?.sessionId) { - return; - } - - openBtwSessionInAuxPane({ - childSessionId, - parentSessionId: activeSession.sessionId, - workspacePath: subagent.workspacePath || activeSession.workspacePath, - sessionKind: 'subagent', - sessionTitle: subagent.title, - agentType: subagent.agentType, - parentToolCallId: subagent.parentToolCallId, - subagentType: subagent.subagentType, - remoteConnectionId: subagent.remoteConnectionId || activeSession.remoteConnectionId, - remoteSshHost: subagent.remoteSshHost || activeSession.remoteSshHost, - includeInternal: true, - }); - }, [activeSession, backgroundSubagents]); - const handleOpenSessionTreeSession = useCallback((selection: SessionTreeSelection) => { if ( !activeSession?.sessionId || @@ -2134,49 +2078,24 @@ export const ModernFlowChatContainer: React.FC = ( }); }, [activeSession]); - const handleStopBackgroundSubagent = useCallback(async (subagent: FlowChatHeaderSubagentSummary) => { - if (stoppingBackgroundSubagentIds.has(subagent.sessionId)) { - return; - } - - setStoppingBackgroundSubagentIds((previous) => new Set(previous).add(subagent.sessionId)); - - if (import.meta.env.DEV && subagent.sessionId.startsWith('mock-background-subagent-')) { - window.setTimeout(() => { - setStoppingBackgroundSubagentIds((previous) => { - const next = new Set(previous); - next.delete(subagent.sessionId); - return next; - }); - }, 1200); - return; - } - + const handleCancelSessionTreeSession = useCallback(async (selection: SessionTreeSelection) => { try { - const result = await agentAPI.cancelSession(subagent.sessionId); + const result = await agentAPI.cancelSession(selection.sessionId, { cancelDescendants: false }); if (!result.cancelled) { - setStoppingBackgroundSubagentIds((previous) => { - const next = new Set(previous); - next.delete(subagent.sessionId); - return next; - }); notificationService.error( - t('flowChatHeader.backgroundSubagentStopFailed'), + t('flowChatHeader.agentTreeCancelFailed'), { duration: 5000 }, ); } + return result.cancelled; } catch (_error) { - setStoppingBackgroundSubagentIds((previous) => { - const next = new Set(previous); - next.delete(subagent.sessionId); - return next; - }); notificationService.error( - t('flowChatHeader.backgroundSubagentStopFailed'), + t('flowChatHeader.agentTreeCancelFailed'), { duration: 5000 }, ); + return false; } - }, [stoppingBackgroundSubagentIds, t]); + }, [t]); const handleOpenBackgroundCommandOutput = useCallback((command: FlowChatHeaderCommandSummary) => { createBackgroundCommandOutputTab({ @@ -2277,35 +2196,17 @@ export const ModernFlowChatContainer: React.FC = ( } }, [t]); - const showMockBackgroundActivities = shouldShowMockBackgroundActivities(); - const headerBackgroundSubagents = useMemo( - () => (showMockBackgroundActivities - ? [...backgroundSubagents, ...MOCK_BACKGROUND_SUBAGENTS] - : backgroundSubagents - ).map(subagent => ({ - ...subagent, - isStopping: stoppingBackgroundSubagentIds.has(subagent.sessionId), - })), - [backgroundSubagents, showMockBackgroundActivities, stoppingBackgroundSubagentIds], - ); + const showMockBackgroundCommands = shouldShowMockBackgroundCommands(); const headerBackgroundCommands = useMemo( - () => (showMockBackgroundActivities + () => (showMockBackgroundCommands ? [...backgroundCommands, ...MOCK_BACKGROUND_COMMANDS] : backgroundCommands ).map(command => ({ ...command, isStopping: stoppingBackgroundCommandIds.has(command.execSessionKey), })), - [backgroundCommands, showMockBackgroundActivities, stoppingBackgroundCommandIds], + [backgroundCommands, showMockBackgroundCommands, stoppingBackgroundCommandIds], ); - const handleStopAllBackgroundSubagents = useCallback(() => { - for (const subagent of headerBackgroundSubagents) { - if (subagent.isStopping === true) { - continue; - } - void handleStopBackgroundSubagent(subagent); - } - }, [handleStopBackgroundSubagent, headerBackgroundSubagents]); const handleStopAllBackgroundCommands = useCallback(() => { for (const command of headerBackgroundCommands) { if (command.status !== 'running' || command.isStopping === true) { @@ -2400,12 +2301,10 @@ export const ModernFlowChatContainer: React.FC = ( onSearchPrev={handleSearchPrev} onSearchClose={clearSearch} searchOpenRequest={searchOpenRequest} - backgroundSubagents={headerBackgroundSubagents} backgroundCommands={headerBackgroundCommands} onOpenSessionTreeSession={handleOpenSessionTreeSession} - onOpenBackgroundSubagent={handleOpenBackgroundSubagent} - onStopBackgroundSubagent={handleStopBackgroundSubagent} - onStopAllBackgroundSubagents={handleStopAllBackgroundSubagents} + hasActiveSessionTreeDescendants={hasActiveSessionTreeDescendants} + onCancelSessionTreeSession={handleCancelSessionTreeSession} onOpenBackgroundCommandOutput={handleOpenBackgroundCommandOutput} onRequestBackgroundCommandInput={handleRequestBackgroundCommandInput} onStopBackgroundCommand={handleStopBackgroundCommand} diff --git a/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.scss b/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.scss index 8d22b9a635..787af8a5ff 100644 --- a/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.scss +++ b/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.scss @@ -12,6 +12,34 @@ &--active { background: color-mix(in srgb, var(--element-bg-soft) 82%, transparent); } + + &--has-activity { + color: color-mix(in srgb, var(--color-success) 86%, var(--color-text-primary)); + + &:not(:disabled):hover, + &.session-tree-popover__trigger--active { + color: var(--color-success); + background: color-mix(in srgb, var(--color-success) 12%, transparent); + } + } + } + + &__trigger-inner { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + } + + &__status-dot { + position: absolute; + right: -2px; + bottom: -1px; + width: 4px; + height: 4px; + border-radius: 999px; + background: color-mix(in srgb, var(--color-success) 92%, white 8%); + box-shadow: 0 0 0 1px var(--color-bg-elevated); } &__panel { @@ -64,6 +92,10 @@ &--root { font-weight: 500; } + + &--cancelling { + opacity: 0.72; + } } &__expand, @@ -108,6 +140,87 @@ } } + &__node-actions { + flex: 0 0 auto; + margin-right: 2px; + opacity: 0; + pointer-events: none; + transition: opacity $motion-base $easing-standard; + } + + &__node:hover &__node-actions, + &__node:focus-within &__node-actions { + opacity: 1; + pointer-events: auto; + } + + &__action-menu-button { + 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); + } + } + + &__action-menu { + position: fixed; + top: auto; + right: auto; + width: max-content; + min-width: 0; + max-width: min(200px, calc(100vw - 48px)); + display: flex; + flex-direction: column; + gap: 2px; + padding: 4px; + border: 1px solid var(--border-base); + border-radius: $size-radius-base; + background: color-mix(in srgb, var(--color-bg-elevated) 98%, transparent); + box-shadow: var(--shadow-base); + z-index: 10000; + } + + &__action-menu-item { + display: flex; + align-items: center; + gap: $size-gap-2; + width: 100%; + padding: 6px 8px; + border: 0; + border-radius: calc($size-radius-base - 2px); + background: transparent; + color: var(--color-text-primary); + font-size: var(--flowchat-font-size-xs); + text-align: left; + white-space: nowrap; + cursor: pointer; + + svg { + flex: 0 0 auto; + color: var(--color-text-secondary); + } + + &:hover:not(:disabled) { + background: color-mix(in srgb, var(--element-bg-soft) 88%, transparent); + } + + &:disabled { + opacity: 0.45; + cursor: default; + } + + &--danger:hover:not(:disabled) { + color: var(--color-error); + background: color-mix(in srgb, var(--color-error) 12%, transparent); + + svg { + color: var(--color-error); + } + } + } + &__node-copy { min-width: 0; flex: 1; diff --git a/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.test.tsx b/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.test.tsx new file mode 100644 index 0000000000..1faf8012e5 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.test.tsx @@ -0,0 +1,135 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SessionTreePopover } from './SessionTreePopover'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const mocks = vi.hoisted(() => ({ + getSessionLineage: vi.fn(), + sessions: new Map>(), +})); + +vi.mock('@/infrastructure/api/service-api/SessionAPI', () => ({ + sessionAPI: { + getSessionLineage: mocks.getSessionLineage, + }, +})); + +vi.mock('../../store/FlowChatStore', () => ({ + flowChatStore: { + getState: () => ({ sessions: mocks.sessions }), + subscribe: () => () => undefined, + }, +})); + +vi.mock('@/component-library', async () => { + const ReactModule = await import('react'); + + return { + DotMatrixLoader: () => , + IconButton: ({ + children, + tooltip, + ...props + }: React.ButtonHTMLAttributes & { tooltip?: string }) => ( + + ), + }; +}); + +function createSession( + sessionId: string, + sessionKind: 'main' | 'subagent', + parentSessionId?: string, +): Record { + return { + sessionId, + sessionKind, + parentSessionId, + parentToolCallId: parentSessionId ? 'tool-1' : undefined, + title: sessionId === 'root' ? 'Root session' : 'Running child', + createdAt: sessionId === 'root' ? 1 : 2, + workspacePath: '/workspace', + mode: 'code', + config: { agentType: 'worker' }, + subagentType: sessionKind === 'subagent' ? 'worker' : undefined, + dialogTurns: sessionKind === 'subagent' ? [{ status: 'processing' }] : [], + }; +} + +describe('SessionTreePopover', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + mocks.getSessionLineage.mockReset(); + mocks.getSessionLineage.mockResolvedValue(null); + mocks.sessions.clear(); + mocks.sessions.set('root', createSession('root', 'main')); + mocks.sessions.set('child', createSession('child', 'subagent', 'root')); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + document.body.querySelector('[data-testid="flowchat-header-session-tree-menu"]')?.remove(); + vi.restoreAllMocks(); + }); + + it('offers non-cascading cancellation for running child sessions', async () => { + const onCancelSession = vi.fn().mockResolvedValue(true); + const t = (key: string) => key; + + await act(async () => { + root.render( + , + ); + }); + + await act(async () => { + container.querySelector('[data-testid="flowchat-header-session-tree"]')?.click(); + await Promise.resolve(); + }); + + const actionButton = container.querySelector( + '[aria-label="flowChatHeader.agentTreeActions"]', + ); + expect(actionButton).not.toBeNull(); + const childNode = Array.from(container.querySelectorAll('[role="treeitem"]')) + .find(node => node.textContent?.includes('Running child')); + const status = childNode?.querySelector('.session-tree-popover__status'); + expect(childNode).not.toBeUndefined(); + expect(status).not.toBeNull(); + expect(Boolean(actionButton?.compareDocumentPosition(status!) & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true); + + await act(async () => { + actionButton?.click(); + }); + + const cancelButton = document.querySelector( + '[data-testid="flowchat-header-session-tree-menu"] [role="menuitem"]', + ); + expect(cancelButton).not.toBeNull(); + + await act(async () => { + cancelButton?.click(); + await Promise.resolve(); + }); + + expect(onCancelSession).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: 'child', + isRoot: false, + })); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.tsx b/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.tsx index b6f41def5c..9b73120542 100644 --- a/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.tsx +++ b/src/web-ui/src/flow_chat/components/modern/SessionTreePopover.tsx @@ -1,14 +1,17 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { Bot, ChevronDown, ChevronRight, - ListTree, MessageSquare, + MoreHorizontal, RefreshCw, + Square, } from 'lucide-react'; import { DotMatrixLoader, IconButton } from '@/component-library'; import { sessionAPI, type SessionLineageSnapshot } from '@/infrastructure/api/service-api/SessionAPI'; +import { computeFixedPopoverPosition } from '@/shared/utils/fixedPopoverViewport'; import { flowChatStore } from '../../store/FlowChatStore'; import { buildSessionLineageTree, @@ -35,7 +38,9 @@ export interface SessionTreeSelection { interface SessionTreePopoverProps { sessionId?: string; fallbackWorkspacePath?: string; + hasActiveDescendants?: boolean; onSelectSession?: (selection: SessionTreeSelection) => void; + onCancelSession?: (selection: SessionTreeSelection) => Promise; t: (key: string, options?: Record) => string; } @@ -53,7 +58,9 @@ function nodeHasActiveWork(node: SessionLineageNode): boolean { export const SessionTreePopover: React.FC = ({ sessionId, fallbackWorkspacePath, + hasActiveDescendants = false, onSelectSession, + onCancelSession, t, }) => { const [isOpen, setIsOpen] = useState(false); @@ -62,8 +69,14 @@ export const SessionTreePopover: React.FC = ({ const [loadFailed, setLoadFailed] = useState(false); const [liveRevision, setLiveRevision] = useState(0); const [expandedSessionIds, setExpandedSessionIds] = useState>(new Set()); + const [collapsedSessionIds, setCollapsedSessionIds] = useState>(new Set()); + const [openActionSessionId, setOpenActionSessionId] = useState(null); + const [cancellingSessionIds, setCancellingSessionIds] = useState>(new Set()); const containerRef = useRef(null); + const actionMenuAnchorRef = useRef(null); + const actionMenuRef = useRef(null); const requestGenerationRef = useRef(0); + const [actionMenuPosition, setActionMenuPosition] = useState<{ top: number; left: number } | null>(null); const refreshSnapshot = useCallback(async () => { if (!sessionId) return; @@ -105,6 +118,10 @@ export const SessionTreePopover: React.FC = ({ setSnapshot(null); setLoadFailed(false); setExpandedSessionIds(new Set()); + setCollapsedSessionIds(new Set()); + setOpenActionSessionId(null); + setCancellingSessionIds(new Set()); + setActionMenuPosition(null); }, [sessionId]); useEffect(() => { @@ -128,12 +145,19 @@ export const SessionTreePopover: React.FC = ({ useEffect(() => { if (!isOpen) return; const handlePointerDown = (event: MouseEvent) => { - if (!containerRef.current?.contains(event.target as Node)) { + if ( + !containerRef.current?.contains(event.target as Node) && + !actionMenuRef.current?.contains(event.target as Node) + ) { setIsOpen(false); + setOpenActionSessionId(null); } }; const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') setIsOpen(false); + if (event.key === 'Escape') { + setIsOpen(false); + setOpenActionSessionId(null); + } }; document.addEventListener('mousedown', handlePointerDown); document.addEventListener('keydown', handleKeyDown); @@ -143,6 +167,36 @@ export const SessionTreePopover: React.FC = ({ }; }, [isOpen]); + const updateActionMenuPosition = useCallback(() => { + const anchor = actionMenuAnchorRef.current; + if (!anchor) return; + + const menu = actionMenuRef.current; + const position = computeFixedPopoverPosition( + anchor.getBoundingClientRect(), + menu?.offsetWidth ?? 150, + menu?.offsetHeight ?? 40, + 4, + 8, + ); + setActionMenuPosition(position); + }, []); + + useLayoutEffect(() => { + if (!openActionSessionId) { + setActionMenuPosition(null); + return; + } + + updateActionMenuPosition(); + window.addEventListener('resize', updateActionMenuPosition); + window.addEventListener('scroll', updateActionMenuPosition, true); + return () => { + window.removeEventListener('resize', updateActionMenuPosition); + window.removeEventListener('scroll', updateActionMenuPosition, true); + }; + }, [openActionSessionId, updateActionMenuPosition]); + const tree = useMemo(() => { void liveRevision; if (!sessionId) return null; @@ -159,22 +213,33 @@ export const SessionTreePopover: React.FC = ({ const defaults = collectExpandedRunningBranches(tree); setExpandedSessionIds(previous => { const next = new Set(previous); - defaults.forEach(sessionId => next.add(sessionId)); + defaults.forEach(sessionId => { + if (!collapsedSessionIds.has(sessionId)) { + next.add(sessionId); + } + }); return next.size === previous.size ? previous : next; }); - }, [tree]); + }, [collapsedSessionIds, tree]); const toggleExpanded = useCallback((targetSessionId: string) => { + const isExpanded = expandedSessionIds.has(targetSessionId); setExpandedSessionIds(previous => { const next = new Set(previous); - if (next.has(targetSessionId)) next.delete(targetSessionId); + if (isExpanded) next.delete(targetSessionId); else next.add(targetSessionId); return next; }); - }, []); + setCollapsedSessionIds(previous => { + const next = new Set(previous); + if (isExpanded) next.add(targetSessionId); + else next.delete(targetSessionId); + return next; + }); + }, [expandedSessionIds]); const handleSelect = useCallback((node: SessionLineageNode) => { - onSelectSession?.({ + const selection = { sessionId: node.sessionId, parentSessionId: node.parentSessionId, parentToolCallId: node.parentToolCallId, @@ -185,15 +250,71 @@ export const SessionTreePopover: React.FC = ({ remoteConnectionId: node.remoteConnectionId, remoteSshHost: node.remoteSshHost, isRoot: node.isRoot, - }); + } satisfies SessionTreeSelection; + onSelectSession?.(selection); setIsOpen(false); + setOpenActionSessionId(null); }, [onSelectSession]); + const handleActionMenuToggle = useCallback(( + event: React.MouseEvent, + node: SessionLineageNode, + ) => { + event.preventDefault(); + event.stopPropagation(); + if (!onCancelSession || node.isRoot || !nodeHasActiveWork(node)) return; + + if (openActionSessionId === node.sessionId) { + setOpenActionSessionId(null); + setActionMenuPosition(null); + return; + } + + actionMenuAnchorRef.current = event.currentTarget; + setOpenActionSessionId(node.sessionId); + updateActionMenuPosition(); + }, [onCancelSession, openActionSessionId, updateActionMenuPosition]); + + const handleCancel = useCallback(async (node: SessionLineageNode) => { + if (!onCancelSession || node.isRoot || !nodeHasActiveWork(node)) return; + if (cancellingSessionIds.has(node.sessionId)) return; + + const selection = { + sessionId: node.sessionId, + parentSessionId: node.parentSessionId, + parentToolCallId: node.parentToolCallId, + title: node.title, + agentType: node.agentType, + subagentType: node.subagentType, + workspacePath: node.workspacePath, + remoteConnectionId: node.remoteConnectionId, + remoteSshHost: node.remoteSshHost, + isRoot: node.isRoot, + } satisfies SessionTreeSelection; + + setOpenActionSessionId(null); + setActionMenuPosition(null); + setCancellingSessionIds(previous => new Set(previous).add(node.sessionId)); + try { + await onCancelSession(selection); + } finally { + setCancellingSessionIds(previous => { + const next = new Set(previous); + next.delete(node.sessionId); + return next; + }); + } + }, [cancellingSessionIds, onCancelSession]); + const renderNode = (node: SessionLineageNode, depth: number): React.ReactNode => { const hasChildren = node.children.length > 0; const isExpanded = expandedSessionIds.has(node.sessionId); - const statusLabel = lifecycleLabel(node.lifecycle, t); + const isCancelling = cancellingSessionIds.has(node.sessionId); + const statusLabel = isCancelling + ? t('flowChatHeader.agentTreeCancelling') + : lifecycleLabel(node.lifecycle, t); const secondaryLabel = node.subagentType || node.agentType; + const canCancel = !!onCancelSession && !node.isRoot && nodeHasActiveWork(node); return ( @@ -202,6 +323,7 @@ export const SessionTreePopover: React.FC = ({ 'session-tree-popover__node', node.isRoot && 'session-tree-popover__node--root', nodeHasActiveWork(node) && 'session-tree-popover__node--active', + isCancelling && 'session-tree-popover__node--cancelling', ].filter(Boolean).join(' ')} role="treeitem" aria-level={depth + 1} @@ -236,12 +358,53 @@ export const SessionTreePopover: React.FC = ({ {secondaryLabel} ) : null} - + {canCancel ? ( +
+ handleActionMenuToggle(event, node)} + tooltip={t('flowChatHeader.agentTreeActions')} + aria-label={t('flowChatHeader.agentTreeActions')} + aria-haspopup="menu" + aria-expanded={openActionSessionId === node.sessionId} + disabled={isCancelling} + > + + {openActionSessionId === node.sessionId && actionMenuPosition ? createPortal( +
+ +
, + document.body, + ) : null} +
+ ) : null} +
{hasChildren && isExpanded ? node.children.map(child => renderNode(child, depth + 1)) : null} @@ -256,6 +419,7 @@ export const SessionTreePopover: React.FC = ({ className={[ 'session-tree-popover__trigger', isOpen && 'session-tree-popover__trigger--active', + hasActiveDescendants && 'session-tree-popover__trigger--has-activity', ].filter(Boolean).join(' ')} variant="ghost" size="xs" @@ -267,7 +431,12 @@ export const SessionTreePopover: React.FC = ({ disabled={!sessionId} data-testid="flowchat-header-session-tree" > - + + + {hasActiveDescendants ? ( + {isOpen ? ( diff --git a/src/web-ui/src/flow_chat/utils/sessionLineage.test.ts b/src/web-ui/src/flow_chat/utils/sessionLineage.test.ts index 4ec300b4fb..bd4ad230cf 100644 --- a/src/web-ui/src/flow_chat/utils/sessionLineage.test.ts +++ b/src/web-ui/src/flow_chat/utils/sessionLineage.test.ts @@ -6,6 +6,7 @@ import { buildSessionLineageTree, collectExpandedRunningBranches, countSessionLineageDescendants, + hasActiveSessionLineageDescendants, } from './sessionLineage'; function metadata( @@ -93,4 +94,54 @@ describe('sessionLineage', () => { expect.arrayContaining(['root', 'child']), ); }); + + it('keeps the persisted title when a live child session has a placeholder title', () => { + const snapshot: SessionLineageSnapshot = { + rootSessionId: 'root', + sessions: [ + metadata('root'), + { + ...metadata('child', 'root', 2), + sessionName: 'Review authentication boundary', + }, + ], + }; + const live = liveSession('child', 'root'); + live.title = 'Child session'; + + const tree = buildSessionLineageTree( + 'root', + snapshot, + new Map([[live.sessionId, live]]), + ); + + expect(tree?.children[0]).toMatchObject({ + sessionId: 'child', + title: 'Review authentication boundary', + lifecycle: 'running', + }); + }); + + it('detects active foreground descendants through the live session hierarchy', () => { + const completedChild = liveSession('child', 'root'); + completedChild.dialogTurns[0].status = 'completed'; + const runningGrandchild = liveSession('grandchild', 'child'); + + expect(hasActiveSessionLineageDescendants( + 'root', + new Map([ + [completedChild.sessionId, completedChild], + [runningGrandchild.sessionId, runningGrandchild], + ]), + )).toBe(true); + + runningGrandchild.dialogTurns[0].status = 'completed'; + expect(hasActiveSessionLineageDescendants( + 'root', + new Map([ + [completedChild.sessionId, completedChild], + [runningGrandchild.sessionId, runningGrandchild], + ]), + )).toBe(false); + }); }); diff --git a/src/web-ui/src/flow_chat/utils/sessionLineage.ts b/src/web-ui/src/flow_chat/utils/sessionLineage.ts index 72a121fb4b..f066510d57 100644 --- a/src/web-ui/src/flow_chat/utils/sessionLineage.ts +++ b/src/web-ui/src/flow_chat/utils/sessionLineage.ts @@ -62,6 +62,10 @@ function sessionLifecycle(session: Session): SessionLineageLifecycle { } } +function isActiveSessionLineageLifecycle(lifecycle: SessionLineageLifecycle): boolean { + return lifecycle === 'running' || lifecycle === 'finishing'; +} + function nodeFromMetadata(metadata: SessionMetadata): FlatSessionLineageNode { const relationship = deriveSessionRelationshipFromMetadata(metadata); return { @@ -137,7 +141,14 @@ export function buildSessionLineageTree( session.sessionKind === 'subagent' || nodes.has(session.sessionId) ) { - nodes.set(session.sessionId, nodeFromSession(session)); + const liveNode = nodeFromSession(session); + const persistedNode = nodes.get(session.sessionId); + // Opened subagent shells can expose a generic title; persisted metadata remains + // the display-title authority while live fields provide current runtime state. + if (persistedNode?.title.trim()) { + liveNode.title = persistedNode.title; + } + nodes.set(session.sessionId, liveNode); } } @@ -175,6 +186,29 @@ export function buildSessionLineageTree( return buildNode(rootSessionId); } +export function hasActiveSessionLineageDescendants( + rootSessionId: string | undefined, + liveSessions: Map, +): boolean { + if (!rootSessionId) return false; + + for (const session of liveSessions.values()) { + if (session.sessionId === rootSessionId || !isActiveSessionLineageLifecycle(sessionLifecycle(session))) { + continue; + } + + const visited = new Set(); + let currentSessionId: string | undefined = session.sessionId; + while (currentSessionId && !visited.has(currentSessionId)) { + if (currentSessionId === rootSessionId) return true; + visited.add(currentSessionId); + currentSessionId = liveSessions.get(currentSessionId)?.parentSessionId; + } + } + + return false; +} + export function countSessionLineageDescendants(root: SessionLineageNode | null): number { if (!root) return 0; return root.children.reduce( diff --git a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.test.ts index 862c8994f2..ad457f022d 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.test.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.test.ts @@ -105,6 +105,22 @@ describe('AgentAPI', () => { }); }); + it('can cancel a session without cancelling its descendants', async () => { + invokeMock.mockResolvedValueOnce({ + cancelled: true, + dialogTurnId: 'turn-parent', + }); + + await agentAPI.cancelSession('parent-session', { cancelDescendants: false }); + + expect(invokeMock).toHaveBeenCalledWith('cancel_session', { + request: { + sessionId: 'parent-session', + cancelDescendants: false, + }, + }); + }); + it('sends subagent timeout extensions with seconds in the action payload', async () => { await agentAPI.setSubagentTimeout('subagent-session', { type: 'extend', seconds: 300 }); 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 828af6ef51..5ef20aa8c5 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -1221,16 +1221,25 @@ export class AgentAPI { return api.listen('session_title_generated', callback); } - async cancelSession(sessionId: string): Promise<{ + async cancelSession( + sessionId: string, + options?: { cancelDescendants?: boolean }, + ): Promise<{ cancelled: boolean; dialogTurnId: string | null; }> { try { + const request = { + sessionId, + ...(options?.cancelDescendants === undefined + ? {} + : { cancelDescendants: options.cancelDescendants }), + }; return await api.invoke<{ cancelled: boolean; dialogTurnId: string | null; }>('cancel_session', { - request: { sessionId } + request, }); } catch (error) { throw createTauriCommandError('cancel_session', error, { sessionId }); diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index b137d06c61..2521dcdca0 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -1236,13 +1236,17 @@ "searchClose": "Close search", "searchOpen": "Search messages", "jumpToCurrentTurn": "Jump to Turn {{turn}}", - "agentTree": "Agent tree", + "agentTree": "Agents", "agentTreeLoading": "Loading Agent tree...", "agentTreeEmpty": "No subagents in this session", "agentTreeLoadFailed": "Unable to load the Agent tree.", "agentTreeRetry": "Retry", "agentTreeExpand": "Expand branch", "agentTreeCollapse": "Collapse branch", + "agentTreeActions": "Agent session actions", + "agentTreeCancel": "Cancel session", + "agentTreeCancelling": "Cancelling session", + "agentTreeCancelFailed": "Failed to cancel the Agent session.", "agentTreeStatus": { "running": "$t(shared:statuses.running)", "finishing": "Finishing", @@ -1252,19 +1256,9 @@ "error": "$t(shared:statuses.failed)", "idle": "Idle" }, - "backgroundActivities": "Background activity ({{count}})", - "backgroundSubagentUntitled": "Background subagent", - "backgroundSubagentSection": "Background subagents ({{count}})", - "backgroundSubagentActions": "Subagent actions", - "backgroundSubagentStop": "Cancel", - "backgroundSubagentStopping": "Cancelling", - "backgroundSubagentStopAll": "Cancel all", - "backgroundSubagentStopFailed": "Failed to cancel background subagent.", - "backgroundCommandSection": "Background commands ({{count}})", + "backgroundCommands": "Background commands ({{count}})", "backgroundCommandUntitled": "Background command", "backgroundCommandSession": "Session #{{id}}", - "subagentStatusProcessing": "Running", - "subagentStatusFinishing": "Finishing", "backgroundCommandStatusRunning": "Active", "backgroundCommandStatusFinished": "Finished", "backgroundCommandActions": "Command actions", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 00bffd13e6..568bcd63f2 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -1236,13 +1236,17 @@ "searchClose": "关闭搜索", "searchOpen": "搜索消息", "jumpToCurrentTurn": "跳转到第 {{turn}} 轮", - "agentTree": "Agent 会话树", + "agentTree": "Agents", "agentTreeLoading": "正在加载 Agent 会话树...", "agentTreeEmpty": "当前会话没有子 Agent", "agentTreeLoadFailed": "无法加载 Agent 会话树。", "agentTreeRetry": "重试", "agentTreeExpand": "展开分支", "agentTreeCollapse": "收起分支", + "agentTreeActions": "Agent 会话操作", + "agentTreeCancel": "取消会话", + "agentTreeCancelling": "正在取消会话", + "agentTreeCancelFailed": "取消 Agent 会话失败。", "agentTreeStatus": { "running": "$t(shared:statuses.running)", "finishing": "收尾中", @@ -1252,19 +1256,9 @@ "error": "$t(shared:statuses.failed)", "idle": "空闲" }, - "backgroundActivities": "后台活动({{count}})", - "backgroundSubagentUntitled": "后台子 Agent", - "backgroundSubagentSection": "后台子 Agent({{count}})", - "backgroundSubagentActions": "子 Agent 操作", - "backgroundSubagentStop": "取消", - "backgroundSubagentStopping": "正在取消", - "backgroundSubagentStopAll": "全部取消", - "backgroundSubagentStopFailed": "取消后台子 Agent 失败。", - "backgroundCommandSection": "后台命令({{count}})", + "backgroundCommands": "后台命令({{count}})", "backgroundCommandUntitled": "后台命令", "backgroundCommandSession": "会话 #{{id}}", - "subagentStatusProcessing": "运行中", - "subagentStatusFinishing": "收尾中", "backgroundCommandStatusRunning": "进行中", "backgroundCommandStatusFinished": "已结束", "backgroundCommandActions": "命令操作", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index effe908f1d..e17b267166 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -1236,13 +1236,17 @@ "searchClose": "關閉搜尋", "searchOpen": "搜尋消息", "jumpToCurrentTurn": "跳轉到第 {{turn}} 輪", - "agentTree": "Agent 會話樹", + "agentTree": "Agents", "agentTreeLoading": "正在載入 Agent 會話樹...", "agentTreeEmpty": "目前會話沒有子 Agent", "agentTreeLoadFailed": "無法載入 Agent 會話樹。", "agentTreeRetry": "重試", "agentTreeExpand": "展開分支", "agentTreeCollapse": "收起分支", + "agentTreeActions": "Agent 會話操作", + "agentTreeCancel": "取消會話", + "agentTreeCancelling": "正在取消會話", + "agentTreeCancelFailed": "取消 Agent 會話失敗。", "agentTreeStatus": { "running": "$t(shared:statuses.running)", "finishing": "收尾中", @@ -1252,19 +1256,9 @@ "error": "$t(shared:statuses.failed)", "idle": "閒置" }, - "backgroundActivities": "背景活動({{count}})", - "backgroundSubagentUntitled": "背景子 Agent", - "backgroundSubagentSection": "背景子 Agent({{count}})", - "backgroundSubagentActions": "子 Agent 操作", - "backgroundSubagentStop": "取消", - "backgroundSubagentStopping": "正在取消", - "backgroundSubagentStopAll": "全部取消", - "backgroundSubagentStopFailed": "取消背景子 Agent 失敗。", - "backgroundCommandSection": "背景命令({{count}})", + "backgroundCommands": "背景命令({{count}})", "backgroundCommandUntitled": "背景命令", "backgroundCommandSession": "會話 #{{id}}", - "subagentStatusProcessing": "運行中", - "subagentStatusFinishing": "收尾中", "backgroundCommandStatusRunning": "進行中", "backgroundCommandStatusFinished": "已結束", "backgroundCommandActions": "命令操作",