diff --git a/app/src/ai/agent_management/details_action_buttons.rs b/app/src/ai/agent_management/details_action_buttons.rs index 0633966d..0106fd70 100644 --- a/app/src/ai/agent_management/details_action_buttons.rs +++ b/app/src/ai/agent_management/details_action_buttons.rs @@ -26,6 +26,7 @@ pub struct ActionButtonsConfig { pub view_details_item_id: Option, /// Conversation link URL (either to the transcript or live session) for copy link button. pub copy_link_url: Option, + pub delete_conversation_id: Option, } impl ActionButtonsConfig { @@ -36,6 +37,7 @@ impl ActionButtonsConfig { && self.fork_conversation_id.is_none() && self.view_details_item_id.is_none() && self.copy_link_url.is_none() + && self.delete_conversation_id.is_none() } /// Create config for a task. @@ -58,6 +60,7 @@ impl ActionButtonsConfig { fork_conversation_id: None, view_details_item_id: None, copy_link_url, + delete_conversation_id: None, } } @@ -75,6 +78,7 @@ impl ActionButtonsConfig { fork_conversation_id: Some(conversation_id), view_details_item_id: None, copy_link_url, + delete_conversation_id: Some(conversation_id), } } } @@ -87,6 +91,7 @@ pub enum AgentDetailsButtonEvent { ForkConversation { conversation_id: AIConversationId }, ViewDetails { item_id: AgentConversationEntryId }, CopyLink { link: String }, + DeleteConversation { conversation_id: AIConversationId }, } /// Actions dispatched by button clicks (internal). @@ -97,6 +102,7 @@ pub enum AgentDetailsAction { ForkConversation, ViewDetails, CopyLink, + DeleteConversation, } /// Reusable action buttons row for details panel. @@ -107,6 +113,7 @@ pub struct ConversationActionButtonsRow { fork_conversation_button: ViewHandle, view_details_button: ViewHandle, copy_link_button: ViewHandle, + delete_conversation_button: ViewHandle, } impl ConversationActionButtonsRow { @@ -156,6 +163,15 @@ impl ConversationActionButtonsRow { ) }); + let delete_conversation_button = ctx.add_typed_action_view(|_| { + Self::make_action_button( + Icon::Trash, + "Delete conversation", + Some(AnsiColorIdentifier::Red), + AgentDetailsAction::DeleteConversation, + ) + }); + Self { config: ActionButtonsConfig::default(), open_button, @@ -163,6 +179,7 @@ impl ConversationActionButtonsRow { fork_conversation_button, view_details_button, copy_link_button, + delete_conversation_button, } } @@ -231,6 +248,9 @@ impl View for ConversationActionButtonsRow { if self.config.view_details_item_id.is_some() { row.add_child(ChildView::new(&self.view_details_button).finish()); } + if self.config.delete_conversation_id.is_some() && !cfg!(target_family = "wasm") { + row.add_child(ChildView::new(&self.delete_conversation_button).finish()); + } row.finish() } @@ -280,6 +300,11 @@ impl TypedActionView for ConversationActionButtonsRow { ); } } + AgentDetailsAction::DeleteConversation => { + if let Some(conversation_id) = self.config.delete_conversation_id { + ctx.emit(AgentDetailsButtonEvent::DeleteConversation { conversation_id }); + } + } } } } diff --git a/app/src/ai/agent_management/view.rs b/app/src/ai/agent_management/view.rs index 8a534339..fbd27d84 100644 --- a/app/src/ai/agent_management/view.rs +++ b/app/src/ai/agent_management/view.rs @@ -1078,7 +1078,7 @@ impl AgentManagementView { open_action: Option, copy_link_url: Option, ) -> ActionButtonsConfig { - if let Some(task_id) = entry.identity.ambient_agent_task_id { + let mut config = if let Some(task_id) = entry.identity.ambient_agent_task_id { ActionButtonsConfig::for_task( task_id, &entry.display.status, @@ -1093,7 +1093,15 @@ impl AgentManagementView { copy_link_url, ..Default::default() } + }; + + if !entry.capabilities.can_delete + || !entry.display.status.to_conversation_status().is_done() + { + config.delete_conversation_id = None; } + + config } fn handle_action_buttons_event( @@ -1173,6 +1181,18 @@ impl AgentManagementView { ctx.clipboard() .write(ClipboardContent::plain_text(link.clone())); } + AgentDetailsButtonEvent::DeleteConversation { conversation_id } => { + let model = AgentConversationsModel::as_ref(ctx); + let conversation_title = model + .get_entry_by_id(item_id, ctx) + .map(|entry| entry.display.title) + .unwrap_or_else(|| "Conversation".to_string()); + ctx.emit(AgentManagementViewEvent::ShowDeleteConfirmationDialog { + conversation_id: *conversation_id, + conversation_title, + terminal_view_id: None, + }); + } } } @@ -1395,6 +1415,16 @@ impl AgentManagementView { notebook_uid: *notebook_uid, }); } + ConversationDetailsPanelEvent::ShowDeleteConfirmationDialog { + conversation_id, + conversation_title, + } => { + ctx.emit(AgentManagementViewEvent::ShowDeleteConfirmationDialog { + conversation_id: *conversation_id, + conversation_title: conversation_title.clone(), + terminal_view_id: None, + }); + } } } @@ -2232,7 +2262,14 @@ pub enum AgentManagementViewAction { pub enum AgentManagementViewEvent { OpenNewTabAndRunWorkflow(Box), - OpenPlanNotebook { notebook_uid: NotebookId }, + OpenPlanNotebook { + notebook_uid: NotebookId, + }, + ShowDeleteConfirmationDialog { + conversation_id: AIConversationId, + conversation_title: String, + terminal_view_id: Option, + }, } impl TypedActionView for AgentManagementView { diff --git a/app/src/ai/blocklist/action_model.rs b/app/src/ai/blocklist/action_model.rs index aead2f9f..6a5e70be 100644 --- a/app/src/ai/blocklist/action_model.rs +++ b/app/src/ai/blocklist/action_model.rs @@ -1048,6 +1048,24 @@ impl BlocklistAIActionModel { has_pending || has_running } + pub fn has_unresolved_ask_user_question_for_conversation( + &self, + conversation_id: AIConversationId, + app: &AppContext, + ) -> bool { + self.pending_actions + .get(&conversation_id) + .is_some_and(|queue| { + queue.iter().any(|action| { + matches!(action.action, AIAgentActionType::AskUserQuestion { .. }) + }) + }) + || self + .executor + .as_ref(app) + .has_running_ask_user_question(conversation_id) + } + /// Returns finished action results received from the most recent AI output for the active conversation. pub fn get_finished_action_results( &self, @@ -1806,6 +1824,18 @@ impl BlocklistAIActionModel { self.finished_tool_results.remove(&conversation_id); } + #[cfg(test)] + pub(super) fn push_pending_action_for_test( + &mut self, + conversation_id: AIConversationId, + action: AIAgentAction, + ) { + self.pending_actions + .entry(conversation_id) + .or_default() + .push_back(action); + } + /// The control flow for initiating cancellations across suggested plans, requested commands, /// and code diff views are identical, and thus should be handled directly by the [`AIBlock`]'s /// respective functions. diff --git a/app/src/ai/blocklist/action_model/execute.rs b/app/src/ai/blocklist/action_model/execute.rs index f315ee42..7f90c954 100644 --- a/app/src/ai/blocklist/action_model/execute.rs +++ b/app/src/ai/blocklist/action_model/execute.rs @@ -398,6 +398,16 @@ impl BlocklistAIActionExecutor { .map(|running| &running.action) } + pub(super) fn has_running_ask_user_question(&self, conversation_id: AIConversationId) -> bool { + self.async_executing_actions.values().any(|running| { + running.conversation_id == conversation_id + && matches!( + running.action.action, + AIAgentActionType::AskUserQuestion { .. } + ) + }) + } + /// Returns the action_id of any running WaitForEvents action for the /// given conversation. There is at most one (wait_for_events is /// documented as exclusive within a turn). diff --git a/app/src/ai/blocklist/controller.rs b/app/src/ai/blocklist/controller.rs index 016091eb..0c2d8107 100644 --- a/app/src/ai/blocklist/controller.rs +++ b/app/src/ai/blocklist/controller.rs @@ -36,26 +36,28 @@ use self::response_stream::{ResponseStream, ResponseStreamEvent}; use super::action_model::{BlocklistAIActionEvent, BlocklistAIActionModel}; use super::context_model::{BlocklistAIContextModel, PendingAttachment, PendingFile}; use super::conversation_selection::{ConversationSelectionEvent, ConversationSelectionHandle}; -use super::history_model::BlocklistAIHistoryModel; +use super::history_model::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel}; use super::orchestration_event_streamer::{ OrchestrationEventStreamer, OrchestrationEventStreamerEvent, }; use super::orchestration_events::{OrchestrationEventService, OrchestrationEventServiceEvent}; +use super::orchestration_topology::descendant_conversation_ids_in_spawn_order; use super::queued_query::{QueuedQueryId, QueuedQueryModel}; use super::{BlocklistAIInputModel, ResponseStreamId}; use crate::ai::agent::api::{self, ServerConversationToken}; use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus}; use crate::ai::agent::task::TaskId; -use crate::ai::agent::{ - extract_user_query_mode, AIAgentActionResult, AIAgentActionResultType, AIAgentAttachment, - AIAgentContext, AIAgentExchangeId, AIAgentInput, AIAgentOutputStatus, AIIdentifiers, - CancellationOutcome, CancellationReason, DocumentContentAttachmentSource, EntrypointType, - FileContext, FinishedAIAgentOutput, PassiveSuggestionResultType, PassiveSuggestionTrigger, - PassiveSuggestionTriggerType, RenderableAIError, RequestCommandOutputResult, RequestCost, - RequestMetadata, RunningCommand, StaticQueryType, TransientNetworkErrorKind, UserQueryMode, -}; #[cfg(not(target_family = "wasm"))] -use crate::ai::agent::{AIAgentAction, AIAgentActionTypeDiscriminants}; +use crate::ai::agent::AIAgentActionTypeDiscriminants; +use crate::ai::agent::{ + extract_user_query_mode, AIAgentAction, AIAgentActionResult, AIAgentActionResultType, + AIAgentAttachment, AIAgentContext, AIAgentExchangeId, AIAgentInput, AIAgentOutputStatus, + AIIdentifiers, CancellationOutcome, CancellationReason, DocumentContentAttachmentSource, + EntrypointType, FileContext, FinishedAIAgentOutput, PassiveSuggestionResultType, + PassiveSuggestionTrigger, PassiveSuggestionTriggerType, RenderableAIError, + RequestCommandOutputResult, RequestCost, RequestMetadata, RunningCommand, StaticQueryType, + TransientNetworkErrorKind, UserQueryMode, +}; use crate::ai::agent_events::AgentMessageEventMetadata; #[cfg(not(target_family = "wasm"))] use crate::ai::agent_sdk::ClaudeHarness; @@ -189,6 +191,146 @@ fn remote_action_summaries(actions: &[AIAgentAction]) -> Vec .collect() } +#[derive(Debug, Clone)] +struct FailedToolProposal { + action_id: String, + task_id: String, + tool_name: String, + requires_result: bool, + error: String, +} + +impl FailedToolProposal { + fn new(action: &AIAgentAction, error: impl Into) -> Self { + Self { + action_id: action.id.to_string(), + task_id: action.task_id.to_string(), + tool_name: failed_proposal_tool_name(action), + requires_result: action.requires_result, + error: error.into(), + } + } + + #[cfg(not(target_family = "wasm"))] + fn to_remote_log_value(&self) -> serde_json::Value { + serde_json::json!({ + "action_id": self.action_id, + "task_id": self.task_id, + "tool_name": self.tool_name, + "requires_result": self.requires_result, + "error": remote_logging::sanitize_error(&self.error), + }) + } +} + +fn failed_proposal_tool_name(action: &AIAgentAction) -> String { + if let Some(tool_name) = action.tool_name.clone() { + return tool_name; + } + #[cfg(not(target_family = "wasm"))] + { + format!("{:?}", AIAgentActionTypeDiscriminants::from(&action.action)) + } + #[cfg(target_family = "wasm")] + { + "unknown".to_string() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ToolQueueDecision { + Cancelled, + UnfinishedExchange, + BlockedFailedToolProposal, + BlockedActiveChildAgents, + NoActions, + QueueActionsWithStreamSnapshotFallback, + QueueActions, +} + +impl ToolQueueDecision { + fn label(self) -> &'static str { + match self { + Self::Cancelled => "cancelled", + Self::UnfinishedExchange => "unfinished_exchange", + Self::BlockedFailedToolProposal => "blocked_failed_tool_proposal", + Self::BlockedActiveChildAgents => "blocked_active_child_agents", + Self::NoActions => "no_actions", + Self::QueueActionsWithStreamSnapshotFallback => { + "queue_actions_with_stream_snapshot_fallback" + } + Self::QueueActions => "queue_actions", + } + } + + fn will_queue_actions(self) -> bool { + matches!( + self, + Self::QueueActions | Self::QueueActionsWithStreamSnapshotFallback + ) + } + + #[cfg(not(target_family = "wasm"))] + fn remote_log_level(self) -> RemoteLogLevel { + match self { + Self::BlockedFailedToolProposal + | Self::BlockedActiveChildAgents + | Self::QueueActionsWithStreamSnapshotFallback => RemoteLogLevel::Warn, + Self::Cancelled | Self::UnfinishedExchange | Self::NoActions | Self::QueueActions => { + RemoteLogLevel::Info + } + } + } +} + +fn tool_queue_decision( + has_cancellation: bool, + has_unfinished_exchange: bool, + has_failed_tool_proposal: bool, + has_active_child_agents: bool, + candidate_action_count: usize, + queued_from_stream_snapshot_count: usize, +) -> ToolQueueDecision { + if has_cancellation { + ToolQueueDecision::Cancelled + } else if has_unfinished_exchange { + ToolQueueDecision::UnfinishedExchange + } else if has_failed_tool_proposal { + ToolQueueDecision::BlockedFailedToolProposal + } else if has_active_child_agents { + ToolQueueDecision::BlockedActiveChildAgents + } else if candidate_action_count == 0 { + ToolQueueDecision::NoActions + } else if queued_from_stream_snapshot_count > 0 { + ToolQueueDecision::QueueActionsWithStreamSnapshotFallback + } else { + ToolQueueDecision::QueueActions + } +} + +fn active_descendant_conversation_ids( + history: &BlocklistAIHistoryModel, + conversation_id: AIConversationId, +) -> Vec { + descendant_conversation_ids_in_spawn_order(history, conversation_id) + .into_iter() + .filter(|descendant_id| { + history + .conversation(descendant_id) + .is_some_and(|conversation| !conversation.status().is_done()) + }) + .collect() +} + +fn query_targets_existing_conversation(input_query: &InputQuery) -> Option { + match &input_query.which_task { + WhichTask::Task { + conversation_id, .. + } => Some(*conversation_id), + WhichTask::NewConversation => None, + } +} + pub enum BlocklistAIControllerEvent { /// Emitted when a request is sent to the AI agent API. SentRequest { @@ -469,6 +611,13 @@ pub struct BlocklistAIController { pending_local_claude_wakes: HashMap, /// Passive conversations explicitly requested to follow up after actions complete. pending_passive_follow_ups: HashSet, + /// Conversations with finished action results that should not be drained + /// until active child agents in their orchestration subtree finish. + pending_child_blocked_follow_ups: HashSet, + /// Tool proposals that arrived in a provider stream but failed to attach to + /// conversation history. If a proposal cannot be attached, executing it via + /// the stream snapshot fallback would create orphaned tool history. + failed_tool_proposals_by_stream: HashMap>, /// Per-conversation loop detection state for preventing recursive tool failures. loop_detection: HashMap, @@ -601,6 +750,73 @@ impl InputQuery { } impl BlocklistAIController { + fn has_unresolved_ask_user_question( + &self, + conversation_id: AIConversationId, + app: &AppContext, + ) -> bool { + self.action_model + .as_ref(app) + .has_unresolved_ask_user_question_for_conversation(conversation_id, app) + } + + fn should_block_follow_up_for_unresolved_ask_user_question( + &self, + input_query: &InputQuery, + active_conversation_id: Option, + app: &AppContext, + ) -> bool { + self.should_block_submission_for_unresolved_ask_user_question( + query_targets_existing_conversation(input_query), + active_conversation_id, + app, + ) + } + + pub(super) fn should_block_submission_for_unresolved_ask_user_question( + &self, + target_conversation_id: Option, + active_conversation_id: Option, + app: &AppContext, + ) -> bool { + if target_conversation_id + .is_some_and(|target_id| self.has_unresolved_ask_user_question(target_id, app)) + { + return true; + } + + active_conversation_id.is_some_and(|active_id| { + Some(active_id) != target_conversation_id + && self.has_unresolved_ask_user_question(active_id, app) + }) + } + + pub(super) fn log_blocked_submission_for_unresolved_ask_user_question( + &self, + target_conversation_id: Option, + active_conversation_id: Option, + is_queued_prompt: bool, + ctx: &mut ModelContext, + ) { + log::warn!( + "Ignoring user follow-up while AskUserQuestion is unresolved: target_conversation_id={target_conversation_id:?}, active_conversation_id={active_conversation_id:?}" + ); + #[cfg(not(target_family = "wasm"))] + remote_logging::log_model_event( + ctx, + RemoteLogRecord { + level: RemoteLogLevel::Warn, + message: "User follow-up blocked for unresolved AskUserQuestion".to_string(), + context: serde_json::json!({ + "event": "user_follow_up_blocked_unresolved_ask_user_question", + "target_conversation_id": target_conversation_id.map(|id| id.to_string()), + "active_conversation_id": active_conversation_id.map(|id| id.to_string()), + "is_queued_prompt": is_queued_prompt, + }), + }, + ); + } + /// Returns the bundled-skill catalog origin for this controller's active session. pub fn skill_path_origin(&self, ctx: &AppContext) -> SkillPathOrigin { SessionContext::from_session(self.active_session.as_ref(ctx), ctx).skill_path_origin() @@ -745,6 +961,30 @@ impl BlocklistAIController { me.send_follow_up_for_conversation(*conversation_id, ctx); }); + let history_model = BlocklistAIHistoryModel::handle(ctx); + ctx.subscribe_to_model(&history_model, |me, _, event, ctx| { + let BlocklistAIHistoryEvent::UpdatedConversationStatus { + terminal_surface_id, + new_status, + .. + } = event + else { + return; + }; + if *terminal_surface_id != me.terminal_surface_id || !new_status.is_done() { + return; + } + + let pending_parents = me + .pending_child_blocked_follow_ups + .iter() + .copied() + .collect::>(); + for parent_id in pending_parents { + me.maybe_resume_child_blocked_follow_up(parent_id, ctx); + } + }); + ctx.subscribe_to_model(&conversation_selection, |me, _, event, ctx| { let ConversationSelectionEvent::Deactivated { conversation_id, @@ -787,9 +1027,13 @@ impl BlocklistAIController { } => { me.handle_dormant_claude_wake_ready(*conversation_id, wake_message.clone(), ctx); } - // Viewer-mode events are handled by `OrchestrationViewerModel`. - OrchestrationEventStreamerEvent::ChildSpawned { .. } - | OrchestrationEventStreamerEvent::ChildStatusChanged { .. } => {} + // Viewer-mode placeholder materialization is handled by + // `OrchestrationViewerModel`; the owner-side controller only + // mirrors status changes for already-known child conversations. + OrchestrationEventStreamerEvent::ChildSpawned { .. } => {} + OrchestrationEventStreamerEvent::ChildStatusChanged { run_id, status, .. } => { + me.handle_orchestrated_child_status_changed(run_id, status.clone(), ctx); + } }); let crosscheck_reviewer = ctx.add_model(crate::ai::crosscheck::CrosscheckReviewer::new); ctx.subscribe_to_model(&crosscheck_reviewer, move |me, _, event, ctx| { @@ -815,6 +1059,8 @@ impl BlocklistAIController { pending_auto_resume_handles: HashMap::new(), pending_local_claude_wakes: HashMap::new(), pending_passive_follow_ups: HashSet::new(), + pending_child_blocked_follow_ups: HashSet::new(), + failed_tool_proposals_by_stream: HashMap::new(), pending_passive_suggestion_results: HashMap::new(), loop_detection: HashMap::new(), error_retry_counts: HashMap::new(), @@ -848,7 +1094,22 @@ impl BlocklistAIController { let query = input_query.query().to_owned(); let is_existing_task = matches!(&input_query.which_task, WhichTask::Task { .. }); - let (conversation_id, task_id) = match input_query.which_task { + let active_conversation_id = + BlocklistAIHistoryModel::as_ref(ctx).active_conversation_id(self.terminal_surface_id); + if self.should_block_follow_up_for_unresolved_ask_user_question( + &input_query, + active_conversation_id, + ctx, + ) { + self.log_blocked_submission_for_unresolved_ask_user_question( + query_targets_existing_conversation(&input_query), + active_conversation_id, + input_query.queued_query_id.is_some(), + ctx, + ); + return; + } + let (conversation_id, task_id) = match &input_query.which_task { WhichTask::NewConversation => { let conversation = self.start_new_conversation_for_request(ctx); (conversation.id(), conversation.get_root_task_id().clone()) @@ -856,15 +1117,13 @@ impl BlocklistAIController { WhichTask::Task { conversation_id, task_id, - } => (conversation_id, task_id), + } => (*conversation_id, task_id.clone()), }; BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { history.refresh_conversation_backend_without_output(conversation_id, ctx); }); - let active_conversation_id = - BlocklistAIHistoryModel::as_ref(ctx).active_conversation_id(self.terminal_surface_id); let is_same_conversation_running_command_monitor = match &input_query.input_query { InputQueryType::UserSubmittedQueryFromInput { running_command: Some(running_command), @@ -1888,6 +2147,38 @@ impl BlocklistAIController { history.mark_active_conversation_id(conversation_id, self.terminal_surface_id, ctx); }); + let active_child_conversation_ids = active_descendant_conversation_ids( + BlocklistAIHistoryModel::as_ref(ctx), + conversation_id, + ); + if !active_child_conversation_ids.is_empty() { + self.pending_child_blocked_follow_ups + .insert(conversation_id); + log::info!( + "Deferring agent follow-up for conversation {conversation_id:?}: active child conversations remain: {:?}", + active_child_conversation_ids + ); + #[cfg(not(target_family = "wasm"))] + remote_logging::log_model_event( + ctx, + RemoteLogRecord { + level: RemoteLogLevel::Warn, + message: "Agent follow-up deferred for active child agents".to_string(), + context: serde_json::json!({ + "event": "agent_follow_up_deferred_active_child_agents", + "conversation_id": conversation_id.to_string(), + "active_descendant_conversation_ids": active_child_conversation_ids + .iter() + .map(ToString::to_string) + .collect::>(), + }), + }, + ); + return; + } + self.pending_child_blocked_follow_ups + .remove(&conversation_id); + let mut finished_results = self.action_model.update(ctx, |action_model, _| { action_model.drain_finished_action_results(conversation_id) }); @@ -2049,6 +2340,69 @@ impl BlocklistAIController { self.pending_passive_follow_ups.remove(&conversation_id); } + fn maybe_resume_child_blocked_follow_up( + &mut self, + conversation_id: AIConversationId, + ctx: &mut ModelContext, + ) { + if !self + .pending_child_blocked_follow_ups + .contains(&conversation_id) + { + return; + } + if self + .in_flight_response_streams + .has_active_stream_for_conversation(conversation_id, ctx) + { + return; + } + if self + .action_model + .as_ref(ctx) + .has_unfinished_actions_for_conversation(conversation_id) + { + return; + } + if !active_descendant_conversation_ids( + BlocklistAIHistoryModel::as_ref(ctx), + conversation_id, + ) + .is_empty() + { + return; + } + self.send_follow_up_for_conversation(conversation_id, ctx); + } + + fn handle_orchestrated_child_status_changed( + &mut self, + run_id: &str, + status: ConversationStatus, + ctx: &mut ModelContext, + ) { + let Some(conversation_id) = + BlocklistAIHistoryModel::as_ref(ctx).conversation_id_for_agent_id(run_id) + else { + return; + }; + let owns_conversation = BlocklistAIHistoryModel::as_ref(ctx) + .all_live_conversations_for_terminal_surface(self.terminal_surface_id) + .any(|conversation| conversation.id() == conversation_id); + if !owns_conversation { + return; + } + + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| { + history_model.update_conversation_status( + self.terminal_surface_id, + conversation_id, + status, + ctx, + ); + }); + } + fn check_and_record_loop_detection( &mut self, conversation_id: AIConversationId, @@ -3627,6 +3981,7 @@ impl BlocklistAIController { remote_action_tool_name(&action), action.requires_result, ); + let failed_proposal = FailedToolProposal::new(&action, String::new()); let apply_result = history_model.update(ctx, |history_model, ctx| { history_model.apply_domain_tool_proposal( &stream_id, @@ -3640,6 +3995,12 @@ impl BlocklistAIController { log::error!( "Failed to apply Rig tool proposal to conversation: {error:?}" ); + let mut failed_proposal = failed_proposal; + failed_proposal.error = format!("{error:?}"); + self.failed_tool_proposals_by_stream + .entry(stream_id.clone()) + .or_default() + .push(failed_proposal); #[cfg(not(target_family = "wasm"))] { let (action_id, task_id, tool_name, requires_result) = @@ -4131,6 +4492,10 @@ impl BlocklistAIController { let history_action_count = actions_to_queue.len(); let proposed_action_count = proposed_actions.len(); + let failed_tool_proposals = self + .failed_tool_proposals_by_stream + .remove(&stream_id) + .unwrap_or_default(); let mut queued_action_ids = actions_to_queue .iter() .map(|action| action.id.clone()) @@ -4142,46 +4507,46 @@ impl BlocklistAIController { actions_to_queue.push(action.clone()); } } + let active_child_conversation_ids = + active_descendant_conversation_ids(history_model.as_ref(ctx), conversation_id); + let queue_decision = tool_queue_decision( + cancellation.is_some(), + is_any_exchange_unfinished, + !failed_tool_proposals.is_empty(), + !active_child_conversation_ids.is_empty(), + actions_to_queue.len(), + queued_from_stream_snapshot_count, + ); #[cfg(not(target_family = "wasm"))] { - let will_queue_actions = cancellation.is_none() && !is_any_exchange_unfinished; - let used_stream_snapshot_fallback = - will_queue_actions && queued_from_stream_snapshot_count > 0; - let decision = if cancellation.is_some() { - "cancelled" - } else if is_any_exchange_unfinished { - "unfinished_exchange" - } else if actions_to_queue.is_empty() { - "no_actions" - } else if used_stream_snapshot_fallback { - "queue_actions_with_stream_snapshot_fallback" - } else { - "queue_actions" - }; - let level = if used_stream_snapshot_fallback { - RemoteLogLevel::Warn - } else { - RemoteLogLevel::Info - }; remote_logging::log_model_event( ctx, RemoteLogRecord { - level, + level: queue_decision.remote_log_level(), message: "Tool queue decision".to_string(), context: serde_json::json!({ "event": "tool_queue_decision", "stream_id": stream_id.as_str(), "conversation_id": conversation_id.to_string(), - "decision": decision, + "decision": queue_decision.label(), "history_action_count": history_action_count, "proposed_action_count": proposed_action_count, "candidate_action_count": actions_to_queue.len(), - "will_queue_action_count": if will_queue_actions { + "will_queue_action_count": if queue_decision.will_queue_actions() { actions_to_queue.len() } else { 0 }, "queued_from_stream_snapshot_count": queued_from_stream_snapshot_count, + "failed_tool_proposal_count": failed_tool_proposals.len(), + "failed_tool_proposals": failed_tool_proposals + .iter() + .map(FailedToolProposal::to_remote_log_value) + .collect::>(), + "active_descendant_conversation_ids": active_child_conversation_ids + .iter() + .map(ToString::to_string) + .collect::>(), "was_passive_request": was_passive_request, "is_any_exchange_unfinished": is_any_exchange_unfinished, "cancellation_reason": cancellation @@ -4248,7 +4613,17 @@ impl BlocklistAIController { ctx, ); }); - } else if !actions_to_queue.is_empty() { + } else if !failed_tool_proposals.is_empty() { + log::warn!( + "Skipping tool queue for stream {stream_id:?}: failed tool proposal attach count={}", + failed_tool_proposals.len() + ); + } else if !active_child_conversation_ids.is_empty() { + log::info!( + "Skipping tool queue for conversation {conversation_id:?}: active child conversations remain: {:?}", + active_child_conversation_ids + ); + } else if queue_decision.will_queue_actions() { log::info!( "[bedrock-debug] AfterStreamFinished: queuing {} actions", actions_to_queue.len() diff --git a/app/src/ai/blocklist/controller/slash_command.rs b/app/src/ai/blocklist/controller/slash_command.rs index 7e9fcbd4..1bf12ae3 100644 --- a/app/src/ai/blocklist/controller/slash_command.rs +++ b/app/src/ai/blocklist/controller/slash_command.rs @@ -148,6 +148,19 @@ impl SlashCommandRequest { is_for_same_conversation: active_conversation_id .is_some_and(|id| id == conversation_id), }; + if controller.should_block_submission_for_unresolved_ask_user_question( + Some(conversation_id), + active_conversation_id, + ctx, + ) { + controller.log_blocked_submission_for_unresolved_ask_user_question( + Some(conversation_id), + active_conversation_id, + is_queued_prompt, + ctx, + ); + return; + } if let Some(active_conversation_id) = active_conversation_id { controller.cancel_conversation_progress( active_conversation_id, diff --git a/app/src/ai/blocklist/controller_tests.rs b/app/src/ai/blocklist/controller_tests.rs index 8d78ff90..4d7c83b7 100644 --- a/app/src/ai/blocklist/controller_tests.rs +++ b/app/src/ai/blocklist/controller_tests.rs @@ -1,16 +1,18 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; +use ai::agent::action::{AskUserQuestionItem, AskUserQuestionType}; use chrono::Local; use uuid::Uuid; use warp_multi_agent_api::response_event; -use warpui::{App, SingletonEntity}; +use warpui::{App, EntityId, SingletonEntity}; -use crate::ai::agent::conversation::AIConversationId; +use crate::ai::agent::conversation::{AIConversationId, ConversationStatus}; use crate::ai::agent::task::TaskId; use crate::ai::agent::{ - AIAgentAttachment, AIAgentContext, AIAgentInput, CancellationReason, ImageContext, - PassiveSuggestionTrigger, RunningCommand, UserQueryMode, + AIAgentAction, AIAgentActionId, AIAgentActionType, AIAgentAttachment, AIAgentContext, + AIAgentInput, CancellationReason, ImageContext, PassiveSuggestionTrigger, RunningCommand, + UserQueryMode, }; use crate::ai::ambient_agents::AmbientAgentTaskId; use crate::ai::blocklist::{ @@ -20,12 +22,33 @@ use crate::ai::blocklist::{ use crate::ai::llms::LLMId; use crate::persistence::model::{AcpConversationData, AgentBackend}; use crate::terminal::model::block::BlockId; +use crate::test_util::settings::initialize_history_persistence_for_tests; use crate::test_util::terminal::{add_window_with_terminal, initialize_app_for_terminal_view}; fn new_ambient_agent_task_id() -> AmbientAgentTaskId { Uuid::new_v4().to_string().parse().unwrap() } +fn ask_user_question_action(action_id: &str) -> AIAgentAction { + AIAgentAction { + id: AIAgentActionId::from(action_id.to_string()), + task_id: TaskId::new(format!("task-{action_id}")), + action: AIAgentActionType::AskUserQuestion { + questions: vec![AskUserQuestionItem { + question_id: "q1".to_owned(), + question: "Which path should the agent take?".to_owned(), + question_type: AskUserQuestionType::MultipleChoice { + is_multiselect: false, + options: vec![], + supports_other: true, + }, + }], + }, + requires_result: true, + tool_name: Some("ask_user_question".to_owned()), + } +} + fn image_attachment(file_name: &str) -> PendingAttachment { PendingAttachment::Image(ImageContext { data: String::new(), @@ -98,6 +121,126 @@ fn no_action_tool_error_recovery_ignores_normal_answers_and_non_failed_tools() { ); } +#[test] +fn tool_queue_decision_blocks_failed_tool_proposal_before_snapshot_fallback() { + assert_eq!( + super::tool_queue_decision(false, false, true, false, 1, 1,), + super::ToolQueueDecision::BlockedFailedToolProposal + ); +} + +#[test] +fn tool_queue_decision_blocks_parent_tools_while_child_agents_are_active() { + assert_eq!( + super::tool_queue_decision(false, false, false, true, 2, 0,), + super::ToolQueueDecision::BlockedActiveChildAgents + ); +} + +#[test] +fn tool_queue_decision_preserves_existing_terminal_precedence() { + assert_eq!( + super::tool_queue_decision(true, false, true, true, 1, 1,), + super::ToolQueueDecision::Cancelled + ); + assert_eq!( + super::tool_queue_decision(false, true, true, true, 1, 1,), + super::ToolQueueDecision::UnfinishedExchange + ); +} + +#[test] +fn tool_queue_decision_uses_snapshot_fallback_only_when_unblocked() { + let decision = super::tool_queue_decision(false, false, false, false, 1, 1); + + assert_eq!( + decision, + super::ToolQueueDecision::QueueActionsWithStreamSnapshotFallback + ); + assert!(decision.will_queue_actions()); +} + +#[test] +fn query_targets_existing_conversation_extracts_existing_task_id() { + let conversation_id = AIConversationId::new(); + let task_id = TaskId::new("task".to_owned()); + + assert_eq!( + super::query_targets_existing_conversation(&super::InputQuery { + which_task: super::WhichTask::Task { + conversation_id, + task_id, + }, + input_query: super::InputQueryType::UserSubmittedQueryFromInput { + query: "Continue".to_owned(), + static_query_type: None, + running_command: None, + }, + additional_attachments: HashMap::new(), + queued_query_id: None, + }), + Some(conversation_id) + ); + assert_eq!( + super::query_targets_existing_conversation(&super::InputQuery { + which_task: super::WhichTask::NewConversation, + input_query: super::InputQueryType::UserSubmittedQueryFromInput { + query: "new task".to_owned(), + static_query_type: None, + running_command: None, + }, + additional_attachments: HashMap::new(), + queued_query_id: None, + }), + None + ); +} + +#[test] +fn active_descendant_conversation_ids_filters_done_children() { + App::test((), |mut app| async move { + initialize_history_persistence_for_tests(&mut app); + let terminal_view_id = EntityId::new(); + let history_model = app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test()); + + let orchestrator_id = history_model.update(&mut app, |history_model, ctx| { + history_model.start_new_conversation(terminal_view_id, false, false, false, ctx) + }); + let child_id = history_model.update(&mut app, |history_model, ctx| { + history_model.start_new_child_conversation( + terminal_view_id, + "manifest-owner".to_string(), + orchestrator_id, + None, + ctx, + ) + }); + + history_model.read(&app, |history_model, _| { + assert_eq!( + super::active_descendant_conversation_ids(history_model, orchestrator_id), + vec![child_id] + ); + }); + + history_model.update(&mut app, |history_model, ctx| { + history_model.update_conversation_status( + terminal_view_id, + child_id, + ConversationStatus::Success, + ctx, + ); + }); + + history_model.read(&app, |history_model, _| { + assert_eq!( + super::active_descendant_conversation_ids(history_model, orchestrator_id), + Vec::::new() + ); + }); + }); +} + #[test] fn acp_backend_model_identity_does_not_claim_a_provider_model() { assert_eq!(super::acp_backend_model_id(&AgentBackend::Provider), None); @@ -380,6 +523,168 @@ fn cancelling_conversation_aborts_pending_auto_resume() { }); } +#[test] +fn user_follow_up_does_not_cancel_unresolved_ask_user_question() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + let terminal = add_window_with_terminal(&mut app, None); + + let sent_request_count = Arc::new(Mutex::new(0)); + let controller = terminal.read(&app, |terminal, _| terminal.ai_controller().clone()); + let sent_request_count_for_subscription = Arc::clone(&sent_request_count); + app.update(|ctx| { + ctx.subscribe_to_model(&controller, move |_, event, _| { + if matches!(event, super::BlocklistAIControllerEvent::SentRequest { .. }) { + *sent_request_count_for_subscription.lock().unwrap() += 1; + } + }); + }); + + let conversation_id = terminal.update(&mut app, |terminal, ctx| { + let terminal_surface_id = terminal.id(); + let conversation_id = + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| { + let conversation_id = history_model.start_new_conversation( + terminal_surface_id, + false, + false, + false, + ctx, + ); + history_model.mark_active_conversation_id( + conversation_id, + terminal_surface_id, + ctx, + ); + history_model.update_conversation_status( + terminal_surface_id, + conversation_id, + ConversationStatus::Blocked { + blocked_action: "ask_user_question".to_owned(), + }, + ctx, + ); + conversation_id + }); + + terminal.ai_controller().update(ctx, |controller, ctx| { + controller.action_model.update(ctx, |action_model, _| { + action_model.push_pending_action_for_test( + conversation_id, + ask_user_question_action("ask-1"), + ); + }); + }); + + conversation_id + }); + + terminal.update(&mut app, |terminal, ctx| { + terminal.ai_controller().update(ctx, |controller, ctx| { + controller.send_user_query_in_conversation( + "Continue".to_owned(), + conversation_id, + None, + ctx, + ); + }); + }); + + assert_eq!(*sent_request_count.lock().unwrap(), 0); + controller.read(&app, |controller, ctx| { + assert!(controller + .action_model + .as_ref(ctx) + .has_unresolved_ask_user_question_for_conversation(conversation_id, ctx)); + }); + }); +} + +#[test] +fn new_conversation_submission_does_not_cancel_active_unresolved_ask_user_question() { + App::test((), |mut app| async move { + initialize_app_for_terminal_view(&mut app); + let terminal = add_window_with_terminal(&mut app, None); + + let (conversation_id, initial_conversation_count) = + terminal.update(&mut app, |terminal, ctx| { + let terminal_surface_id = terminal.id(); + let conversation_id = + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, ctx| { + let conversation_id = history_model.start_new_conversation( + terminal_surface_id, + false, + false, + false, + ctx, + ); + history_model.mark_active_conversation_id( + conversation_id, + terminal_surface_id, + ctx, + ); + history_model.update_conversation_status( + terminal_surface_id, + conversation_id, + ConversationStatus::Blocked { + blocked_action: "ask_user_question".to_owned(), + }, + ctx, + ); + conversation_id + }); + + terminal.ai_controller().update(ctx, |controller, ctx| { + controller.action_model.update(ctx, |action_model, _| { + action_model.push_pending_action_for_test( + conversation_id, + ask_user_question_action("ask-new-task"), + ); + }); + }); + + let initial_conversation_count = BlocklistAIHistoryModel::as_ref(ctx) + .all_live_conversations() + .len(); + (conversation_id, initial_conversation_count) + }); + + terminal.update(&mut app, |terminal, ctx| { + terminal.ai_controller().update(ctx, |controller, ctx| { + controller.send_user_query_in_new_conversation( + "Start another task".to_owned(), + None, + crate::ai::agent::EntrypointType::UserInitiated, + None, + ctx, + ); + }); + }); + + terminal.read(&app, |terminal, ctx| { + let history_model = BlocklistAIHistoryModel::as_ref(ctx); + assert_eq!( + history_model.all_live_conversations().len(), + initial_conversation_count + ); + assert_eq!( + history_model + .conversation(&conversation_id) + .map(|c| c.status()), + Some(&ConversationStatus::Blocked { + blocked_action: "ask_user_question".to_owned() + }) + ); + assert!(terminal + .ai_controller() + .as_ref(ctx) + .action_model + .as_ref(ctx) + .has_unresolved_ask_user_question_for_conversation(conversation_id, ctx)); + }); + }); +} + #[test] fn mock_response_stream_updates_history_through_controller() { App::test((), |mut app| async move { diff --git a/app/src/ai/conversation_details_panel.rs b/app/src/ai/conversation_details_panel.rs index 5074241a..67920f4b 100644 --- a/app/src/ai/conversation_details_panel.rs +++ b/app/src/ai/conversation_details_panel.rs @@ -602,7 +602,13 @@ impl ConversationDetailsData { #[derive(Debug, Clone)] pub enum ConversationDetailsPanelEvent { Close, - OpenPlanNotebook { notebook_uid: NotebookId }, + OpenPlanNotebook { + notebook_uid: NotebookId, + }, + ShowDeleteConfirmationDialog { + conversation_id: AIConversationId, + conversation_title: String, + }, } /// Actions for the ConversationDetailsPanel. @@ -878,14 +884,20 @@ impl ConversationDetailsPanel { )) } PanelMode::Conversation { - ai_conversation_id, .. + ai_conversation_id, + status, + .. } => { let conversation_id = *ai_conversation_id.as_ref()?; - Some(ActionButtonsConfig::for_conversation( + let mut config = ActionButtonsConfig::for_conversation( conversation_id, open_action, data.copy_link_url.clone(), - )) + ); + if !status.as_ref().is_some_and(ConversationStatus::is_done) { + config.delete_conversation_id = None; + } + Some(config) } } } @@ -1003,6 +1015,14 @@ impl ConversationDetailsPanel { ctx.clipboard() .write(ClipboardContent::plain_text(link.clone())); } + AgentDetailsButtonEvent::DeleteConversation { conversation_id } => { + ctx.emit( + ConversationDetailsPanelEvent::ShowDeleteConfirmationDialog { + conversation_id: *conversation_id, + conversation_title: self.data.title.clone(), + }, + ); + } } } diff --git a/app/src/pane_group/mod.rs b/app/src/pane_group/mod.rs index 00da9d49..a6f850cf 100644 --- a/app/src/pane_group/mod.rs +++ b/app/src/pane_group/mod.rs @@ -681,6 +681,11 @@ pub enum Event { flavor: ToastFlavor, pane_id: Option, }, + ShowDeleteConversationConfirmationDialog { + conversation_id: AIConversationId, + conversation_title: String, + terminal_view_id: Option, + }, SignupAnonymousUser { entrypoint: AnonymousUserSignupEntrypoint, }, diff --git a/app/src/pane_group/pane/terminal_pane.rs b/app/src/pane_group/pane/terminal_pane.rs index 52762148..74076231 100644 --- a/app/src/pane_group/pane/terminal_pane.rs +++ b/app/src/pane_group/pane/terminal_pane.rs @@ -1190,6 +1190,19 @@ fn handle_terminal_view_event( Event::OpenShareSessionDeniedModal => { group.open_share_session_denied_modal(terminal_pane_id, ctx); } + Event::ShowDeleteConversationConfirmationDialog { + conversation_id, + conversation_title, + terminal_view_id, + } => { + ctx.emit( + pane_group::Event::ShowDeleteConversationConfirmationDialog { + conversation_id: *conversation_id, + conversation_title: conversation_title.clone(), + terminal_view_id: *terminal_view_id, + }, + ); + } Event::FocusSession => { group.focus_pane(terminal_pane_id.into(), true, ctx); ctx.emit(pane_group::Event::FocusPaneGroup); diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index d9131424..d43d4408 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -1822,6 +1822,11 @@ pub enum Event { OpenShareSessionModal { open_source: SharedSessionActionSource, }, + ShowDeleteConversationConfirmationDialog { + conversation_id: AIConversationId, + conversation_title: String, + terminal_view_id: Option, + }, OpenShareSessionDeniedModal, /// Used to focus and bring this session to the foreground. FocusSession, @@ -4306,6 +4311,16 @@ impl TerminalView { let object_uid = SyncId::from(*notebook_uid).uid(); ctx.emit(Event::OpenGalaxyDriveObjectInPane(object_uid)); } + ConversationDetailsPanelEvent::ShowDeleteConfirmationDialog { + conversation_id, + conversation_title, + } => { + ctx.emit(Event::ShowDeleteConversationConfirmationDialog { + conversation_id: *conversation_id, + conversation_title: conversation_title.clone(), + terminal_view_id: Some(ctx.view_id()), + }); + } } }); @@ -26293,6 +26308,10 @@ impl TypedActionView for TerminalView { "Execute rewind to before this point in the AI conversation.".to_owned(), GalaxyA11yRole::ButtonRole, )), + RequestDeleteCurrentConversation => Custom(AccessibilityContent::new_without_help( + "Show confirmation dialog to delete this conversation.".to_owned(), + GalaxyA11yRole::ButtonRole, + )), SelectAIAttachedBlock(_) => Custom(AccessibilityContent::new_without_help( "Click on a block attached as context to this AI query.".to_owned(), GalaxyA11yRole::ButtonRole, @@ -26535,6 +26554,23 @@ impl TypedActionView for TerminalView { ); } } + RequestDeleteCurrentConversation => { + let Some(conversation_id) = self.active_conversation_id(ctx).or_else(|| { + self.ai_context_model + .as_ref(ctx) + .selected_conversation_id(ctx) + }) else { + return; + }; + let conversation_title = self + .selected_conversation_display_title(ctx) + .unwrap_or_else(|| "Conversation".to_string()); + ctx.emit(Event::ShowDeleteConversationConfirmationDialog { + conversation_id, + conversation_title, + terminal_view_id: Some(ctx.view_id()), + }); + } CloseContextMenu => self.close_context_menu(ctx, true), Paste => self.paste(false, ctx), Copy => self.copy(ctx), diff --git a/app/src/terminal/view/action.rs b/app/src/terminal/view/action.rs index 6b58c6b6..531b3e08 100644 --- a/app/src/terminal/view/action.rs +++ b/app/src/terminal/view/action.rs @@ -246,6 +246,8 @@ pub enum TerminalAction { exchange_id: AIAgentExchangeId, conversation_id: AIConversationId, }, + /// Ask the workspace to confirm deletion of the active conversation for this terminal view. + RequestDeleteCurrentConversation, SelectAllBlocks, ExpandBlockSelectionAbove, ExpandBlockSelectionBelow, @@ -578,6 +580,7 @@ impl fmt::Debug for TerminalAction { write!(f, "OpenInputContextMenu {{ position: {position:?} }}") } InputContextMenuItem(action) => write!(f, "InputContextMenuItem({action:?})"), + RequestDeleteCurrentConversation => f.write_str("RequestDeleteCurrentConversation"), SelectAllBlocks => f.write_str("SelectAllBlocks"), ExpandBlockSelectionAbove => f.write_str("ExpandBlockSelectionAbove"), ExpandBlockSelectionBelow => f.write_str("ExpandBlockSelectionBelow"), diff --git a/app/src/terminal/view/pane_impl.rs b/app/src/terminal/view/pane_impl.rs index f8a6d537..1e03d8d7 100644 --- a/app/src/terminal/view/pane_impl.rs +++ b/app/src/terminal/view/pane_impl.rs @@ -708,6 +708,19 @@ impl BackingView for TerminalView { ); } + if self.current_conversation_can_be_deleted(ctx) { + if !items.is_empty() { + items.push(MenuItem::Separator); + } + + items.push( + MenuItemFields::new("Delete conversation") + .with_override_text_color(Appearance::as_ref(ctx).theme().ansi_fg_red()) + .with_on_select_action(TerminalAction::RequestDeleteCurrentConversation) + .into_item(), + ); + } + items } @@ -1003,6 +1016,23 @@ impl TerminalView { )) } + fn current_conversation_can_be_deleted(&self, ctx: &AppContext) -> bool { + let Some(conversation_id) = self.active_conversation_id(ctx).or_else(|| { + self.ai_context_model + .as_ref(ctx) + .selected_conversation_id(ctx) + }) else { + return false; + }; + + let history = BlocklistAIHistoryModel::as_ref(ctx); + let Some(conversation) = history.conversation(&conversation_id) else { + return false; + }; + + !conversation.is_empty() && conversation.status().is_done() + } + pub fn selected_conversation_is_empty(&self, ctx: &AppContext) -> bool { self.selected_conversation_for_user_facing_chrome(ctx) .is_some_and(|conversation| conversation.is_empty()) diff --git a/app/src/workspace/action.rs b/app/src/workspace/action.rs index 02b1b472..6246b560 100644 --- a/app/src/workspace/action.rs +++ b/app/src/workspace/action.rs @@ -44,6 +44,7 @@ use crate::terminal::view::inline_banner::ZeroStatePromptSuggestionType; use crate::themes::theme::AnsiColorIdentifier; use crate::themes::theme_chooser::ThemeChooserMode; use crate::workflows::{WorkflowSelectionSource, WorkflowSource, WorkflowType}; +use crate::workspace::delete_conversation_confirmation_dialog::DeleteConversationTarget; use crate::workspace::tab_group::TabGroupId; use crate::workspace::PaneViewLocator; @@ -794,6 +795,10 @@ pub enum WorkspaceAction { conversation_id: AIConversationId, terminal_view_id: Option, }, + /// Execute the actual deletion of multiple conversations after confirmation + ExecuteDeleteConversations { + conversations: Vec, + }, /// Open the canonical ambient agent conversation pane and attach it to a live session. OpenOrAttachAmbientAgentConversation { session_id: SessionId, @@ -1167,6 +1172,7 @@ impl WorkspaceAction { | ShowRewindConfirmationDialog { .. } | ExecuteRewindAIConversation { .. } | ExecuteDeleteConversation { .. } + | ExecuteDeleteConversations { .. } | OpenOrAttachAmbientAgentConversation { .. } | OpenConversationTranscriptViewer { .. } | OpenLightbox { .. } diff --git a/app/src/workspace/delete_conversation_confirmation_dialog.rs b/app/src/workspace/delete_conversation_confirmation_dialog.rs index 43001479..dc9c25ba 100644 --- a/app/src/workspace/delete_conversation_confirmation_dialog.rs +++ b/app/src/workspace/delete_conversation_confirmation_dialog.rs @@ -36,13 +36,46 @@ pub fn init(app: &mut AppContext) { const DIALOG_WIDTH: f32 = 460.; -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct DeleteConversationDialogSource { + pub conversations: Vec, +} + +#[derive(Clone, Debug)] +pub struct DeleteConversationTarget { pub conversation_id: AIConversationId, pub conversation_title: String, pub terminal_view_id: Option, } +impl DeleteConversationDialogSource { + pub fn single( + conversation_id: AIConversationId, + conversation_title: String, + terminal_view_id: Option, + ) -> Self { + Self { + conversations: vec![DeleteConversationTarget { + conversation_id, + conversation_title, + terminal_view_id, + }], + } + } + + pub fn multiple(conversations: Vec) -> Self { + Self { conversations } + } + + pub fn len(&self) -> usize { + self.conversations.len() + } + + pub fn is_empty(&self) -> bool { + self.conversations.is_empty() + } +} + pub struct DeleteConversationConfirmationDialog { cancel_button: ViewHandle, delete_button: ViewHandle, @@ -101,15 +134,34 @@ impl View for DeleteConversationConfirmationDialog { let title = self .source .as_ref() - .map(|s| format!("Delete '{}'?", s.conversation_title)) + .map(|source| match source.conversations.as_slice() { + [conversation] => format!("Delete '{}'?", conversation.conversation_title), + conversations => format!("Delete {} conversations?", conversations.len()), + }) .unwrap_or_else(|| "Delete conversation?".into()); + let body = self + .source + .as_ref() + .map(|source| { + if source.len() == 1 { + "This conversation will be permanently deleted. This action cannot be undone." + .to_string() + } else { + format!( + "{} conversations will be permanently deleted. This action cannot be undone.", + source.len() + ) + } + }) + .unwrap_or_else(|| { + "This conversation will be permanently deleted. This action cannot be undone." + .to_string() + }); + let dialog = Dialog::new( title, - Some( - "This conversation will be permanently deleted. This action cannot be undone." - .into(), - ), + Some(body), UiComponentStyles { width: Some(DIALOG_WIDTH), ..dialog_styles(appearance) @@ -165,6 +217,10 @@ impl TypedActionView for DeleteConversationConfirmationDialog { log::error!("Delete confirm button pressed with no source"); return; }; + if source.is_empty() { + log::error!("Delete confirm button pressed with no conversations"); + return; + } ctx.emit(DeleteConversationConfirmationEvent::Confirm { source }); } DeleteConversationConfirmationAction::Cancel => { diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 5533c2f4..a4a6772a 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -133,7 +133,7 @@ use super::close_session_confirmation_dialog::{ }; use super::delete_conversation_confirmation_dialog::{ DeleteConversationConfirmationDialog, DeleteConversationConfirmationEvent, - DeleteConversationDialogSource, + DeleteConversationDialogSource, DeleteConversationTarget, }; use super::hoa_onboarding::{ mark_hoa_onboarding_completed, HoaOnboardingFlow, HoaOnboardingFlowEvent, HoaOnboardingStep, @@ -6154,6 +6154,20 @@ impl Workspace { false, ); } + AgentManagementViewEvent::ShowDeleteConfirmationDialog { + conversation_id, + conversation_title, + terminal_view_id, + } => { + self.show_delete_conversation_confirmation_dialog( + DeleteConversationDialogSource::single( + *conversation_id, + conversation_title.clone(), + *terminal_view_id, + ), + ctx, + ); + } } } @@ -6343,11 +6357,17 @@ impl Workspace { terminal_view_id, } => { self.show_delete_conversation_confirmation_dialog( - DeleteConversationDialogSource { - conversation_id: *conversation_id, - conversation_title: conversation_title.clone(), - terminal_view_id: *terminal_view_id, - }, + DeleteConversationDialogSource::single( + *conversation_id, + conversation_title.clone(), + *terminal_view_id, + ), + ctx, + ); + } + LeftPanelEvent::ShowBulkDeleteConfirmationDialog { conversations } => { + self.show_delete_conversation_confirmation_dialog( + DeleteConversationDialogSource::multiple(conversations.clone()), ctx, ); } @@ -11208,13 +11228,22 @@ impl Workspace { DeleteConversationConfirmationEvent::Confirm { source } => { self.current_workspace_state .is_delete_conversation_confirmation_dialog_open = false; - self.handle_action( - &WorkspaceAction::ExecuteDeleteConversation { - conversation_id: source.conversation_id, - terminal_view_id: source.terminal_view_id, - }, - ctx, - ); + if let [conversation] = source.conversations.as_slice() { + self.handle_action( + &WorkspaceAction::ExecuteDeleteConversation { + conversation_id: conversation.conversation_id, + terminal_view_id: conversation.terminal_view_id, + }, + ctx, + ); + } else { + self.handle_action( + &WorkspaceAction::ExecuteDeleteConversations { + conversations: source.conversations.clone(), + }, + ctx, + ); + } ctx.focus(&self.left_panel_view); ctx.notify(); } @@ -16625,6 +16654,20 @@ impl Workspace { toast_stack.add_ephemeral_toast(toast, ctx); }); } + pane_group::Event::ShowDeleteConversationConfirmationDialog { + conversation_id, + conversation_title, + terminal_view_id, + } => { + self.show_delete_conversation_confirmation_dialog( + DeleteConversationDialogSource::single( + *conversation_id, + conversation_title.clone(), + *terminal_view_id, + ), + ctx, + ); + } pane_group::Event::SignupAnonymousUser { entrypoint } => { self.initiate_user_signup(*entrypoint, ctx); } @@ -18173,6 +18216,68 @@ impl Workspace { ctx.notify(); } + fn delete_conversation_targets( + &mut self, + conversations: Vec, + window_id: WindowId, + ctx: &mut ViewContext, + ) { + let mut seen = HashSet::new(); + let conversations = conversations + .into_iter() + .filter(|target| seen.insert(target.conversation_id)) + .collect::>(); + if conversations.is_empty() { + return; + } + + for target in &conversations { + // Exit agent view first if this conversation is currently expanded. + // This must happen before updating BlocklistAIHistoryModel to avoid + // circular model references. + if let Some(controller) = ActiveAgentViewsModel::as_ref(ctx) + .get_controller_for_conversation(target.conversation_id, ctx) + { + let succesfully_exited_agent_view = controller.update(ctx, |controller, ctx| { + controller.exit_agent_view(ctx); + !controller.is_active() + }); + + if !succesfully_exited_agent_view { + ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { + toast_stack.add_ephemeral_toast( + DismissibleToast::error( + "Failed to delete conversation. Please exit the agent view and try again.".to_string(), + ), + window_id, + ctx, + ); + }); + return; + } + } + } + + let deleted_count = conversations.len(); + for target in conversations { + conversation_utils::delete_conversation( + target.conversation_id, + target.terminal_view_id, + ctx, + ); + } + + send_telemetry_from_ctx!(TelemetryEvent::ConversationListItemDeleted, ctx); + let message = if deleted_count == 1 { + "Conversation deleted".to_string() + } else { + format!("{deleted_count} conversations deleted") + }; + ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { + toast_stack.add_ephemeral_toast(DismissibleToast::success(message), window_id, ctx); + }); + } + pub fn show_native_modal( &mut self, dialog: AlertDialogWithCallbacks, @@ -25520,42 +25625,15 @@ impl TypedActionView for Workspace { conversation_id, terminal_view_id, } => { - // Exit agent view first if this conversation is currently expanded. - // This must happen before updating BlocklistAIHistoryModel to avoid - // circular model references. - if let Some(controller) = ActiveAgentViewsModel::as_ref(ctx) - .get_controller_for_conversation(*conversation_id, ctx) - { - let succesfully_exited_agent_view = - controller.update(ctx, |controller, ctx| { - controller.exit_agent_view(ctx); - !controller.is_active() - }); - - if !succesfully_exited_agent_view { - ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { - toast_stack.add_ephemeral_toast( - DismissibleToast::error( - "Failed to delete conversation. Please exit the agent view and try again.".to_string(), - ), - window_id, - ctx, - ); - }); - return; - } - } - - conversation_utils::delete_conversation(*conversation_id, *terminal_view_id, ctx); - - send_telemetry_from_ctx!(TelemetryEvent::ConversationListItemDeleted, ctx); - ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { - toast_stack.add_ephemeral_toast( - DismissibleToast::success("Conversation deleted".to_string()), - window_id, - ctx, - ); - }); + let target = DeleteConversationTarget { + conversation_id: *conversation_id, + conversation_title: String::new(), + terminal_view_id: *terminal_view_id, + }; + self.delete_conversation_targets(vec![target], window_id, ctx); + } + ExecuteDeleteConversations { conversations } => { + self.delete_conversation_targets(conversations.clone(), window_id, ctx); } #[cfg(target_family = "wasm")] ToggleConversationTranscriptDetailsPanel => { diff --git a/app/src/workspace/view/conversation_list/item.rs b/app/src/workspace/view/conversation_list/item.rs index 31b0927e..55029157 100644 --- a/app/src/workspace/view/conversation_list/item.rs +++ b/app/src/workspace/view/conversation_list/item.rs @@ -12,6 +12,7 @@ use galaxyui::elements::{ use galaxyui::fonts::{Properties, Weight}; use galaxyui::platform::Cursor; use galaxyui::text_layout::TextStyle; +use galaxyui::ui_components::checkbox::Checkbox; use galaxyui::ui_components::components::{UiComponent, UiComponentStyles}; use galaxyui::ui_components::text_input::TextInput; use galaxyui::{AppContext, SingletonEntity, ViewHandle}; @@ -51,6 +52,7 @@ const LIST_ITEM_AGENT_SIZE: f32 = 22.; /// the conversation list reads better with the status sitting slightly further out than /// on the other surfaces. const LIST_ITEM_OVERLAY_EXTRA_OVERHANG: f32 = 0.05; +const BULK_CHECKBOX_SIZE: f32 = 14.0; /// Generate a position ID for a conversation list item fn conversation_item_position_id(id: &AgentConversationEntryId) -> String { @@ -99,6 +101,8 @@ pub struct ItemProps<'a> { pub rename_editor: Option<&'a ViewHandle>, pub sharing_dialog: &'a ViewHandle, pub is_share_dialog_open: bool, + pub is_bulk_delete_mode: bool, + pub is_bulk_delete_selected: bool, pub list_position_id: &'a str, pub tooltip_opens_right: bool, } @@ -194,6 +198,8 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box { rename_editor, sharing_dialog, is_share_dialog_open, + is_bulk_delete_mode, + is_bulk_delete_selected, list_position_id, tooltip_opens_right, } = props; @@ -255,16 +261,21 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box { theme.background(), ); - let icon_and_title_row = Shrinkable::new( - 1.0, - Flex::row() - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_spacing(ICON_SPACING) - .with_child(icon_element) - .with_child(Shrinkable::new(1.0, title_element).finish()) - .finish(), - ) - .finish(); + let mut title_row = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_spacing(ICON_SPACING); + if is_bulk_delete_mode { + title_row.add_child(render_bulk_delete_checkbox( + state.overflow_button_state.clone(), + is_bulk_delete_selected, + conversation.capabilities.can_delete, + appearance, + )); + } + title_row.add_child(icon_element); + title_row.add_child(Shrinkable::new(1.0, title_element).finish()); + + let icon_and_title_row = Shrinkable::new(1.0, title_row.finish()).finish(); let timestamp = Text::new_inline( format_approx_duration_from_now_utc(conversation.display.last_updated), @@ -274,6 +285,13 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box { .with_color(theme.sub_text_color(theme.background()).into()) .finish(); + let bottom_row_left_padding = status_element_size + + ICON_SPACING + + if is_bulk_delete_mode { + BULK_CHECKBOX_SIZE + ICON_SPACING + } else { + 0. + }; let bottom_row = if let Some(subtext) = format_item_subtext(conversation, app) { let subtext_element = Shrinkable::new( 1.0, @@ -292,7 +310,7 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box { .with_child(timestamp) .finish(), ) - .with_padding_left(status_element_size + ICON_SPACING) + .with_padding_left(bottom_row_left_padding) .finish() } else { // If no subtext, still show timestamp in the bottom row @@ -303,7 +321,7 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box { .with_child(timestamp) .finish(), ) - .with_padding_left(status_element_size + ICON_SPACING) + .with_padding_left(bottom_row_left_padding) .finish() }; @@ -313,7 +331,7 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box { .with_child(bottom_row) .finish(); - let can_open = conversation.capabilities.can_open; + let can_open = conversation.capabilities.can_open && !is_bulk_delete_mode; let tooltip_text = truncate_from_end(&conversation.display.title, MAX_TOOLTIP_LENGTH); let overflow_button_state = state.overflow_button_state.clone(); let hoverable = Hoverable::new(state.mouse_state.clone(), move |_| { @@ -332,7 +350,8 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box { let mut stack = Stack::new().with_child(container.finish()); // We show the overflow menu button when the item is selected, or the overflow menu is already open. - if !is_renaming + if !is_bulk_delete_mode + && !is_renaming && (is_selected || !matches!(overflow_menu_display, OverflowMenuDisplay::Closed)) { let button_style = UiComponentStyles::default() @@ -373,7 +392,8 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box { } // Hide the tooltip when the overflow menu is being shown so that they don't overlap. - if !is_renaming + if !is_bulk_delete_mode + && !is_renaming && is_selected && matches!(overflow_menu_display, OverflowMenuDisplay::Closed) { @@ -396,6 +416,9 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box { .on_right_click({ let list_position_id = list_position_id.to_string(); move |ctx, _, position| { + if is_bulk_delete_mode { + return; + } let Some(parent_bounds) = ctx.element_position_by_id(&list_position_id) else { log::warn!("Could not retrieve the position of the conversation list for overflow menu display."); return; @@ -410,7 +433,22 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box { }) .with_defer_events_to_children(); - let hoverable_element = if can_open && !is_renaming { + let hoverable_element = if is_bulk_delete_mode { + if conversation.capabilities.can_delete { + hoverable + .with_cursor(Cursor::PointingHand) + .on_click(move |ctx, _, _| { + ctx.dispatch_typed_action( + ConversationListViewAction::ToggleBulkDeleteSelection { + id: conversation_id, + }, + ); + }) + .finish() + } else { + hoverable.finish() + } + } else if can_open && !is_renaming { hoverable .with_cursor(Cursor::PointingHand) .on_click(move |ctx, _, _| { @@ -468,6 +506,55 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box { SavePosition::new(item_stack.finish(), &position_id).finish() } +fn render_bulk_delete_checkbox( + mouse_state: MouseStateHandle, + is_selected: bool, + can_delete: bool, + appearance: &Appearance, +) -> Box { + let theme = appearance.theme(); + let zero_margin = galaxyui::ui_components::components::Coords::uniform(0.); + let border_color = if can_delete { + theme.sub_text_color(theme.background()) + } else { + theme.disabled_text_color(theme.background()) + }; + let checkbox_default = UiComponentStyles { + font_size: Some(BULK_CHECKBOX_SIZE), + border_width: Some(1.), + border_color: Some(border_color.into()), + border_radius: Some(CornerRadius::with_all(Radius::Pixels(2.))), + margin: Some(zero_margin), + ..Default::default() + }; + let checkbox_checked = UiComponentStyles { + font_size: Some(BULK_CHECKBOX_SIZE), + background: Some(theme.accent_button_color().into()), + font_color: Some(theme.main_text_color(theme.accent_button_color()).into()), + border_width: Some(1.), + border_color: Some(theme.accent_button_color().into()), + border_radius: Some(CornerRadius::with_all(Radius::Pixels(2.))), + margin: Some(zero_margin), + ..Default::default() + }; + + let mut checkbox = Checkbox::new( + mouse_state, + checkbox_default, + None, + Some(checkbox_checked), + None, + ) + .check(is_selected) + .build(); + + if !can_delete { + checkbox = checkbox.disable(); + } + + checkbox.finish() +} + fn render_inline_rename_editor( rename_editor: &ViewHandle, appearance: &Appearance, diff --git a/app/src/workspace/view/conversation_list/view.rs b/app/src/workspace/view/conversation_list/view.rs index c6873a08..062a7cd7 100644 --- a/app/src/workspace/view/conversation_list/view.rs +++ b/app/src/workspace/view/conversation_list/view.rs @@ -18,6 +18,8 @@ use galaxyui::keymap::macros::*; use galaxyui::keymap::FixedBinding; use galaxyui::platform::Cursor; use galaxyui::text_layout::TextAlignment; +use galaxyui::ui_components::checkbox::Checkbox; +use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles}; use galaxyui::{ AppContext, BlurContext, Entity, EntityId, ModelHandle, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, WindowId, @@ -42,8 +44,11 @@ use crate::editor::{ }; use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields}; use crate::server::telemetry::SharingDialogSource; -use crate::view_components::action_button::{ActionButton, ButtonSize, SecondaryTheme}; +use crate::view_components::action_button::{ + ActionButton, ButtonSize, DangerSecondaryTheme, SecondaryTheme, +}; use crate::view_components::DismissibleToast; +use crate::workspace::delete_conversation_confirmation_dialog::DeleteConversationTarget; use crate::workspace::global_actions::ForkedConversationDestination; use crate::workspace::header_toolbar_item::HeaderToolbarItemKind; use crate::workspace::tab_settings::TabSettings; @@ -56,6 +61,7 @@ use crate::workspace::{ToastStack, WorkspaceAction}; const VIEW_ALL_LABEL: &str = "View all"; /// Maximum number of past items to show before the user toggles "view all". const INITIAL_MAX_PAST_ITEMS: usize = 10; +const BULK_CHECKBOX_SIZE: f32 = 14.0; /// State handles for tracking UI state (hover, scroll, list selection, etc.). struct StateHandles { @@ -67,6 +73,7 @@ struct StateHandles { zero_state_button: MouseStateHandle, active_header: MouseStateHandle, past_header: MouseStateHandle, + bulk_select_all: MouseStateHandle, } impl Default for StateHandles { @@ -80,6 +87,7 @@ impl Default for StateHandles { zero_state_button: MouseStateHandle::default(), active_header: MouseStateHandle::default(), past_header: MouseStateHandle::default(), + bulk_select_all: MouseStateHandle::default(), } } } @@ -150,6 +158,13 @@ pub enum ConversationListViewAction { }, FinishRename, CancelRename, + EnterBulkDeleteMode, + ExitBulkDeleteMode, + ToggleBulkDeleteSelection { + id: AgentConversationEntryId, + }, + ToggleSelectAllDeletable, + DeleteSelectedConversations, } pub enum Event { @@ -159,6 +174,9 @@ pub enum Event { conversation_title: String, terminal_view_id: Option, }, + ShowBulkDeleteConfirmationDialog { + conversations: Vec, + }, } pub struct ConversationListView { @@ -167,6 +185,9 @@ pub struct ConversationListView { view_model: ModelHandle, query_editor: ViewHandle, toggle_view_all_button: ViewHandle, + cleanup_button: ViewHandle, + delete_selected_button: ViewHandle, + cancel_bulk_delete_button: ViewHandle, item_overflow_menu: ViewHandle>, /// Tracks the overflow menu state (which item it's open for and where to position it). overflow_menu_state: Option, @@ -186,6 +207,8 @@ pub struct ConversationListView { /// Total number of past items before truncation /// (we use this to decide whether or not to show the view all button). total_past_items: usize, + is_bulk_delete_mode: bool, + bulk_delete_selection: HashSet, state_handles: StateHandles, } @@ -279,6 +302,33 @@ impl ConversationListView { }) }); + let cleanup_button = ctx.add_typed_action_view(|_| { + ActionButton::new("Clean up sessions", SecondaryTheme) + .with_size(ButtonSize::Small) + .with_icon(Icon::Trash) + .on_click(|ctx| { + ctx.dispatch_typed_action(ConversationListViewAction::EnterBulkDeleteMode); + }) + }); + + let delete_selected_button = ctx.add_typed_action_view(|_| { + ActionButton::new("Delete selected", DangerSecondaryTheme) + .with_size(ButtonSize::Small) + .on_click(|ctx| { + ctx.dispatch_typed_action( + ConversationListViewAction::DeleteSelectedConversations, + ); + }) + }); + + let cancel_bulk_delete_button = ctx.add_typed_action_view(|_| { + ActionButton::new("Cancel", SecondaryTheme) + .with_size(ButtonSize::Small) + .on_click(|ctx| { + ctx.dispatch_typed_action(ConversationListViewAction::ExitBulkDeleteMode); + }) + }); + let item_overflow_menu = ctx.add_typed_action_view(|_| { Menu::new() .prevent_interaction_with_other_elements() @@ -309,6 +359,9 @@ impl ConversationListView { view_model, query_editor, toggle_view_all_button, + cleanup_button, + delete_selected_button, + cancel_bulk_delete_button, item_overflow_menu, overflow_menu_state: None, sharing_dialog, @@ -320,6 +373,8 @@ impl ConversationListView { list_items: Arc::new(Vec::new()), view_all: false, total_past_items: 0, + is_bulk_delete_mode: false, + bulk_delete_selection: HashSet::new(), state_handles: StateHandles::default(), }; view.sync_list_items(ctx); @@ -675,13 +730,25 @@ impl ConversationListView { .retain(|id, _| current_ids.contains(id)); // Add new entries - for id in current_ids { - self.state_handles.item_states.entry(id).or_default(); + for id in ¤t_ids { + self.state_handles.item_states.entry(*id).or_default(); } // Rebuild list_items with current collapse state self.rebuild_list_items(ctx); + self.bulk_delete_selection.retain(|id| { + current_ids.contains(id) + && self + .view_model + .as_ref(ctx) + .get_item_by_id(id, ctx) + .is_some_and(|entry| entry.capabilities.can_delete) + }); + if self.is_bulk_delete_mode && self.bulk_delete_selection.is_empty() { + self.selected_index = None; + } + // Adjust selection if it's now invalid. if let Some(index) = self.selected_index { if index >= self.item_count() { @@ -694,6 +761,146 @@ impl ConversationListView { ctx.notify(); } + fn deletable_visible_conversation_ids( + &self, + ctx: &AppContext, + ) -> Vec { + let model = self.view_model.as_ref(ctx); + self.list_items + .iter() + .filter_map(|item| match item { + ListItem::Conversation { entry, .. } => model + .get_item_by_id(&entry.id, ctx) + .filter(|entry| entry.capabilities.can_delete) + .map(|_| entry.id), + ListItem::SectionHeader(_) + | ListItem::StartNewConversation + | ListItem::ToggleViewAllButton => None, + }) + .collect() + } + + fn selected_delete_targets(&self, ctx: &AppContext) -> Vec { + let model = self.view_model.as_ref(ctx); + let active_views_model = ActiveAgentViewsModel::as_ref(ctx); + self.bulk_delete_selection + .iter() + .filter_map(|id| { + let entry = model.get_item_by_id(id, ctx)?; + if !entry.capabilities.can_delete { + return None; + } + let conversation_id = entry.identity.local_conversation_id?; + Some(DeleteConversationTarget { + conversation_id, + conversation_title: entry.display.title, + terminal_view_id: active_views_model + .get_terminal_view_id_for_conversation(conversation_id, ctx), + }) + }) + .collect() + } + + fn toggle_bulk_delete_selection( + &mut self, + id: AgentConversationEntryId, + ctx: &mut ViewContext, + ) { + let can_delete = self + .view_model + .as_ref(ctx) + .get_item_by_id(&id, ctx) + .is_some_and(|entry| entry.capabilities.can_delete); + if !can_delete { + return; + } + + if !self.bulk_delete_selection.insert(id) { + self.bulk_delete_selection.remove(&id); + } + ctx.notify(); + } + + fn render_bulk_delete_toolbar(&self, app: &AppContext) -> Box { + let appearance = Appearance::as_ref(app); + let theme = appearance.theme(); + let visible_deletable_ids = self.deletable_visible_conversation_ids(app); + let selected_count = self.bulk_delete_selection.len(); + let all_selected = !visible_deletable_ids.is_empty() + && visible_deletable_ids + .iter() + .all(|id| self.bulk_delete_selection.contains(id)); + + let zero_margin = Coords::uniform(0.); + let checkbox_default = UiComponentStyles { + font_size: Some(BULK_CHECKBOX_SIZE), + border_width: Some(1.), + border_color: Some(theme.sub_text_color(theme.background()).into()), + border_radius: Some(CornerRadius::with_all(Radius::Pixels(2.))), + margin: Some(zero_margin), + ..Default::default() + }; + let checkbox_checked = UiComponentStyles { + font_size: Some(BULK_CHECKBOX_SIZE), + background: Some(theme.accent_button_color().into()), + font_color: Some(theme.main_text_color(theme.accent_button_color()).into()), + border_width: Some(1.), + border_color: Some(theme.accent_button_color().into()), + border_radius: Some(CornerRadius::with_all(Radius::Pixels(2.))), + margin: Some(zero_margin), + ..Default::default() + }; + + let select_all = Checkbox::new( + self.state_handles.bulk_select_all.clone(), + checkbox_default, + None, + Some(checkbox_checked), + None, + ) + .check(all_selected) + .build() + .on_click(|ctx, _, _| { + ctx.dispatch_typed_action(ConversationListViewAction::ToggleSelectAllDeletable); + }) + .with_cursor(Cursor::PointingHand) + .finish(); + + let label = Text::new_inline( + if selected_count == 0 { + "Select conversations to delete".to_string() + } else { + format!("{selected_count} selected") + }, + appearance.ui_font_family(), + appearance.ui_font_size(), + ) + .with_color(theme.main_text_color(theme.background()).into()) + .finish(); + + let buttons = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_spacing(6.) + .with_child(ChildView::new(&self.delete_selected_button).finish()) + .with_child(ChildView::new(&self.cancel_bulk_delete_button).finish()) + .finish(); + + Container::new( + Flex::row() + .with_main_axis_size(MainAxisSize::Max) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_spacing(8.) + .with_child(select_all) + .with_child(Shrinkable::new(1., label).finish()) + .with_child(buttons) + .finish(), + ) + .with_horizontal_padding(12.) + .with_vertical_padding(8.) + .with_border(Border::bottom(1.).with_border_fill(theme.surface_3())) + .finish() + } + fn start_rename(&mut self, id: AgentConversationEntryId, ctx: &mut ViewContext) { let Some(entry) = self.view_model.as_ref(ctx).get_item_by_id(&id, ctx) else { return; @@ -969,6 +1176,26 @@ fn render_list_action_button(button: &ViewHandle) -> Box, + app: &AppContext, +) -> Box { + let appearance = Appearance::as_ref(app); + let theme = appearance.theme(); + + Container::new( + Flex::row() + .with_main_axis_size(MainAxisSize::Max) + .with_main_axis_alignment(MainAxisAlignment::End) + .with_child(ChildView::new(cleanup_button).finish()) + .finish(), + ) + .with_horizontal_padding(12.) + .with_vertical_padding(8.) + .with_border(Border::bottom(1.).with_border_fill(theme.surface_3())) + .finish() +} + impl Entity for ConversationListView { type Event = Event; } @@ -1274,6 +1501,52 @@ impl TypedActionView for ConversationListView { ConversationListViewAction::CancelRename => { self.cancel_rename(ctx); } + ConversationListViewAction::EnterBulkDeleteMode => { + self.is_bulk_delete_mode = true; + self.selected_index = None; + self.overflow_menu_state = None; + ctx.notify(); + } + ConversationListViewAction::ExitBulkDeleteMode => { + self.is_bulk_delete_mode = false; + self.bulk_delete_selection.clear(); + ctx.notify(); + } + ConversationListViewAction::ToggleBulkDeleteSelection { id } => { + self.toggle_bulk_delete_selection(*id, ctx); + } + ConversationListViewAction::ToggleSelectAllDeletable => { + let visible_deletable_ids = self.deletable_visible_conversation_ids(ctx); + if visible_deletable_ids.is_empty() { + return; + } + + let all_selected = visible_deletable_ids + .iter() + .all(|id| self.bulk_delete_selection.contains(id)); + if all_selected { + for id in visible_deletable_ids { + self.bulk_delete_selection.remove(&id); + } + } else { + self.bulk_delete_selection.extend(visible_deletable_ids); + } + ctx.notify(); + } + ConversationListViewAction::DeleteSelectedConversations => { + let targets = self.selected_delete_targets(ctx); + if targets.is_empty() { + return; + } + + self.is_bulk_delete_mode = false; + self.bulk_delete_selection.clear(); + self.selected_index = None; + ctx.emit(Event::ShowBulkDeleteConfirmationDialog { + conversations: targets, + }); + ctx.notify(); + } } } } @@ -1333,6 +1606,8 @@ impl View for ConversationListView { let open_conversation_ids = ActiveAgentViewsModel::as_ref(app).get_all_open_conversation_ids(app); let share_dialog_open_for = self.share_dialog_open_for; + let is_bulk_delete_mode = self.is_bulk_delete_mode; + let bulk_delete_selection = self.bulk_delete_selection.clone(); let list_position_id = self.get_position_id(); let tooltip_opens_right = TabSettings::as_ref(app) .header_toolbar_chip_selection @@ -1413,6 +1688,8 @@ impl View for ConversationListView { }; let is_share_dialog_open = share_dialog_open_for == Some(entry.id); + let is_bulk_delete_selected = + bulk_delete_selection.contains(&entry.id); Some(render_item( ItemProps { conversation: &conversation, @@ -1429,6 +1706,8 @@ impl View for ConversationListView { rename_editor: is_renaming.then_some(&rename_editor), sharing_dialog: &sharing_dialog, is_share_dialog_open, + is_bulk_delete_mode, + is_bulk_delete_selected, list_position_id: &list_position_id, tooltip_opens_right, }, @@ -1481,6 +1760,11 @@ impl View for ConversationListView { if has_conversations { column = column.with_child(render_search_box(&self.query_editor, app)); + if self.is_bulk_delete_mode { + column = column.with_child(self.render_bulk_delete_toolbar(app)); + } else { + column = column.with_child(render_cleanup_action(&self.cleanup_button, app)); + } } let column_element = column diff --git a/app/src/workspace/view/left_panel.rs b/app/src/workspace/view/left_panel.rs index 2f2fbf24..bd2d5b07 100644 --- a/app/src/workspace/view/left_panel.rs +++ b/app/src/workspace/view/left_panel.rs @@ -49,6 +49,7 @@ use crate::util::openable_file_type::FileTarget; use crate::util::openable_file_type::{ is_markdown_file, resolve_file_target_with_editor_choice, EditorLayout, }; +use crate::workspace::delete_conversation_confirmation_dialog::DeleteConversationTarget; use crate::workspace::view::conversation_list::view::{ ConversationListView, Event as ConversationListViewEvent, }; @@ -97,6 +98,9 @@ pub enum LeftPanelEvent { conversation_title: String, terminal_view_id: Option, }, + ShowBulkDeleteConfirmationDialog { + conversations: Vec, + }, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -236,6 +240,11 @@ impl LeftPanelView { terminal_view_id: *terminal_view_id, }); } + ConversationListViewEvent::ShowBulkDeleteConfirmationDialog { conversations } => { + ctx.emit(LeftPanelEvent::ShowBulkDeleteConfirmationDialog { + conversations: conversations.clone(), + }); + } }); let active_view = views.first().copied().unwrap_or(ToolPanelView::WarpDrive); diff --git a/app/src/workspace/view/wasm_view.rs b/app/src/workspace/view/wasm_view.rs index dcc47bb9..0789dfd0 100644 --- a/app/src/workspace/view/wasm_view.rs +++ b/app/src/workspace/view/wasm_view.rs @@ -103,6 +103,19 @@ impl Workspace { true, ); } + ConversationDetailsPanelEvent::ShowDeleteConfirmationDialog { + conversation_id, + conversation_title, + } => { + me.show_delete_conversation_confirmation_dialog( + crate::workspace::delete_conversation_confirmation_dialog::DeleteConversationDialogSource::single( + *conversation_id, + conversation_title.clone(), + None, + ), + ctx, + ); + } }); panel