diff --git a/app/src/ai/agent_conversations_model.rs b/app/src/ai/agent_conversations_model.rs index f97991d505..f2e96aca0a 100644 --- a/app/src/ai/agent_conversations_model.rs +++ b/app/src/ai/agent_conversations_model.rs @@ -194,6 +194,13 @@ pub enum CreatedOnFilter { LastWeek, } +#[derive(Copy, Clone, PartialEq, Eq, Debug, Default, Serialize, Deserialize)] +pub enum SubagentsFilter { + #[default] + All, + Hide, +} + #[derive(Clone, PartialEq, Eq, Debug, Default, Serialize, Deserialize)] pub enum EnvironmentFilter { #[default] @@ -244,6 +251,8 @@ pub struct AgentManagementFilters { pub creator: CreatorFilter, pub artifact: ArtifactFilter, #[serde(default)] + pub subagents: SubagentsFilter, + #[serde(default)] pub environment: EnvironmentFilter, #[serde(default)] pub harness: HarnessFilter, @@ -256,6 +265,7 @@ impl AgentManagementFilters { self.created_on = CreatedOnFilter::default(); self.creator = CreatorFilter::default(); self.artifact = ArtifactFilter::default(); + self.subagents = SubagentsFilter::default(); self.environment = EnvironmentFilter::default(); self.harness = HarnessFilter::default(); } @@ -266,6 +276,7 @@ impl AgentManagementFilters { || self.created_on != CreatedOnFilter::default() || self.creator != CreatorFilter::default() && self.owners != OwnerFilter::PersonalOnly || self.artifact != ArtifactFilter::default() + || self.subagents != SubagentsFilter::default() || self.environment != EnvironmentFilter::default() || self.harness != HarnessFilter::default() } diff --git a/app/src/ai/agent_conversations_model/entry.rs b/app/src/ai/agent_conversations_model/entry.rs index 12dde2a99a..7eb0b4cfb0 100644 --- a/app/src/ai/agent_conversations_model/entry.rs +++ b/app/src/ai/agent_conversations_model/entry.rs @@ -70,6 +70,7 @@ pub struct AgentConversationEntry { pub id: AgentConversationEntryId, pub identity: AgentConversationIdentity, pub provenance: AgentConversationProvenance, + pub is_subagent: bool, pub display: AgentConversationDisplayData, pub backing: AgentConversationBackingData, pub capabilities: AgentConversationCapabilities, @@ -177,6 +178,7 @@ impl AgentConversationEntry { && self.matches_artifact(&filters.artifact) && self.matches_environment(&filters.environment) && self.matches_harness(&filters.harness) + && self.matches_subagent(&filters.subagents) } fn matches_owner_and_creator( @@ -262,6 +264,13 @@ impl AgentConversationEntry { } } + fn matches_subagent(&self, subagents_filter: &super::SubagentsFilter) -> bool { + match subagents_filter { + super::SubagentsFilter::All => true, + super::SubagentsFilter::Hide => !self.is_subagent, + } + } + pub fn has_open_action( &self, restore_layout: Option, @@ -442,6 +451,12 @@ pub(super) fn entry_for_task( let can_copy_link = task.has_active_execution() && task.active_run_execution().session_link.is_some() || server_conversation_token.is_some(); + let is_subagent = task.parent_run_id.is_some() + || local_conversation_id.is_some_and(|id| { + history_model + .conversation(&id) + .is_some_and(|conversation| conversation.is_child_agent_conversation()) + }); AgentConversationEntry { id: AgentConversationEntryId::AmbientRun(task.task_id), @@ -452,6 +467,7 @@ pub(super) fn entry_for_task( session_id: task_session_id(task), }, provenance: AgentConversationProvenance::AmbientRun, + is_subagent, display: AgentConversationDisplayData { title: task.title.clone(), initial_query: Some(task.prompt.clone()), @@ -573,6 +589,9 @@ fn entry_for_conversation_parts( session_id: None, }, provenance, + is_subagent: history_model + .conversation(&conversation_id) + .is_some_and(|conversation| conversation.is_child_agent_conversation()), display: AgentConversationDisplayData { title: conversation_title(&metadata, history_model), initial_query: metadata.nav_data.initial_query.clone(), diff --git a/app/src/ai/agent_conversations_model_tests.rs b/app/src/ai/agent_conversations_model_tests.rs index cb9818509d..fa4401e64c 100644 --- a/app/src/ai/agent_conversations_model_tests.rs +++ b/app/src/ai/agent_conversations_model_tests.rs @@ -39,7 +39,8 @@ use super::{ record_earliest_rtc_task_refresh_timestamp, AgentConversationsModel, AgentConversationsModelEvent, AgentManagementFilters, AgentRunDisplayStatus, ArtifactFilter, ConversationMetadata, ConversationUpdateKind, EnvironmentFilter, HarnessFilter, OwnerFilter, - RtcTaskRefreshThrottleState, StatusFilter, TaskFetchState, MAX_PERSONAL_TASKS, MAX_TEAM_TASKS, + RtcTaskRefreshThrottleState, StatusFilter, SubagentsFilter, TaskFetchState, MAX_PERSONAL_TASKS, + MAX_TEAM_TASKS, }; use crate::ai::ambient_agents::task::HarnessConfig; use crate::workspace::WorkspaceAction; @@ -668,6 +669,26 @@ fn all_owner_filters() -> AgentManagementFilters { } } +fn default_conversation_data() -> AgentConversationData { + AgentConversationData { + server_conversation_token: None, + conversation_usage_metadata: None, + reverted_action_ids: None, + forked_from_server_conversation_token: None, + artifacts_json: None, + parent_agent_id: None, + agent_name: None, + orchestration_harness_type: None, + parent_conversation_id: None, + is_remote_child: false, + root_task_is_optimistic: None, + run_id: None, + autoexecute_override: None, + last_event_sequence: None, + pinned: false, + } +} + fn add_entry_projection_test_models(app: &mut App) { app.add_singleton_model(|_| AuthStateProvider::new_for_test()); app.add_singleton_model(|_| BlocklistAIHistoryModel::new(vec![], &[])); @@ -779,6 +800,69 @@ fn test_get_entries_includes_local_only_entry() { }); } +#[test] +fn test_get_entries_can_hide_local_subagent_conversation() { + App::test((), |mut app| async move { + add_entry_projection_test_models(&mut app); + + let conversation_id = AIConversationId::new(); + let terminal_view_id = EntityId::new(); + let mut conversation_data = default_conversation_data(); + conversation_data.parent_agent_id = Some("parent-run-id".to_string()); + let child_conversation = + create_restored_conversation(conversation_id, "child-task", conversation_data); + + BlocklistAIHistoryModel::handle(&app).update(&mut app, |model, ctx| { + model.restore_conversations(terminal_view_id, vec![child_conversation], ctx); + }); + + let mut model = create_test_model(); + model.conversations.insert( + conversation_id, + create_test_conversation_metadata(conversation_id, "Child conversation"), + ); + + app.update(|ctx| { + let all_entries = model.get_entries(&all_owner_filters(), ctx); + assert_eq!(all_entries.len(), 1); + assert!(all_entries[0].is_subagent); + + let hidden_entries = model.get_entries( + &AgentManagementFilters { + subagents: SubagentsFilter::Hide, + ..all_owner_filters() + }, + ctx, + ); + assert!(hidden_entries.is_empty()); + }); + }); +} + +#[test] +fn test_get_entries_can_hide_task_subagent() { + App::test((), |mut app| async move { + add_entry_projection_test_models(&mut app); + + let now = Utc::now(); + let mut model = create_test_model(); + let mut task = create_test_task(&make_uuid(8150), "user-a", now); + task.parent_run_id = Some("parent-run-id".to_string()); + model.tasks.insert(task.task_id, task); + + app.update(|ctx| { + let hidden_entries = model.get_entries( + &AgentManagementFilters { + subagents: SubagentsFilter::Hide, + ..all_owner_filters() + }, + ctx, + ); + assert!(hidden_entries.is_empty()); + }); + }); +} + #[test] fn test_get_entries_includes_cloud_metadata_only_entry() { App::test((), |mut app| async move { @@ -2251,7 +2335,7 @@ fn test_get_or_async_fetch_task_data_skips_within_transient_cooldown() { #[test] fn test_agent_management_filters_serde_backwards_compat() { - // Persisted state from older clients has no `harness` key → deserializes to All. + // Persisted state from older clients has no newer filter keys -> deserializes to defaults. let legacy = r#"{ "owners": "PersonalOnly", "status": "All", @@ -2263,6 +2347,7 @@ fn test_agent_management_filters_serde_backwards_compat() { let decoded: AgentManagementFilters = serde_json::from_str(legacy).expect("legacy payload without harness must deserialize"); assert_eq!(decoded.harness, HarnessFilter::All); + assert_eq!(decoded.subagents, SubagentsFilter::All); // Round trip a Specific(Claude) value. let original = AgentManagementFilters { diff --git a/app/src/ai/agent_management/view.rs b/app/src/ai/agent_management/view.rs index 883ca09beb..4b57668ccd 100644 --- a/app/src/ai/agent_management/view.rs +++ b/app/src/ai/agent_management/view.rs @@ -15,7 +15,7 @@ use crate::ai::agent_conversations_model::{ AgentConversationEntry, AgentConversationEntryId, AgentConversationNavigationSubject, AgentConversationsModel, AgentConversationsModelEvent, AgentManagementFilters, ArtifactFilter, ConversationUpdateKind, CreatedOnFilter, CreatorFilter, EnvironmentFilter, HarnessFilter, - OwnerFilter, SessionStatus, SourceFilter, StatusFilter, + OwnerFilter, SessionStatus, SourceFilter, StatusFilter, SubagentsFilter, }; use crate::ai::agent_management::agent_type_selector::{ AgentType, AgentTypeSelector, AgentTypeSelectorEvent, @@ -176,6 +176,7 @@ pub struct AgentManagementView { source_dropdown: ViewHandle>, created_on_dropdown: ViewHandle>, artifact_dropdown: ViewHandle>, + subagents_dropdown: ViewHandle>, harness_dropdown: ViewHandle>, environment_dropdown: ViewHandle>, creator_dropdown: ViewHandle>, @@ -265,6 +266,7 @@ impl AgentManagementView { let source_dropdown = ctx.add_typed_action_view(Self::create_source_dropdown); let created_on_dropdown = ctx.add_typed_action_view(Self::create_created_on_dropdown); let artifact_dropdown = ctx.add_typed_action_view(Self::create_artifact_dropdown); + let subagents_dropdown = ctx.add_typed_action_view(Self::create_subagents_dropdown); let harness_dropdown = ctx.add_typed_action_view(Self::create_harness_dropdown); let environment_dropdown = ctx.add_typed_action_view(Self::create_environment_dropdown); let creator_dropdown = ctx.add_typed_action_view(Self::create_creator_dropdown); @@ -359,6 +361,7 @@ impl AgentManagementView { source_dropdown, created_on_dropdown, artifact_dropdown, + subagents_dropdown, harness_dropdown, environment_dropdown, creator_dropdown, @@ -448,6 +451,13 @@ impl AgentManagementView { ); }); + self.subagents_dropdown.update(ctx, |dropdown, ctx| { + dropdown.set_selected_by_action( + AgentManagementViewAction::SetSubagentsFilter(self.filters.subagents), + ctx, + ); + }); + self.harness_dropdown.update(ctx, |dropdown, ctx| { dropdown.set_selected_by_action( AgentManagementViewAction::SetHarnessFilter(self.filters.harness), @@ -661,6 +671,30 @@ impl AgentManagementView { dropdown } + fn create_subagents_dropdown( + ctx: &mut ViewContext>, + ) -> Dropdown { + let mut dropdown = Dropdown::new(ctx); + Self::setup_filter_menu(&mut dropdown, "Subagents", ctx); + + let items = vec![ + MenuItem::Item(MenuItemFields::new("Show").with_on_select_action( + DropdownAction::SelectActionAndClose( + AgentManagementViewAction::SetSubagentsFilter(SubagentsFilter::All), + ), + )), + MenuItem::Item(MenuItemFields::new("Hide").with_on_select_action( + DropdownAction::SelectActionAndClose( + AgentManagementViewAction::SetSubagentsFilter(SubagentsFilter::Hide), + ), + )), + ]; + + dropdown.set_rich_items(items, ctx); + dropdown.set_selected_by_index(0, ctx); + dropdown + } + fn create_harness_dropdown( ctx: &mut ViewContext>, ) -> Dropdown { @@ -926,6 +960,9 @@ impl AgentManagementView { self.artifact_dropdown.update(ctx, |dropdown, ctx| { dropdown.set_selected_by_index(0, ctx); }); + self.subagents_dropdown.update(ctx, |dropdown, ctx| { + dropdown.set_selected_by_index(0, ctx); + }); self.harness_dropdown.update(ctx, |dropdown, ctx| { dropdown.set_selected_by_index(0, ctx); }); @@ -1940,7 +1977,8 @@ impl AgentManagementView { .with_child(ChildView::new(&self.status_dropdown).finish()) .with_child(ChildView::new(&self.source_dropdown).finish()) .with_child(ChildView::new(&self.created_on_dropdown).finish()) - .with_child(ChildView::new(&self.artifact_dropdown).finish()); + .with_child(ChildView::new(&self.artifact_dropdown).finish()) + .with_child(ChildView::new(&self.subagents_dropdown).finish()); if HarnessAvailabilityModel::as_ref(app).should_show_harness_selector() { filters_wrap.add_child(ChildView::new(&self.harness_dropdown).finish()); @@ -2212,6 +2250,7 @@ pub enum AgentManagementViewAction { SetSourceFilter(SourceFilter), SetCreatedOnFilter(CreatedOnFilter), SetArtifactFilter(ArtifactFilter), + SetSubagentsFilter(SubagentsFilter), SetEnvironmentFilter(EnvironmentFilter), SetCreatorFilter(CreatorFilter), SetHarnessFilter(HarnessFilter), @@ -2278,6 +2317,11 @@ impl TypedActionView for AgentManagementView { self.get_tasks_from_model(ctx); ctx.dispatch_global_action("workspace:save_app", ()); } + AgentManagementViewAction::SetSubagentsFilter(filter) => { + self.filters.subagents = *filter; + self.get_tasks_from_model(ctx); + ctx.dispatch_global_action("workspace:save_app", ()); + } AgentManagementViewAction::SetEnvironmentFilter(filter) => { self.filters.environment = filter.clone(); self.on_filter_changed(ctx); @@ -2318,6 +2362,9 @@ impl TypedActionView for AgentManagementView { self.artifact_dropdown.update(ctx, |dropdown, ctx| { dropdown.set_selected_by_index(0, ctx); }); + self.subagents_dropdown.update(ctx, |dropdown, ctx| { + dropdown.set_selected_by_index(0, ctx); + }); self.harness_dropdown.update(ctx, |dropdown, ctx| { dropdown.set_selected_by_index(0, ctx); }); diff --git a/app/src/ui_components/agent_icon_tests.rs b/app/src/ui_components/agent_icon_tests.rs index c026a7d743..7933be0bc7 100644 --- a/app/src/ui_components/agent_icon_tests.rs +++ b/app/src/ui_components/agent_icon_tests.rs @@ -395,6 +395,7 @@ fn non_ambient_entry_uses_display_harness() { session_id: None, }, provenance: AgentConversationProvenance::CloudSyncedConversation, + is_subagent: false, display: AgentConversationDisplayData { title: "Codex conversation".to_string(), initial_query: None, diff --git a/app/src/workspace/view/conversation_list/view.rs b/app/src/workspace/view/conversation_list/view.rs index 2db50033f3..548ebe8182 100644 --- a/app/src/workspace/view/conversation_list/view.rs +++ b/app/src/workspace/view/conversation_list/view.rs @@ -137,6 +137,7 @@ pub enum ConversationListViewAction { NewConversationInNewTab, ToggleSection(ConversationSection), ToggleViewAll, + ToggleSubagents, ForkConversation { conversation_id: AgentConversationEntryId, destination: ForkedConversationDestination, @@ -157,6 +158,7 @@ pub struct ConversationListView { view_id: EntityId, view_model: ModelHandle, query_editor: ViewHandle, + subagents_filter_button: ViewHandle, toggle_view_all_button: ViewHandle, item_overflow_menu: ViewHandle>, /// Tracks the overflow menu state (which item it's open for and where to position it). @@ -234,6 +236,16 @@ impl ConversationListView { me.handle_query_editor_event(event, ctx); }); + let subagents_filter_button = ctx.add_typed_action_view(|_| { + ActionButton::new("Subagents", SecondaryTheme) + .with_size(ButtonSize::Small) + .with_icon(Icon::Users) + .with_tooltip("Show subagent conversations") + .on_click(|ctx| { + ctx.dispatch_typed_action(ConversationListViewAction::ToggleSubagents); + }) + }); + // We use this as both the "view all" and "show less" button // (switching out the text on-toggle). let toggle_view_all_button = ctx.add_typed_action_view(|_| { @@ -273,6 +285,7 @@ impl ConversationListView { view_id: ctx.view_id(), view_model, query_editor, + subagents_filter_button, toggle_view_all_button, item_overflow_menu, overflow_menu_state: None, @@ -782,6 +795,19 @@ fn render_search_box(query_editor: &ViewHandle, app: &AppContext) -> .finish() } +fn render_filter_bar(subagents_filter_button: &ViewHandle) -> Box { + Container::new( + Flex::row() + .with_main_axis_size(MainAxisSize::Max) + .with_spacing(4.) + .with_child(ChildView::new(subagents_filter_button).finish()) + .finish(), + ) + .with_horizontal_padding(12.) + .with_padding_bottom(4.) + .finish() +} + fn render_section_header( section: ConversationSection, is_collapsed: bool, @@ -1129,6 +1155,23 @@ impl TypedActionView for ConversationListView { ctx.notify(); } + ConversationListViewAction::ToggleSubagents => { + let show_subagents = !self.view_model.as_ref(ctx).show_subagents(); + self.subagents_filter_button.update(ctx, |button, ctx| { + button.set_active(show_subagents, ctx); + button.set_tooltip( + Some(if show_subagents { + "Hide subagent conversations" + } else { + "Show subagent conversations" + }), + ctx, + ); + }); + self.view_model.update(ctx, |model, ctx| { + model.set_show_subagents(show_subagents, ctx); + }); + } ConversationListViewAction::ForkConversation { conversation_id, destination, @@ -1340,6 +1383,7 @@ impl View for ConversationListView { if has_conversations { column = column.with_child(render_search_box(&self.query_editor, app)); + column = column.with_child(render_filter_bar(&self.subagents_filter_button)); } let column_element = column diff --git a/app/src/workspace/view/conversation_list/view_model.rs b/app/src/workspace/view/conversation_list/view_model.rs index 8156f00254..40da974e30 100644 --- a/app/src/workspace/view/conversation_list/view_model.rs +++ b/app/src/workspace/view/conversation_list/view_model.rs @@ -1,7 +1,7 @@ use crate::ai::agent_conversations_model::{ AgentConversationEntry, AgentConversationEntryId, AgentConversationsModel, AgentConversationsModelEvent, AgentManagementFilters, ArtifactFilter, CreatedOnFilter, - CreatorFilter, OwnerFilter, SourceFilter, StatusFilter, + CreatorFilter, OwnerFilter, SourceFilter, StatusFilter, SubagentsFilter, }; use fuzzy_match::match_indices_case_insensitive; use warpui::{AppContext, Entity, ModelContext, ModelHandle, SingletonEntity}; @@ -19,6 +19,7 @@ pub struct ConversationListViewModel { cached_entry_ids: Vec, filtered_items: Vec, search_query: String, + show_subagents: bool, } impl Entity for ConversationListViewModel { @@ -53,6 +54,7 @@ impl ConversationListViewModel { cached_entry_ids: Vec::new(), filtered_items: Vec::new(), search_query: String::new(), + show_subagents: false, }; model.refresh_cached_items(ctx); model @@ -76,6 +78,7 @@ impl ConversationListViewModel { created_on: CreatedOnFilter::All, creator: CreatorFilter::All, artifact: ArtifactFilter::All, + subagents: SubagentsFilter::All, environment: Default::default(), harness: Default::default(), }, @@ -100,6 +103,20 @@ impl ConversationListViewModel { ctx.emit(ConversationListViewModelEvent); } + pub fn set_show_subagents(&mut self, show: bool, ctx: &mut ModelContext) { + if show == self.show_subagents { + return; + } + + self.show_subagents = show; + self.apply_search_filter(ctx); + ctx.emit(ConversationListViewModelEvent); + } + + pub fn show_subagents(&self) -> bool { + self.show_subagents + } + fn apply_search_filter(&mut self, ctx: &mut ModelContext) { let search_query = self.search_query.trim().to_lowercase(); let conversations_model = self.conversations_model.as_ref(ctx); @@ -108,6 +125,12 @@ impl ConversationListViewModel { self.filtered_items = self .cached_entry_ids .iter() + .filter(|id| { + self.show_subagents + || conversations_model + .get_entry_by_id(id, ctx) + .is_none_or(|item| !item.is_subagent) + }) .map(|id| ConversationEntry { id: *id, highlight_indices: vec![], @@ -119,6 +142,9 @@ impl ConversationListViewModel { .iter() .filter_map(|id| { let item = conversations_model.get_entry_by_id(id, ctx)?; + if !self.show_subagents && item.is_subagent { + return None; + } match_indices_case_insensitive(&item.display.title, &search_query).map( |result| {