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