live fixes
This commit is contained in:
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
@@ -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 => {
|
||||||
|
|||||||
@@ -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 ¤t_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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user