live fixes

This commit is contained in:
2026-08-12 17:36:21 -05:00
parent a5fac64cd9
commit 934cddc063
20 changed files with 1559 additions and 124 deletions
@@ -26,6 +26,7 @@ pub struct ActionButtonsConfig {
pub view_details_item_id: Option<AgentConversationEntryId>, pub view_details_item_id: Option<AgentConversationEntryId>,
/// Conversation link URL (either to the transcript or live session) for copy link button. /// Conversation link URL (either to the transcript or live session) for copy link button.
pub copy_link_url: Option<String>, pub copy_link_url: Option<String>,
pub delete_conversation_id: Option<AIConversationId>,
} }
impl ActionButtonsConfig { impl ActionButtonsConfig {
@@ -36,6 +37,7 @@ impl ActionButtonsConfig {
&& self.fork_conversation_id.is_none() && self.fork_conversation_id.is_none()
&& self.view_details_item_id.is_none() && self.view_details_item_id.is_none()
&& self.copy_link_url.is_none() && self.copy_link_url.is_none()
&& self.delete_conversation_id.is_none()
} }
/// Create config for a task. /// Create config for a task.
@@ -58,6 +60,7 @@ impl ActionButtonsConfig {
fork_conversation_id: None, fork_conversation_id: None,
view_details_item_id: None, view_details_item_id: None,
copy_link_url, copy_link_url,
delete_conversation_id: None,
} }
} }
@@ -75,6 +78,7 @@ impl ActionButtonsConfig {
fork_conversation_id: Some(conversation_id), fork_conversation_id: Some(conversation_id),
view_details_item_id: None, view_details_item_id: None,
copy_link_url, copy_link_url,
delete_conversation_id: Some(conversation_id),
} }
} }
} }
@@ -87,6 +91,7 @@ pub enum AgentDetailsButtonEvent {
ForkConversation { conversation_id: AIConversationId }, ForkConversation { conversation_id: AIConversationId },
ViewDetails { item_id: AgentConversationEntryId }, ViewDetails { item_id: AgentConversationEntryId },
CopyLink { link: String }, CopyLink { link: String },
DeleteConversation { conversation_id: AIConversationId },
} }
/// Actions dispatched by button clicks (internal). /// Actions dispatched by button clicks (internal).
@@ -97,6 +102,7 @@ pub enum AgentDetailsAction {
ForkConversation, ForkConversation,
ViewDetails, ViewDetails,
CopyLink, CopyLink,
DeleteConversation,
} }
/// Reusable action buttons row for details panel. /// Reusable action buttons row for details panel.
@@ -107,6 +113,7 @@ pub struct ConversationActionButtonsRow {
fork_conversation_button: ViewHandle<ActionButton>, fork_conversation_button: ViewHandle<ActionButton>,
view_details_button: ViewHandle<ActionButton>, view_details_button: ViewHandle<ActionButton>,
copy_link_button: ViewHandle<ActionButton>, copy_link_button: ViewHandle<ActionButton>,
delete_conversation_button: ViewHandle<ActionButton>,
} }
impl ConversationActionButtonsRow { 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 { Self {
config: ActionButtonsConfig::default(), config: ActionButtonsConfig::default(),
open_button, open_button,
@@ -163,6 +179,7 @@ impl ConversationActionButtonsRow {
fork_conversation_button, fork_conversation_button,
view_details_button, view_details_button,
copy_link_button, copy_link_button,
delete_conversation_button,
} }
} }
@@ -231,6 +248,9 @@ impl View for ConversationActionButtonsRow {
if self.config.view_details_item_id.is_some() { if self.config.view_details_item_id.is_some() {
row.add_child(ChildView::new(&self.view_details_button).finish()); 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() 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 });
}
}
} }
} }
} }
+39 -2
View File
@@ -1078,7 +1078,7 @@ impl AgentManagementView {
open_action: Option<WorkspaceAction>, open_action: Option<WorkspaceAction>,
copy_link_url: Option<String>, copy_link_url: Option<String>,
) -> ActionButtonsConfig { ) -> 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( ActionButtonsConfig::for_task(
task_id, task_id,
&entry.display.status, &entry.display.status,
@@ -1093,7 +1093,15 @@ impl AgentManagementView {
copy_link_url, copy_link_url,
..Default::default() ..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( fn handle_action_buttons_event(
@@ -1173,6 +1181,18 @@ impl AgentManagementView {
ctx.clipboard() ctx.clipboard()
.write(ClipboardContent::plain_text(link.clone())); .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, 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 { pub enum AgentManagementViewEvent {
OpenNewTabAndRunWorkflow(Box<WorkflowType>), OpenNewTabAndRunWorkflow(Box<WorkflowType>),
OpenPlanNotebook { notebook_uid: NotebookId }, OpenPlanNotebook {
notebook_uid: NotebookId,
},
ShowDeleteConfirmationDialog {
conversation_id: AIConversationId,
conversation_title: String,
terminal_view_id: Option<galaxyui::EntityId>,
},
} }
impl TypedActionView for AgentManagementView { impl TypedActionView for AgentManagementView {
+30
View File
@@ -1048,6 +1048,24 @@ impl BlocklistAIActionModel {
has_pending || has_running 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. /// Returns finished action results received from the most recent AI output for the active conversation.
pub fn get_finished_action_results( pub fn get_finished_action_results(
&self, &self,
@@ -1806,6 +1824,18 @@ impl BlocklistAIActionModel {
self.finished_tool_results.remove(&conversation_id); 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, /// 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 /// and code diff views are identical, and thus should be handled directly by the [`AIBlock`]'s
/// respective functions. /// respective functions.
@@ -398,6 +398,16 @@ impl BlocklistAIActionExecutor {
.map(|running| &running.action) .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 /// Returns the action_id of any running WaitForEvents action for the
/// given conversation. There is at most one (wait_for_events is /// given conversation. There is at most one (wait_for_events is
/// documented as exclusive within a turn). /// documented as exclusive within a turn).
+415 -40
View File
@@ -36,26 +36,28 @@ use self::response_stream::{ResponseStream, ResponseStreamEvent};
use super::action_model::{BlocklistAIActionEvent, BlocklistAIActionModel}; use super::action_model::{BlocklistAIActionEvent, BlocklistAIActionModel};
use super::context_model::{BlocklistAIContextModel, PendingAttachment, PendingFile}; use super::context_model::{BlocklistAIContextModel, PendingAttachment, PendingFile};
use super::conversation_selection::{ConversationSelectionEvent, ConversationSelectionHandle}; use super::conversation_selection::{ConversationSelectionEvent, ConversationSelectionHandle};
use super::history_model::BlocklistAIHistoryModel; use super::history_model::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
use super::orchestration_event_streamer::{ use super::orchestration_event_streamer::{
OrchestrationEventStreamer, OrchestrationEventStreamerEvent, OrchestrationEventStreamer, OrchestrationEventStreamerEvent,
}; };
use super::orchestration_events::{OrchestrationEventService, OrchestrationEventServiceEvent}; use super::orchestration_events::{OrchestrationEventService, OrchestrationEventServiceEvent};
use super::orchestration_topology::descendant_conversation_ids_in_spawn_order;
use super::queued_query::{QueuedQueryId, QueuedQueryModel}; use super::queued_query::{QueuedQueryId, QueuedQueryModel};
use super::{BlocklistAIInputModel, ResponseStreamId}; use super::{BlocklistAIInputModel, ResponseStreamId};
use crate::ai::agent::api::{self, ServerConversationToken}; use crate::ai::agent::api::{self, ServerConversationToken};
use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus}; use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus};
use crate::ai::agent::task::TaskId; 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"))] #[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; use crate::ai::agent_events::AgentMessageEventMetadata;
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
use crate::ai::agent_sdk::ClaudeHarness; use crate::ai::agent_sdk::ClaudeHarness;
@@ -189,6 +191,146 @@ fn remote_action_summaries(actions: &[AIAgentAction]) -> Vec<serde_json::Value>
.collect() .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<String>) -> 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<AIConversationId> {
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<AIConversationId> {
match &input_query.which_task {
WhichTask::Task {
conversation_id, ..
} => Some(*conversation_id),
WhichTask::NewConversation => None,
}
}
pub enum BlocklistAIControllerEvent { pub enum BlocklistAIControllerEvent {
/// Emitted when a request is sent to the AI agent API. /// Emitted when a request is sent to the AI agent API.
SentRequest { SentRequest {
@@ -469,6 +611,13 @@ pub struct BlocklistAIController {
pending_local_claude_wakes: HashMap<AIConversationId, SpawnedFutureHandle>, pending_local_claude_wakes: HashMap<AIConversationId, SpawnedFutureHandle>,
/// Passive conversations explicitly requested to follow up after actions complete. /// Passive conversations explicitly requested to follow up after actions complete.
pending_passive_follow_ups: HashSet<AIConversationId>, pending_passive_follow_ups: HashSet<AIConversationId>,
/// 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<AIConversationId>,
/// 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<ResponseStreamId, Vec<FailedToolProposal>>,
/// Per-conversation loop detection state for preventing recursive tool failures. /// Per-conversation loop detection state for preventing recursive tool failures.
loop_detection: HashMap<AIConversationId, ToolLoopGuard>, loop_detection: HashMap<AIConversationId, ToolLoopGuard>,
@@ -601,6 +750,73 @@ impl InputQuery {
} }
impl BlocklistAIController { 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<AIConversationId>,
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<AIConversationId>,
active_conversation_id: Option<AIConversationId>,
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<AIConversationId>,
active_conversation_id: Option<AIConversationId>,
is_queued_prompt: bool,
ctx: &mut ModelContext<Self>,
) {
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. /// Returns the bundled-skill catalog origin for this controller's active session.
pub fn skill_path_origin(&self, ctx: &AppContext) -> SkillPathOrigin { pub fn skill_path_origin(&self, ctx: &AppContext) -> SkillPathOrigin {
SessionContext::from_session(self.active_session.as_ref(ctx), ctx).skill_path_origin() 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); 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::<Vec<_>>();
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| { ctx.subscribe_to_model(&conversation_selection, |me, _, event, ctx| {
let ConversationSelectionEvent::Deactivated { let ConversationSelectionEvent::Deactivated {
conversation_id, conversation_id,
@@ -787,9 +1027,13 @@ impl BlocklistAIController {
} => { } => {
me.handle_dormant_claude_wake_ready(*conversation_id, wake_message.clone(), ctx); me.handle_dormant_claude_wake_ready(*conversation_id, wake_message.clone(), ctx);
} }
// Viewer-mode events are handled by `OrchestrationViewerModel`. // Viewer-mode placeholder materialization is handled by
OrchestrationEventStreamerEvent::ChildSpawned { .. } // `OrchestrationViewerModel`; the owner-side controller only
| OrchestrationEventStreamerEvent::ChildStatusChanged { .. } => {} // 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); let crosscheck_reviewer = ctx.add_model(crate::ai::crosscheck::CrosscheckReviewer::new);
ctx.subscribe_to_model(&crosscheck_reviewer, move |me, _, event, ctx| { ctx.subscribe_to_model(&crosscheck_reviewer, move |me, _, event, ctx| {
@@ -815,6 +1059,8 @@ impl BlocklistAIController {
pending_auto_resume_handles: HashMap::new(), pending_auto_resume_handles: HashMap::new(),
pending_local_claude_wakes: HashMap::new(), pending_local_claude_wakes: HashMap::new(),
pending_passive_follow_ups: HashSet::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(), pending_passive_suggestion_results: HashMap::new(),
loop_detection: HashMap::new(), loop_detection: HashMap::new(),
error_retry_counts: HashMap::new(), error_retry_counts: HashMap::new(),
@@ -848,7 +1094,22 @@ impl BlocklistAIController {
let query = input_query.query().to_owned(); let query = input_query.query().to_owned();
let is_existing_task = matches!(&input_query.which_task, WhichTask::Task { .. }); 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 => { WhichTask::NewConversation => {
let conversation = self.start_new_conversation_for_request(ctx); let conversation = self.start_new_conversation_for_request(ctx);
(conversation.id(), conversation.get_root_task_id().clone()) (conversation.id(), conversation.get_root_task_id().clone())
@@ -856,15 +1117,13 @@ impl BlocklistAIController {
WhichTask::Task { WhichTask::Task {
conversation_id, conversation_id,
task_id, task_id,
} => (conversation_id, task_id), } => (*conversation_id, task_id.clone()),
}; };
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history.refresh_conversation_backend_without_output(conversation_id, 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 { let is_same_conversation_running_command_monitor = match &input_query.input_query {
InputQueryType::UserSubmittedQueryFromInput { InputQueryType::UserSubmittedQueryFromInput {
running_command: Some(running_command), running_command: Some(running_command),
@@ -1888,6 +2147,38 @@ impl BlocklistAIController {
history.mark_active_conversation_id(conversation_id, self.terminal_surface_id, ctx); 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::<Vec<_>>(),
}),
},
);
return;
}
self.pending_child_blocked_follow_ups
.remove(&conversation_id);
let mut finished_results = self.action_model.update(ctx, |action_model, _| { let mut finished_results = self.action_model.update(ctx, |action_model, _| {
action_model.drain_finished_action_results(conversation_id) action_model.drain_finished_action_results(conversation_id)
}); });
@@ -2049,6 +2340,69 @@ impl BlocklistAIController {
self.pending_passive_follow_ups.remove(&conversation_id); self.pending_passive_follow_ups.remove(&conversation_id);
} }
fn maybe_resume_child_blocked_follow_up(
&mut self,
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
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<Self>,
) {
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( fn check_and_record_loop_detection(
&mut self, &mut self,
conversation_id: AIConversationId, conversation_id: AIConversationId,
@@ -3627,6 +3981,7 @@ impl BlocklistAIController {
remote_action_tool_name(&action), remote_action_tool_name(&action),
action.requires_result, action.requires_result,
); );
let failed_proposal = FailedToolProposal::new(&action, String::new());
let apply_result = history_model.update(ctx, |history_model, ctx| { let apply_result = history_model.update(ctx, |history_model, ctx| {
history_model.apply_domain_tool_proposal( history_model.apply_domain_tool_proposal(
&stream_id, &stream_id,
@@ -3640,6 +3995,12 @@ impl BlocklistAIController {
log::error!( log::error!(
"Failed to apply Rig tool proposal to conversation: {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"))] #[cfg(not(target_family = "wasm"))]
{ {
let (action_id, task_id, tool_name, requires_result) = let (action_id, task_id, tool_name, requires_result) =
@@ -4131,6 +4492,10 @@ impl BlocklistAIController {
let history_action_count = actions_to_queue.len(); let history_action_count = actions_to_queue.len();
let proposed_action_count = proposed_actions.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 let mut queued_action_ids = actions_to_queue
.iter() .iter()
.map(|action| action.id.clone()) .map(|action| action.id.clone())
@@ -4142,46 +4507,46 @@ impl BlocklistAIController {
actions_to_queue.push(action.clone()); 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"))] #[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( remote_logging::log_model_event(
ctx, ctx,
RemoteLogRecord { RemoteLogRecord {
level, level: queue_decision.remote_log_level(),
message: "Tool queue decision".to_string(), message: "Tool queue decision".to_string(),
context: serde_json::json!({ context: serde_json::json!({
"event": "tool_queue_decision", "event": "tool_queue_decision",
"stream_id": stream_id.as_str(), "stream_id": stream_id.as_str(),
"conversation_id": conversation_id.to_string(), "conversation_id": conversation_id.to_string(),
"decision": decision, "decision": queue_decision.label(),
"history_action_count": history_action_count, "history_action_count": history_action_count,
"proposed_action_count": proposed_action_count, "proposed_action_count": proposed_action_count,
"candidate_action_count": actions_to_queue.len(), "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() actions_to_queue.len()
} else { } else {
0 0
}, },
"queued_from_stream_snapshot_count": queued_from_stream_snapshot_count, "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::<Vec<_>>(),
"active_descendant_conversation_ids": active_child_conversation_ids
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>(),
"was_passive_request": was_passive_request, "was_passive_request": was_passive_request,
"is_any_exchange_unfinished": is_any_exchange_unfinished, "is_any_exchange_unfinished": is_any_exchange_unfinished,
"cancellation_reason": cancellation "cancellation_reason": cancellation
@@ -4248,7 +4613,17 @@ impl BlocklistAIController {
ctx, 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!( log::info!(
"[bedrock-debug] AfterStreamFinished: queuing {} actions", "[bedrock-debug] AfterStreamFinished: queuing {} actions",
actions_to_queue.len() actions_to_queue.len()
@@ -148,6 +148,19 @@ impl SlashCommandRequest {
is_for_same_conversation: active_conversation_id is_for_same_conversation: active_conversation_id
.is_some_and(|id| id == 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 { if let Some(active_conversation_id) = active_conversation_id {
controller.cancel_conversation_progress( controller.cancel_conversation_progress(
active_conversation_id, active_conversation_id,
+309 -4
View File
@@ -1,16 +1,18 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use ai::agent::action::{AskUserQuestionItem, AskUserQuestionType};
use chrono::Local; use chrono::Local;
use uuid::Uuid; use uuid::Uuid;
use warp_multi_agent_api::response_event; 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::task::TaskId;
use crate::ai::agent::{ use crate::ai::agent::{
AIAgentAttachment, AIAgentContext, AIAgentInput, CancellationReason, ImageContext, AIAgentAction, AIAgentActionId, AIAgentActionType, AIAgentAttachment, AIAgentContext,
PassiveSuggestionTrigger, RunningCommand, UserQueryMode, AIAgentInput, CancellationReason, ImageContext, PassiveSuggestionTrigger, RunningCommand,
UserQueryMode,
}; };
use crate::ai::ambient_agents::AmbientAgentTaskId; use crate::ai::ambient_agents::AmbientAgentTaskId;
use crate::ai::blocklist::{ use crate::ai::blocklist::{
@@ -20,12 +22,33 @@ use crate::ai::blocklist::{
use crate::ai::llms::LLMId; use crate::ai::llms::LLMId;
use crate::persistence::model::{AcpConversationData, AgentBackend}; use crate::persistence::model::{AcpConversationData, AgentBackend};
use crate::terminal::model::block::BlockId; 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}; use crate::test_util::terminal::{add_window_with_terminal, initialize_app_for_terminal_view};
fn new_ambient_agent_task_id() -> AmbientAgentTaskId { fn new_ambient_agent_task_id() -> AmbientAgentTaskId {
Uuid::new_v4().to_string().parse().unwrap() 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 { fn image_attachment(file_name: &str) -> PendingAttachment {
PendingAttachment::Image(ImageContext { PendingAttachment::Image(ImageContext {
data: String::new(), 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::<AIConversationId>::new()
);
});
});
}
#[test] #[test]
fn acp_backend_model_identity_does_not_claim_a_provider_model() { fn acp_backend_model_identity_does_not_claim_a_provider_model() {
assert_eq!(super::acp_backend_model_id(&AgentBackend::Provider), None); 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] #[test]
fn mock_response_stream_updates_history_through_controller() { fn mock_response_stream_updates_history_through_controller() {
App::test((), |mut app| async move { App::test((), |mut app| async move {
+24 -4
View File
@@ -602,7 +602,13 @@ impl ConversationDetailsData {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum ConversationDetailsPanelEvent { pub enum ConversationDetailsPanelEvent {
Close, Close,
OpenPlanNotebook { notebook_uid: NotebookId }, OpenPlanNotebook {
notebook_uid: NotebookId,
},
ShowDeleteConfirmationDialog {
conversation_id: AIConversationId,
conversation_title: String,
},
} }
/// Actions for the ConversationDetailsPanel. /// Actions for the ConversationDetailsPanel.
@@ -878,14 +884,20 @@ impl ConversationDetailsPanel {
)) ))
} }
PanelMode::Conversation { PanelMode::Conversation {
ai_conversation_id, .. ai_conversation_id,
status,
..
} => { } => {
let conversation_id = *ai_conversation_id.as_ref()?; let conversation_id = *ai_conversation_id.as_ref()?;
Some(ActionButtonsConfig::for_conversation( let mut config = ActionButtonsConfig::for_conversation(
conversation_id, conversation_id,
open_action, open_action,
data.copy_link_url.clone(), 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() ctx.clipboard()
.write(ClipboardContent::plain_text(link.clone())); .write(ClipboardContent::plain_text(link.clone()));
} }
AgentDetailsButtonEvent::DeleteConversation { conversation_id } => {
ctx.emit(
ConversationDetailsPanelEvent::ShowDeleteConfirmationDialog {
conversation_id: *conversation_id,
conversation_title: self.data.title.clone(),
},
);
}
} }
} }
+5
View File
@@ -681,6 +681,11 @@ pub enum Event {
flavor: ToastFlavor, flavor: ToastFlavor,
pane_id: Option<PaneId>, pane_id: Option<PaneId>,
}, },
ShowDeleteConversationConfirmationDialog {
conversation_id: AIConversationId,
conversation_title: String,
terminal_view_id: Option<EntityId>,
},
SignupAnonymousUser { SignupAnonymousUser {
entrypoint: AnonymousUserSignupEntrypoint, entrypoint: AnonymousUserSignupEntrypoint,
}, },
+13
View File
@@ -1190,6 +1190,19 @@ fn handle_terminal_view_event(
Event::OpenShareSessionDeniedModal => { Event::OpenShareSessionDeniedModal => {
group.open_share_session_denied_modal(terminal_pane_id, ctx); 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 => { Event::FocusSession => {
group.focus_pane(terminal_pane_id.into(), true, ctx); group.focus_pane(terminal_pane_id.into(), true, ctx);
ctx.emit(pane_group::Event::FocusPaneGroup); ctx.emit(pane_group::Event::FocusPaneGroup);
+36
View File
@@ -1822,6 +1822,11 @@ pub enum Event {
OpenShareSessionModal { OpenShareSessionModal {
open_source: SharedSessionActionSource, open_source: SharedSessionActionSource,
}, },
ShowDeleteConversationConfirmationDialog {
conversation_id: AIConversationId,
conversation_title: String,
terminal_view_id: Option<EntityId>,
},
OpenShareSessionDeniedModal, OpenShareSessionDeniedModal,
/// Used to focus and bring this session to the foreground. /// Used to focus and bring this session to the foreground.
FocusSession, FocusSession,
@@ -4306,6 +4311,16 @@ impl TerminalView {
let object_uid = SyncId::from(*notebook_uid).uid(); let object_uid = SyncId::from(*notebook_uid).uid();
ctx.emit(Event::OpenGalaxyDriveObjectInPane(object_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(), "Execute rewind to before this point in the AI conversation.".to_owned(),
GalaxyA11yRole::ButtonRole, 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( SelectAIAttachedBlock(_) => Custom(AccessibilityContent::new_without_help(
"Click on a block attached as context to this AI query.".to_owned(), "Click on a block attached as context to this AI query.".to_owned(),
GalaxyA11yRole::ButtonRole, 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), CloseContextMenu => self.close_context_menu(ctx, true),
Paste => self.paste(false, ctx), Paste => self.paste(false, ctx),
Copy => self.copy(ctx), Copy => self.copy(ctx),
+3
View File
@@ -246,6 +246,8 @@ pub enum TerminalAction {
exchange_id: AIAgentExchangeId, exchange_id: AIAgentExchangeId,
conversation_id: AIConversationId, conversation_id: AIConversationId,
}, },
/// Ask the workspace to confirm deletion of the active conversation for this terminal view.
RequestDeleteCurrentConversation,
SelectAllBlocks, SelectAllBlocks,
ExpandBlockSelectionAbove, ExpandBlockSelectionAbove,
ExpandBlockSelectionBelow, ExpandBlockSelectionBelow,
@@ -578,6 +580,7 @@ impl fmt::Debug for TerminalAction {
write!(f, "OpenInputContextMenu {{ position: {position:?} }}") write!(f, "OpenInputContextMenu {{ position: {position:?} }}")
} }
InputContextMenuItem(action) => write!(f, "InputContextMenuItem({action:?})"), InputContextMenuItem(action) => write!(f, "InputContextMenuItem({action:?})"),
RequestDeleteCurrentConversation => f.write_str("RequestDeleteCurrentConversation"),
SelectAllBlocks => f.write_str("SelectAllBlocks"), SelectAllBlocks => f.write_str("SelectAllBlocks"),
ExpandBlockSelectionAbove => f.write_str("ExpandBlockSelectionAbove"), ExpandBlockSelectionAbove => f.write_str("ExpandBlockSelectionAbove"),
ExpandBlockSelectionBelow => f.write_str("ExpandBlockSelectionBelow"), ExpandBlockSelectionBelow => f.write_str("ExpandBlockSelectionBelow"),
+30
View File
@@ -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 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 { pub fn selected_conversation_is_empty(&self, ctx: &AppContext) -> bool {
self.selected_conversation_for_user_facing_chrome(ctx) self.selected_conversation_for_user_facing_chrome(ctx)
.is_some_and(|conversation| conversation.is_empty()) .is_some_and(|conversation| conversation.is_empty())
+6
View File
@@ -44,6 +44,7 @@ use crate::terminal::view::inline_banner::ZeroStatePromptSuggestionType;
use crate::themes::theme::AnsiColorIdentifier; use crate::themes::theme::AnsiColorIdentifier;
use crate::themes::theme_chooser::ThemeChooserMode; use crate::themes::theme_chooser::ThemeChooserMode;
use crate::workflows::{WorkflowSelectionSource, WorkflowSource, WorkflowType}; use crate::workflows::{WorkflowSelectionSource, WorkflowSource, WorkflowType};
use crate::workspace::delete_conversation_confirmation_dialog::DeleteConversationTarget;
use crate::workspace::tab_group::TabGroupId; use crate::workspace::tab_group::TabGroupId;
use crate::workspace::PaneViewLocator; use crate::workspace::PaneViewLocator;
@@ -794,6 +795,10 @@ pub enum WorkspaceAction {
conversation_id: AIConversationId, conversation_id: AIConversationId,
terminal_view_id: Option<EntityId>, terminal_view_id: Option<EntityId>,
}, },
/// Execute the actual deletion of multiple conversations after confirmation
ExecuteDeleteConversations {
conversations: Vec<DeleteConversationTarget>,
},
/// Open the canonical ambient agent conversation pane and attach it to a live session. /// Open the canonical ambient agent conversation pane and attach it to a live session.
OpenOrAttachAmbientAgentConversation { OpenOrAttachAmbientAgentConversation {
session_id: SessionId, session_id: SessionId,
@@ -1167,6 +1172,7 @@ impl WorkspaceAction {
| ShowRewindConfirmationDialog { .. } | ShowRewindConfirmationDialog { .. }
| ExecuteRewindAIConversation { .. } | ExecuteRewindAIConversation { .. }
| ExecuteDeleteConversation { .. } | ExecuteDeleteConversation { .. }
| ExecuteDeleteConversations { .. }
| OpenOrAttachAmbientAgentConversation { .. } | OpenOrAttachAmbientAgentConversation { .. }
| OpenConversationTranscriptViewer { .. } | OpenConversationTranscriptViewer { .. }
| OpenLightbox { .. } | OpenLightbox { .. }
@@ -36,13 +36,46 @@ pub fn init(app: &mut AppContext) {
const DIALOG_WIDTH: f32 = 460.; const DIALOG_WIDTH: f32 = 460.;
#[derive(Clone)] #[derive(Clone, Debug)]
pub struct DeleteConversationDialogSource { pub struct DeleteConversationDialogSource {
pub conversations: Vec<DeleteConversationTarget>,
}
#[derive(Clone, Debug)]
pub struct DeleteConversationTarget {
pub conversation_id: AIConversationId, pub conversation_id: AIConversationId,
pub conversation_title: String, pub conversation_title: String,
pub terminal_view_id: Option<galaxyui::EntityId>, pub terminal_view_id: Option<galaxyui::EntityId>,
} }
impl DeleteConversationDialogSource {
pub fn single(
conversation_id: AIConversationId,
conversation_title: String,
terminal_view_id: Option<galaxyui::EntityId>,
) -> Self {
Self {
conversations: vec![DeleteConversationTarget {
conversation_id,
conversation_title,
terminal_view_id,
}],
}
}
pub fn multiple(conversations: Vec<DeleteConversationTarget>) -> Self {
Self { conversations }
}
pub fn len(&self) -> usize {
self.conversations.len()
}
pub fn is_empty(&self) -> bool {
self.conversations.is_empty()
}
}
pub struct DeleteConversationConfirmationDialog { pub struct DeleteConversationConfirmationDialog {
cancel_button: ViewHandle<ActionButton>, cancel_button: ViewHandle<ActionButton>,
delete_button: ViewHandle<ActionButton>, delete_button: ViewHandle<ActionButton>,
@@ -101,15 +134,34 @@ impl View for DeleteConversationConfirmationDialog {
let title = self let title = self
.source .source
.as_ref() .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()); .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( let dialog = Dialog::new(
title, title,
Some( Some(body),
"This conversation will be permanently deleted. This action cannot be undone."
.into(),
),
UiComponentStyles { UiComponentStyles {
width: Some(DIALOG_WIDTH), width: Some(DIALOG_WIDTH),
..dialog_styles(appearance) ..dialog_styles(appearance)
@@ -165,6 +217,10 @@ impl TypedActionView for DeleteConversationConfirmationDialog {
log::error!("Delete confirm button pressed with no source"); log::error!("Delete confirm button pressed with no source");
return; return;
}; };
if source.is_empty() {
log::error!("Delete confirm button pressed with no conversations");
return;
}
ctx.emit(DeleteConversationConfirmationEvent::Confirm { source }); ctx.emit(DeleteConversationConfirmationEvent::Confirm { source });
} }
DeleteConversationConfirmationAction::Cancel => { DeleteConversationConfirmationAction::Cancel => {
+127 -49
View File
@@ -133,7 +133,7 @@ use super::close_session_confirmation_dialog::{
}; };
use super::delete_conversation_confirmation_dialog::{ use super::delete_conversation_confirmation_dialog::{
DeleteConversationConfirmationDialog, DeleteConversationConfirmationEvent, DeleteConversationConfirmationDialog, DeleteConversationConfirmationEvent,
DeleteConversationDialogSource, DeleteConversationDialogSource, DeleteConversationTarget,
}; };
use super::hoa_onboarding::{ use super::hoa_onboarding::{
mark_hoa_onboarding_completed, HoaOnboardingFlow, HoaOnboardingFlowEvent, HoaOnboardingStep, mark_hoa_onboarding_completed, HoaOnboardingFlow, HoaOnboardingFlowEvent, HoaOnboardingStep,
@@ -6154,6 +6154,20 @@ impl Workspace {
false, 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, terminal_view_id,
} => { } => {
self.show_delete_conversation_confirmation_dialog( self.show_delete_conversation_confirmation_dialog(
DeleteConversationDialogSource { DeleteConversationDialogSource::single(
conversation_id: *conversation_id, *conversation_id,
conversation_title: conversation_title.clone(), conversation_title.clone(),
terminal_view_id: *terminal_view_id, *terminal_view_id,
}, ),
ctx,
);
}
LeftPanelEvent::ShowBulkDeleteConfirmationDialog { conversations } => {
self.show_delete_conversation_confirmation_dialog(
DeleteConversationDialogSource::multiple(conversations.clone()),
ctx, ctx,
); );
} }
@@ -11208,13 +11228,22 @@ impl Workspace {
DeleteConversationConfirmationEvent::Confirm { source } => { DeleteConversationConfirmationEvent::Confirm { source } => {
self.current_workspace_state self.current_workspace_state
.is_delete_conversation_confirmation_dialog_open = false; .is_delete_conversation_confirmation_dialog_open = false;
self.handle_action( if let [conversation] = source.conversations.as_slice() {
&WorkspaceAction::ExecuteDeleteConversation { self.handle_action(
conversation_id: source.conversation_id, &WorkspaceAction::ExecuteDeleteConversation {
terminal_view_id: source.terminal_view_id, conversation_id: conversation.conversation_id,
}, terminal_view_id: conversation.terminal_view_id,
ctx, },
); ctx,
);
} else {
self.handle_action(
&WorkspaceAction::ExecuteDeleteConversations {
conversations: source.conversations.clone(),
},
ctx,
);
}
ctx.focus(&self.left_panel_view); ctx.focus(&self.left_panel_view);
ctx.notify(); ctx.notify();
} }
@@ -16625,6 +16654,20 @@ impl Workspace {
toast_stack.add_ephemeral_toast(toast, ctx); 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 } => { pane_group::Event::SignupAnonymousUser { entrypoint } => {
self.initiate_user_signup(*entrypoint, ctx); self.initiate_user_signup(*entrypoint, ctx);
} }
@@ -18173,6 +18216,68 @@ impl Workspace {
ctx.notify(); ctx.notify();
} }
fn delete_conversation_targets(
&mut self,
conversations: Vec<DeleteConversationTarget>,
window_id: WindowId,
ctx: &mut ViewContext<Self>,
) {
let mut seen = HashSet::new();
let conversations = conversations
.into_iter()
.filter(|target| seen.insert(target.conversation_id))
.collect::<Vec<_>>();
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( pub fn show_native_modal(
&mut self, &mut self,
dialog: AlertDialogWithCallbacks<AppModalCallback>, dialog: AlertDialogWithCallbacks<AppModalCallback>,
@@ -25520,42 +25625,15 @@ impl TypedActionView for Workspace {
conversation_id, conversation_id,
terminal_view_id, terminal_view_id,
} => { } => {
// Exit agent view first if this conversation is currently expanded. let target = DeleteConversationTarget {
// This must happen before updating BlocklistAIHistoryModel to avoid conversation_id: *conversation_id,
// circular model references. conversation_title: String::new(),
if let Some(controller) = ActiveAgentViewsModel::as_ref(ctx) terminal_view_id: *terminal_view_id,
.get_controller_for_conversation(*conversation_id, ctx) };
{ self.delete_conversation_targets(vec![target], window_id, ctx);
let succesfully_exited_agent_view = }
controller.update(ctx, |controller, ctx| { ExecuteDeleteConversations { conversations } => {
controller.exit_agent_view(ctx); self.delete_conversation_targets(conversations.clone(), window_id, 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,
);
});
} }
#[cfg(target_family = "wasm")] #[cfg(target_family = "wasm")]
ToggleConversationTranscriptDetailsPanel => { ToggleConversationTranscriptDetailsPanel => {
+103 -16
View File
@@ -12,6 +12,7 @@ use galaxyui::elements::{
use galaxyui::fonts::{Properties, Weight}; use galaxyui::fonts::{Properties, Weight};
use galaxyui::platform::Cursor; use galaxyui::platform::Cursor;
use galaxyui::text_layout::TextStyle; use galaxyui::text_layout::TextStyle;
use galaxyui::ui_components::checkbox::Checkbox;
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles}; use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
use galaxyui::ui_components::text_input::TextInput; use galaxyui::ui_components::text_input::TextInput;
use galaxyui::{AppContext, SingletonEntity, ViewHandle}; 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 /// the conversation list reads better with the status sitting slightly further out than
/// on the other surfaces. /// on the other surfaces.
const LIST_ITEM_OVERLAY_EXTRA_OVERHANG: f32 = 0.05; 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 /// Generate a position ID for a conversation list item
fn conversation_item_position_id(id: &AgentConversationEntryId) -> String { fn conversation_item_position_id(id: &AgentConversationEntryId) -> String {
@@ -99,6 +101,8 @@ pub struct ItemProps<'a> {
pub rename_editor: Option<&'a ViewHandle<EditorView>>, pub rename_editor: Option<&'a ViewHandle<EditorView>>,
pub sharing_dialog: &'a ViewHandle<SharingDialog>, pub sharing_dialog: &'a ViewHandle<SharingDialog>,
pub is_share_dialog_open: bool, 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 list_position_id: &'a str,
pub tooltip_opens_right: bool, pub tooltip_opens_right: bool,
} }
@@ -194,6 +198,8 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
rename_editor, rename_editor,
sharing_dialog, sharing_dialog,
is_share_dialog_open, is_share_dialog_open,
is_bulk_delete_mode,
is_bulk_delete_selected,
list_position_id, list_position_id,
tooltip_opens_right, tooltip_opens_right,
} = props; } = props;
@@ -255,16 +261,21 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
theme.background(), theme.background(),
); );
let icon_and_title_row = Shrinkable::new( let mut title_row = Flex::row()
1.0, .with_cross_axis_alignment(CrossAxisAlignment::Center)
Flex::row() .with_spacing(ICON_SPACING);
.with_cross_axis_alignment(CrossAxisAlignment::Center) if is_bulk_delete_mode {
.with_spacing(ICON_SPACING) title_row.add_child(render_bulk_delete_checkbox(
.with_child(icon_element) state.overflow_button_state.clone(),
.with_child(Shrinkable::new(1.0, title_element).finish()) is_bulk_delete_selected,
.finish(), conversation.capabilities.can_delete,
) appearance,
.finish(); ));
}
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( let timestamp = Text::new_inline(
format_approx_duration_from_now_utc(conversation.display.last_updated), format_approx_duration_from_now_utc(conversation.display.last_updated),
@@ -274,6 +285,13 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
.with_color(theme.sub_text_color(theme.background()).into()) .with_color(theme.sub_text_color(theme.background()).into())
.finish(); .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 bottom_row = if let Some(subtext) = format_item_subtext(conversation, app) {
let subtext_element = Shrinkable::new( let subtext_element = Shrinkable::new(
1.0, 1.0,
@@ -292,7 +310,7 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
.with_child(timestamp) .with_child(timestamp)
.finish(), .finish(),
) )
.with_padding_left(status_element_size + ICON_SPACING) .with_padding_left(bottom_row_left_padding)
.finish() .finish()
} else { } else {
// If no subtext, still show timestamp in the bottom row // If no subtext, still show timestamp in the bottom row
@@ -303,7 +321,7 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
.with_child(timestamp) .with_child(timestamp)
.finish(), .finish(),
) )
.with_padding_left(status_element_size + ICON_SPACING) .with_padding_left(bottom_row_left_padding)
.finish() .finish()
}; };
@@ -313,7 +331,7 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
.with_child(bottom_row) .with_child(bottom_row)
.finish(); .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 tooltip_text = truncate_from_end(&conversation.display.title, MAX_TOOLTIP_LENGTH);
let overflow_button_state = state.overflow_button_state.clone(); let overflow_button_state = state.overflow_button_state.clone();
let hoverable = Hoverable::new(state.mouse_state.clone(), move |_| { let hoverable = Hoverable::new(state.mouse_state.clone(), move |_| {
@@ -332,7 +350,8 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
let mut stack = Stack::new().with_child(container.finish()); 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. // 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)) && (is_selected || !matches!(overflow_menu_display, OverflowMenuDisplay::Closed))
{ {
let button_style = UiComponentStyles::default() let button_style = UiComponentStyles::default()
@@ -373,7 +392,8 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
} }
// Hide the tooltip when the overflow menu is being shown so that they don't overlap. // 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 && is_selected
&& matches!(overflow_menu_display, OverflowMenuDisplay::Closed) && matches!(overflow_menu_display, OverflowMenuDisplay::Closed)
{ {
@@ -396,6 +416,9 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
.on_right_click({ .on_right_click({
let list_position_id = list_position_id.to_string(); let list_position_id = list_position_id.to_string();
move |ctx, _, position| { move |ctx, _, position| {
if is_bulk_delete_mode {
return;
}
let Some(parent_bounds) = ctx.element_position_by_id(&list_position_id) else { 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."); log::warn!("Could not retrieve the position of the conversation list for overflow menu display.");
return; return;
@@ -410,7 +433,22 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
}) })
.with_defer_events_to_children(); .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 hoverable
.with_cursor(Cursor::PointingHand) .with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| { .on_click(move |ctx, _, _| {
@@ -468,6 +506,55 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
SavePosition::new(item_stack.finish(), &position_id).finish() 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<dyn Element> {
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( fn render_inline_rename_editor(
rename_editor: &ViewHandle<EditorView>, rename_editor: &ViewHandle<EditorView>,
appearance: &Appearance, appearance: &Appearance,
@@ -18,6 +18,8 @@ use galaxyui::keymap::macros::*;
use galaxyui::keymap::FixedBinding; use galaxyui::keymap::FixedBinding;
use galaxyui::platform::Cursor; use galaxyui::platform::Cursor;
use galaxyui::text_layout::TextAlignment; use galaxyui::text_layout::TextAlignment;
use galaxyui::ui_components::checkbox::Checkbox;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::{ use galaxyui::{
AppContext, BlurContext, Entity, EntityId, ModelHandle, SingletonEntity, TypedActionView, View, AppContext, BlurContext, Entity, EntityId, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext, ViewHandle, WindowId, ViewContext, ViewHandle, WindowId,
@@ -42,8 +44,11 @@ use crate::editor::{
}; };
use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields}; use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields};
use crate::server::telemetry::SharingDialogSource; 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::view_components::DismissibleToast;
use crate::workspace::delete_conversation_confirmation_dialog::DeleteConversationTarget;
use crate::workspace::global_actions::ForkedConversationDestination; use crate::workspace::global_actions::ForkedConversationDestination;
use crate::workspace::header_toolbar_item::HeaderToolbarItemKind; use crate::workspace::header_toolbar_item::HeaderToolbarItemKind;
use crate::workspace::tab_settings::TabSettings; use crate::workspace::tab_settings::TabSettings;
@@ -56,6 +61,7 @@ use crate::workspace::{ToastStack, WorkspaceAction};
const VIEW_ALL_LABEL: &str = "View all"; const VIEW_ALL_LABEL: &str = "View all";
/// Maximum number of past items to show before the user toggles "view all". /// Maximum number of past items to show before the user toggles "view all".
const INITIAL_MAX_PAST_ITEMS: usize = 10; 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.). /// State handles for tracking UI state (hover, scroll, list selection, etc.).
struct StateHandles { struct StateHandles {
@@ -67,6 +73,7 @@ struct StateHandles {
zero_state_button: MouseStateHandle, zero_state_button: MouseStateHandle,
active_header: MouseStateHandle, active_header: MouseStateHandle,
past_header: MouseStateHandle, past_header: MouseStateHandle,
bulk_select_all: MouseStateHandle,
} }
impl Default for StateHandles { impl Default for StateHandles {
@@ -80,6 +87,7 @@ impl Default for StateHandles {
zero_state_button: MouseStateHandle::default(), zero_state_button: MouseStateHandle::default(),
active_header: MouseStateHandle::default(), active_header: MouseStateHandle::default(),
past_header: MouseStateHandle::default(), past_header: MouseStateHandle::default(),
bulk_select_all: MouseStateHandle::default(),
} }
} }
} }
@@ -150,6 +158,13 @@ pub enum ConversationListViewAction {
}, },
FinishRename, FinishRename,
CancelRename, CancelRename,
EnterBulkDeleteMode,
ExitBulkDeleteMode,
ToggleBulkDeleteSelection {
id: AgentConversationEntryId,
},
ToggleSelectAllDeletable,
DeleteSelectedConversations,
} }
pub enum Event { pub enum Event {
@@ -159,6 +174,9 @@ pub enum Event {
conversation_title: String, conversation_title: String,
terminal_view_id: Option<EntityId>, terminal_view_id: Option<EntityId>,
}, },
ShowBulkDeleteConfirmationDialog {
conversations: Vec<DeleteConversationTarget>,
},
} }
pub struct ConversationListView { pub struct ConversationListView {
@@ -167,6 +185,9 @@ pub struct ConversationListView {
view_model: ModelHandle<ConversationListViewModel>, view_model: ModelHandle<ConversationListViewModel>,
query_editor: ViewHandle<EditorView>, query_editor: ViewHandle<EditorView>,
toggle_view_all_button: ViewHandle<ActionButton>, toggle_view_all_button: ViewHandle<ActionButton>,
cleanup_button: ViewHandle<ActionButton>,
delete_selected_button: ViewHandle<ActionButton>,
cancel_bulk_delete_button: ViewHandle<ActionButton>,
item_overflow_menu: ViewHandle<Menu<ConversationListViewAction>>, item_overflow_menu: ViewHandle<Menu<ConversationListViewAction>>,
/// Tracks the overflow menu state (which item it's open for and where to position it). /// Tracks the overflow menu state (which item it's open for and where to position it).
overflow_menu_state: Option<OverflowMenuState>, overflow_menu_state: Option<OverflowMenuState>,
@@ -186,6 +207,8 @@ pub struct ConversationListView {
/// Total number of past items before truncation /// Total number of past items before truncation
/// (we use this to decide whether or not to show the view all button). /// (we use this to decide whether or not to show the view all button).
total_past_items: usize, total_past_items: usize,
is_bulk_delete_mode: bool,
bulk_delete_selection: HashSet<AgentConversationEntryId>,
state_handles: StateHandles, 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(|_| { let item_overflow_menu = ctx.add_typed_action_view(|_| {
Menu::new() Menu::new()
.prevent_interaction_with_other_elements() .prevent_interaction_with_other_elements()
@@ -309,6 +359,9 @@ impl ConversationListView {
view_model, view_model,
query_editor, query_editor,
toggle_view_all_button, toggle_view_all_button,
cleanup_button,
delete_selected_button,
cancel_bulk_delete_button,
item_overflow_menu, item_overflow_menu,
overflow_menu_state: None, overflow_menu_state: None,
sharing_dialog, sharing_dialog,
@@ -320,6 +373,8 @@ impl ConversationListView {
list_items: Arc::new(Vec::new()), list_items: Arc::new(Vec::new()),
view_all: false, view_all: false,
total_past_items: 0, total_past_items: 0,
is_bulk_delete_mode: false,
bulk_delete_selection: HashSet::new(),
state_handles: StateHandles::default(), state_handles: StateHandles::default(),
}; };
view.sync_list_items(ctx); view.sync_list_items(ctx);
@@ -675,13 +730,25 @@ impl ConversationListView {
.retain(|id, _| current_ids.contains(id)); .retain(|id, _| current_ids.contains(id));
// Add new entries // Add new entries
for id in current_ids { for id in &current_ids {
self.state_handles.item_states.entry(id).or_default(); self.state_handles.item_states.entry(*id).or_default();
} }
// Rebuild list_items with current collapse state // Rebuild list_items with current collapse state
self.rebuild_list_items(ctx); 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. // Adjust selection if it's now invalid.
if let Some(index) = self.selected_index { if let Some(index) = self.selected_index {
if index >= self.item_count() { if index >= self.item_count() {
@@ -694,6 +761,146 @@ impl ConversationListView {
ctx.notify(); ctx.notify();
} }
fn deletable_visible_conversation_ids(
&self,
ctx: &AppContext,
) -> Vec<AgentConversationEntryId> {
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<DeleteConversationTarget> {
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<Self>,
) {
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<dyn Element> {
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<Self>) { fn start_rename(&mut self, id: AgentConversationEntryId, ctx: &mut ViewContext<Self>) {
let Some(entry) = self.view_model.as_ref(ctx).get_item_by_id(&id, ctx) else { let Some(entry) = self.view_model.as_ref(ctx).get_item_by_id(&id, ctx) else {
return; return;
@@ -969,6 +1176,26 @@ fn render_list_action_button(button: &ViewHandle<ActionButton>) -> Box<dyn Eleme
.finish() .finish()
} }
fn render_cleanup_action(
cleanup_button: &ViewHandle<ActionButton>,
app: &AppContext,
) -> Box<dyn Element> {
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 { impl Entity for ConversationListView {
type Event = Event; type Event = Event;
} }
@@ -1274,6 +1501,52 @@ impl TypedActionView for ConversationListView {
ConversationListViewAction::CancelRename => { ConversationListViewAction::CancelRename => {
self.cancel_rename(ctx); 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 = let open_conversation_ids =
ActiveAgentViewsModel::as_ref(app).get_all_open_conversation_ids(app); ActiveAgentViewsModel::as_ref(app).get_all_open_conversation_ids(app);
let share_dialog_open_for = self.share_dialog_open_for; 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 list_position_id = self.get_position_id();
let tooltip_opens_right = TabSettings::as_ref(app) let tooltip_opens_right = TabSettings::as_ref(app)
.header_toolbar_chip_selection .header_toolbar_chip_selection
@@ -1413,6 +1688,8 @@ impl View for ConversationListView {
}; };
let is_share_dialog_open = let is_share_dialog_open =
share_dialog_open_for == Some(entry.id); share_dialog_open_for == Some(entry.id);
let is_bulk_delete_selected =
bulk_delete_selection.contains(&entry.id);
Some(render_item( Some(render_item(
ItemProps { ItemProps {
conversation: &conversation, conversation: &conversation,
@@ -1429,6 +1706,8 @@ impl View for ConversationListView {
rename_editor: is_renaming.then_some(&rename_editor), rename_editor: is_renaming.then_some(&rename_editor),
sharing_dialog: &sharing_dialog, sharing_dialog: &sharing_dialog,
is_share_dialog_open, is_share_dialog_open,
is_bulk_delete_mode,
is_bulk_delete_selected,
list_position_id: &list_position_id, list_position_id: &list_position_id,
tooltip_opens_right, tooltip_opens_right,
}, },
@@ -1481,6 +1760,11 @@ impl View for ConversationListView {
if has_conversations { if has_conversations {
column = column.with_child(render_search_box(&self.query_editor, app)); 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 let column_element = column
+9
View File
@@ -49,6 +49,7 @@ use crate::util::openable_file_type::FileTarget;
use crate::util::openable_file_type::{ use crate::util::openable_file_type::{
is_markdown_file, resolve_file_target_with_editor_choice, EditorLayout, 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::{ use crate::workspace::view::conversation_list::view::{
ConversationListView, Event as ConversationListViewEvent, ConversationListView, Event as ConversationListViewEvent,
}; };
@@ -97,6 +98,9 @@ pub enum LeftPanelEvent {
conversation_title: String, conversation_title: String,
terminal_view_id: Option<galaxyui::EntityId>, terminal_view_id: Option<galaxyui::EntityId>,
}, },
ShowBulkDeleteConfirmationDialog {
conversations: Vec<DeleteConversationTarget>,
},
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -236,6 +240,11 @@ impl LeftPanelView {
terminal_view_id: *terminal_view_id, 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); let active_view = views.first().copied().unwrap_or(ToolPanelView::WarpDrive);
+13
View File
@@ -103,6 +103,19 @@ impl Workspace {
true, 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 panel