Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions app/src/ai/agent_conversations_model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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.)
Expand Down Expand Up @@ -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<AIConversationId> {
Expand Down
102 changes: 96 additions & 6 deletions app/src/ai/agent_management/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 => {}
Expand All @@ -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<Self>,
) {
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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [IMPORTANT] Same-bucket re-emissions still reach this path, so repeated InProgress status sets scan self.items and rebuild the card button config on every streaming event; make exact status re-emissions a no-op before calling update_card_for_conversation (or carry enough previous/new status detail to distinguish re-emits from real same-bucket changes).

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<Self>,
) {
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,
Expand Down