diff --git a/app/src/ai/agent_conversations_model.rs b/app/src/ai/agent_conversations_model.rs index 010ab2738..13e710e88 100644 --- a/app/src/ai/agent_conversations_model.rs +++ b/app/src/ai/agent_conversations_model.rs @@ -99,6 +99,25 @@ pub enum StatusFilter { Failed, } +impl StatusFilter { + /// Returns `true` if a status transition from `prev_bucket` to `new_bucket` flips + /// whether an item is included by this filter. `All` matches every bucket so it + /// is never crossed; the other variants are crossed when exactly one of the buckets + /// equals this filter. + pub(crate) fn is_membership_crossed( + self, + prev_bucket: StatusFilter, + new_bucket: StatusFilter, + ) -> bool { + match self { + StatusFilter::All => false, + StatusFilter::Working | StatusFilter::Done | StatusFilter::Failed => { + (prev_bucket == self) != (new_bucket == self) + } + } + } +} + #[derive(Clone, PartialEq, Eq, Debug, Default, Serialize, Deserialize)] pub enum SourceFilter { #[default] @@ -873,9 +892,7 @@ pub enum AgentConversationsModelEvent { TasksUpdated, /// Conversation status data was updated ConversationUpdated { - #[allow(dead_code)] conversation_id: AIConversationId, - #[allow(dead_code)] kind: ConversationUpdateKind, }, /// Conversation artifacts were updated (plans, PRs, etc.) @@ -1403,7 +1420,7 @@ impl AgentConversationsModel { /// We first match using the orchestration agent ID (task ID / run ID under v2), and fall back /// to the server conversation token for cases where the task only carries conversation identity /// through `conversation_id`. - fn conversation_id_shadowed_by_task( + pub(crate) fn conversation_id_shadowed_by_task( task: &AmbientAgentTask, history_model: &BlocklistAIHistoryModel, ) -> Option { diff --git a/app/src/ai/agent_management/view.rs b/app/src/ai/agent_management/view.rs index f9b7ceb1c..36df71a2b 100644 --- a/app/src/ai/agent_management/view.rs +++ b/app/src/ai/agent_management/view.rs @@ -13,8 +13,8 @@ use warpui::ui_components::button::ButtonVariant; use crate::ai::agent::conversation::AIConversationId; use crate::ai::agent_conversations_model::{ AgentConversationsModel, AgentConversationsModelEvent, AgentManagementFilters, ArtifactFilter, - ConversationOrTask, CreatedOnFilter, CreatorFilter, EnvironmentFilter, HarnessFilter, - OwnerFilter, SessionStatus, SourceFilter, StatusFilter, + ConversationOrTask, ConversationUpdateKind, CreatedOnFilter, CreatorFilter, EnvironmentFilter, + HarnessFilter, OwnerFilter, SessionStatus, SourceFilter, StatusFilter, }; use crate::ai::agent_management::agent_type_selector::{ AgentType, AgentTypeSelector, AgentTypeSelectorEvent, @@ -1237,10 +1237,11 @@ impl AgentManagementView { self.refresh_details_panel_if_needed(ctx); self.get_tasks_from_model(ctx); } - AgentConversationsModelEvent::ConversationUpdated { .. } => { - self.get_tasks_from_model(ctx); - self.refresh_details_panel_if_needed(ctx); - ctx.notify(); + AgentConversationsModelEvent::ConversationUpdated { + conversation_id, + kind, + } => { + self.handle_conversation_updated(*conversation_id, *kind, ctx); } // TaskManuallyOpened is handled by the conversation list view, not here. AgentConversationsModelEvent::TaskManuallyOpened => {} @@ -1258,6 +1259,95 @@ impl AgentManagementView { } } + /// Decide how much work a `ConversationUpdated` event requires, based on its kind and the + /// active status filter: + /// * `Restored`: the underlying status didn't change, so the visible cards don't change + /// either. Just refresh the details panel. + /// * `StatusSet` that crosses the active status filter: rebuild the + /// card list via `get_tasks_from_model`. + /// * `StatusSet` that doesn't cross the active filter (or `All` is active): targeted + /// refresh of the affected card via `update_card_for_conversation` (no-op when the card + /// is not visible) plus a re-render so the status icon picks up the new value. + fn handle_conversation_updated( + &mut self, + conversation_id: AIConversationId, + kind: ConversationUpdateKind, + ctx: &mut ViewContext, + ) { + match kind { + ConversationUpdateKind::Restored => { + self.refresh_details_panel_if_needed(ctx); + } + ConversationUpdateKind::StatusSet { + prev_filter, + new_filter, + } => { + let crossed_active_filter = self + .filters + .status + .is_membership_crossed(prev_filter, new_filter); + + if crossed_active_filter { + self.get_tasks_from_model(ctx); + self.refresh_details_panel_if_needed(ctx); + } else { + self.update_card_for_conversation(conversation_id, ctx); + self.refresh_details_panel_if_needed(ctx); + ctx.notify(); + } + } + } + } + + /// Update the action-buttons config for the card backing a given conversation, without + /// rebuilding the entire list. If no matching card is currently visible, this is a no-op. + fn update_card_for_conversation( + &mut self, + conversation_id: AIConversationId, + ctx: &mut ViewContext, + ) { + let model = AgentConversationsModel::as_ref(ctx); + let history_model = BlocklistAIHistoryModel::as_ref(ctx); + + let target_index = self.items.iter().position(|card| match &card.item_id { + ManagementCardItemId::Conversation(id) => *id == conversation_id, + ManagementCardItemId::Task(task_id) => model + .get_task_data(task_id) + .and_then(|task| { + AgentConversationsModel::conversation_id_shadowed_by_task(&task, history_model) + }) + .is_some_and(|id| id == conversation_id), + }); + + let Some(index) = target_index else { return }; + + let card_state = &self.items[index]; + let card_data = match &card_state.item_id { + ManagementCardItemId::Task(task_id) => model.get_task(task_id), + ManagementCardItemId::Conversation(conv_id) => model.get_conversation(conv_id), + }; + let Some(card_data) = card_data else { return }; + + let copy_link_url = card_data.session_or_conversation_link(ctx); + let mut config = match &card_data { + ConversationOrTask::Task(task) => ActionButtonsConfig::for_task( + task.task_id, + &card_data.display_status(ctx), + None, + copy_link_url, + ), + ConversationOrTask::Conversation(conversation) => { + ActionButtonsConfig::for_conversation(conversation.nav_data.id, None, copy_link_url) + } + }; + if FeatureFlag::AgentManagementDetailsView.is_enabled() { + config.view_details_item_id = Some(card_state.item_id.clone()); + } + + let action_buttons_view = card_state.action_buttons_view.clone(); + action_buttons_view.update(ctx, |row, ctx| row.set_config(config, ctx)); + } + /// Update the details panel with fresh data for the given item. fn update_details_panel_for_item( &mut self,