live fixes

This commit is contained in:
2026-08-12 17:36:21 -05:00
parent a5fac64cd9
commit 934cddc063
20 changed files with 1559 additions and 124 deletions
+6
View File
@@ -44,6 +44,7 @@ use crate::terminal::view::inline_banner::ZeroStatePromptSuggestionType;
use crate::themes::theme::AnsiColorIdentifier;
use crate::themes::theme_chooser::ThemeChooserMode;
use crate::workflows::{WorkflowSelectionSource, WorkflowSource, WorkflowType};
use crate::workspace::delete_conversation_confirmation_dialog::DeleteConversationTarget;
use crate::workspace::tab_group::TabGroupId;
use crate::workspace::PaneViewLocator;
@@ -794,6 +795,10 @@ pub enum WorkspaceAction {
conversation_id: AIConversationId,
terminal_view_id: Option<EntityId>,
},
/// Execute the actual deletion of multiple conversations after confirmation
ExecuteDeleteConversations {
conversations: Vec<DeleteConversationTarget>,
},
/// Open the canonical ambient agent conversation pane and attach it to a live session.
OpenOrAttachAmbientAgentConversation {
session_id: SessionId,
@@ -1167,6 +1172,7 @@ impl WorkspaceAction {
| ShowRewindConfirmationDialog { .. }
| ExecuteRewindAIConversation { .. }
| ExecuteDeleteConversation { .. }
| ExecuteDeleteConversations { .. }
| OpenOrAttachAmbientAgentConversation { .. }
| OpenConversationTranscriptViewer { .. }
| OpenLightbox { .. }
@@ -36,13 +36,46 @@ pub fn init(app: &mut AppContext) {
const DIALOG_WIDTH: f32 = 460.;
#[derive(Clone)]
#[derive(Clone, Debug)]
pub struct DeleteConversationDialogSource {
pub conversations: Vec<DeleteConversationTarget>,
}
#[derive(Clone, Debug)]
pub struct DeleteConversationTarget {
pub conversation_id: AIConversationId,
pub conversation_title: String,
pub terminal_view_id: Option<galaxyui::EntityId>,
}
impl DeleteConversationDialogSource {
pub fn single(
conversation_id: AIConversationId,
conversation_title: String,
terminal_view_id: Option<galaxyui::EntityId>,
) -> Self {
Self {
conversations: vec![DeleteConversationTarget {
conversation_id,
conversation_title,
terminal_view_id,
}],
}
}
pub fn multiple(conversations: Vec<DeleteConversationTarget>) -> Self {
Self { conversations }
}
pub fn len(&self) -> usize {
self.conversations.len()
}
pub fn is_empty(&self) -> bool {
self.conversations.is_empty()
}
}
pub struct DeleteConversationConfirmationDialog {
cancel_button: ViewHandle<ActionButton>,
delete_button: ViewHandle<ActionButton>,
@@ -101,15 +134,34 @@ impl View for DeleteConversationConfirmationDialog {
let title = self
.source
.as_ref()
.map(|s| format!("Delete '{}'?", s.conversation_title))
.map(|source| match source.conversations.as_slice() {
[conversation] => format!("Delete '{}'?", conversation.conversation_title),
conversations => format!("Delete {} conversations?", conversations.len()),
})
.unwrap_or_else(|| "Delete conversation?".into());
let body = self
.source
.as_ref()
.map(|source| {
if source.len() == 1 {
"This conversation will be permanently deleted. This action cannot be undone."
.to_string()
} else {
format!(
"{} conversations will be permanently deleted. This action cannot be undone.",
source.len()
)
}
})
.unwrap_or_else(|| {
"This conversation will be permanently deleted. This action cannot be undone."
.to_string()
});
let dialog = Dialog::new(
title,
Some(
"This conversation will be permanently deleted. This action cannot be undone."
.into(),
),
Some(body),
UiComponentStyles {
width: Some(DIALOG_WIDTH),
..dialog_styles(appearance)
@@ -165,6 +217,10 @@ impl TypedActionView for DeleteConversationConfirmationDialog {
log::error!("Delete confirm button pressed with no source");
return;
};
if source.is_empty() {
log::error!("Delete confirm button pressed with no conversations");
return;
}
ctx.emit(DeleteConversationConfirmationEvent::Confirm { source });
}
DeleteConversationConfirmationAction::Cancel => {
+127 -49
View File
@@ -133,7 +133,7 @@ use super::close_session_confirmation_dialog::{
};
use super::delete_conversation_confirmation_dialog::{
DeleteConversationConfirmationDialog, DeleteConversationConfirmationEvent,
DeleteConversationDialogSource,
DeleteConversationDialogSource, DeleteConversationTarget,
};
use super::hoa_onboarding::{
mark_hoa_onboarding_completed, HoaOnboardingFlow, HoaOnboardingFlowEvent, HoaOnboardingStep,
@@ -6154,6 +6154,20 @@ impl Workspace {
false,
);
}
AgentManagementViewEvent::ShowDeleteConfirmationDialog {
conversation_id,
conversation_title,
terminal_view_id,
} => {
self.show_delete_conversation_confirmation_dialog(
DeleteConversationDialogSource::single(
*conversation_id,
conversation_title.clone(),
*terminal_view_id,
),
ctx,
);
}
}
}
@@ -6343,11 +6357,17 @@ impl Workspace {
terminal_view_id,
} => {
self.show_delete_conversation_confirmation_dialog(
DeleteConversationDialogSource {
conversation_id: *conversation_id,
conversation_title: conversation_title.clone(),
terminal_view_id: *terminal_view_id,
},
DeleteConversationDialogSource::single(
*conversation_id,
conversation_title.clone(),
*terminal_view_id,
),
ctx,
);
}
LeftPanelEvent::ShowBulkDeleteConfirmationDialog { conversations } => {
self.show_delete_conversation_confirmation_dialog(
DeleteConversationDialogSource::multiple(conversations.clone()),
ctx,
);
}
@@ -11208,13 +11228,22 @@ impl Workspace {
DeleteConversationConfirmationEvent::Confirm { source } => {
self.current_workspace_state
.is_delete_conversation_confirmation_dialog_open = false;
self.handle_action(
&WorkspaceAction::ExecuteDeleteConversation {
conversation_id: source.conversation_id,
terminal_view_id: source.terminal_view_id,
},
ctx,
);
if let [conversation] = source.conversations.as_slice() {
self.handle_action(
&WorkspaceAction::ExecuteDeleteConversation {
conversation_id: conversation.conversation_id,
terminal_view_id: conversation.terminal_view_id,
},
ctx,
);
} else {
self.handle_action(
&WorkspaceAction::ExecuteDeleteConversations {
conversations: source.conversations.clone(),
},
ctx,
);
}
ctx.focus(&self.left_panel_view);
ctx.notify();
}
@@ -16625,6 +16654,20 @@ impl Workspace {
toast_stack.add_ephemeral_toast(toast, ctx);
});
}
pane_group::Event::ShowDeleteConversationConfirmationDialog {
conversation_id,
conversation_title,
terminal_view_id,
} => {
self.show_delete_conversation_confirmation_dialog(
DeleteConversationDialogSource::single(
*conversation_id,
conversation_title.clone(),
*terminal_view_id,
),
ctx,
);
}
pane_group::Event::SignupAnonymousUser { entrypoint } => {
self.initiate_user_signup(*entrypoint, ctx);
}
@@ -18173,6 +18216,68 @@ impl Workspace {
ctx.notify();
}
fn delete_conversation_targets(
&mut self,
conversations: Vec<DeleteConversationTarget>,
window_id: WindowId,
ctx: &mut ViewContext<Self>,
) {
let mut seen = HashSet::new();
let conversations = conversations
.into_iter()
.filter(|target| seen.insert(target.conversation_id))
.collect::<Vec<_>>();
if conversations.is_empty() {
return;
}
for target in &conversations {
// Exit agent view first if this conversation is currently expanded.
// This must happen before updating BlocklistAIHistoryModel to avoid
// circular model references.
if let Some(controller) = ActiveAgentViewsModel::as_ref(ctx)
.get_controller_for_conversation(target.conversation_id, ctx)
{
let succesfully_exited_agent_view = controller.update(ctx, |controller, ctx| {
controller.exit_agent_view(ctx);
!controller.is_active()
});
if !succesfully_exited_agent_view {
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(
DismissibleToast::error(
"Failed to delete conversation. Please exit the agent view and try again.".to_string(),
),
window_id,
ctx,
);
});
return;
}
}
}
let deleted_count = conversations.len();
for target in conversations {
conversation_utils::delete_conversation(
target.conversation_id,
target.terminal_view_id,
ctx,
);
}
send_telemetry_from_ctx!(TelemetryEvent::ConversationListItemDeleted, ctx);
let message = if deleted_count == 1 {
"Conversation deleted".to_string()
} else {
format!("{deleted_count} conversations deleted")
};
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(DismissibleToast::success(message), window_id, ctx);
});
}
pub fn show_native_modal(
&mut self,
dialog: AlertDialogWithCallbacks<AppModalCallback>,
@@ -25520,42 +25625,15 @@ impl TypedActionView for Workspace {
conversation_id,
terminal_view_id,
} => {
// Exit agent view first if this conversation is currently expanded.
// This must happen before updating BlocklistAIHistoryModel to avoid
// circular model references.
if let Some(controller) = ActiveAgentViewsModel::as_ref(ctx)
.get_controller_for_conversation(*conversation_id, ctx)
{
let succesfully_exited_agent_view =
controller.update(ctx, |controller, ctx| {
controller.exit_agent_view(ctx);
!controller.is_active()
});
if !succesfully_exited_agent_view {
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(
DismissibleToast::error(
"Failed to delete conversation. Please exit the agent view and try again.".to_string(),
),
window_id,
ctx,
);
});
return;
}
}
conversation_utils::delete_conversation(*conversation_id, *terminal_view_id, ctx);
send_telemetry_from_ctx!(TelemetryEvent::ConversationListItemDeleted, ctx);
ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| {
toast_stack.add_ephemeral_toast(
DismissibleToast::success("Conversation deleted".to_string()),
window_id,
ctx,
);
});
let target = DeleteConversationTarget {
conversation_id: *conversation_id,
conversation_title: String::new(),
terminal_view_id: *terminal_view_id,
};
self.delete_conversation_targets(vec![target], window_id, ctx);
}
ExecuteDeleteConversations { conversations } => {
self.delete_conversation_targets(conversations.clone(), window_id, ctx);
}
#[cfg(target_family = "wasm")]
ToggleConversationTranscriptDetailsPanel => {
+103 -16
View File
@@ -12,6 +12,7 @@ use galaxyui::elements::{
use galaxyui::fonts::{Properties, Weight};
use galaxyui::platform::Cursor;
use galaxyui::text_layout::TextStyle;
use galaxyui::ui_components::checkbox::Checkbox;
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
use galaxyui::ui_components::text_input::TextInput;
use galaxyui::{AppContext, SingletonEntity, ViewHandle};
@@ -51,6 +52,7 @@ const LIST_ITEM_AGENT_SIZE: f32 = 22.;
/// the conversation list reads better with the status sitting slightly further out than
/// on the other surfaces.
const LIST_ITEM_OVERLAY_EXTRA_OVERHANG: f32 = 0.05;
const BULK_CHECKBOX_SIZE: f32 = 14.0;
/// Generate a position ID for a conversation list item
fn conversation_item_position_id(id: &AgentConversationEntryId) -> String {
@@ -99,6 +101,8 @@ pub struct ItemProps<'a> {
pub rename_editor: Option<&'a ViewHandle<EditorView>>,
pub sharing_dialog: &'a ViewHandle<SharingDialog>,
pub is_share_dialog_open: bool,
pub is_bulk_delete_mode: bool,
pub is_bulk_delete_selected: bool,
pub list_position_id: &'a str,
pub tooltip_opens_right: bool,
}
@@ -194,6 +198,8 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
rename_editor,
sharing_dialog,
is_share_dialog_open,
is_bulk_delete_mode,
is_bulk_delete_selected,
list_position_id,
tooltip_opens_right,
} = props;
@@ -255,16 +261,21 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
theme.background(),
);
let icon_and_title_row = Shrinkable::new(
1.0,
Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(ICON_SPACING)
.with_child(icon_element)
.with_child(Shrinkable::new(1.0, title_element).finish())
.finish(),
)
.finish();
let mut title_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(ICON_SPACING);
if is_bulk_delete_mode {
title_row.add_child(render_bulk_delete_checkbox(
state.overflow_button_state.clone(),
is_bulk_delete_selected,
conversation.capabilities.can_delete,
appearance,
));
}
title_row.add_child(icon_element);
title_row.add_child(Shrinkable::new(1.0, title_element).finish());
let icon_and_title_row = Shrinkable::new(1.0, title_row.finish()).finish();
let timestamp = Text::new_inline(
format_approx_duration_from_now_utc(conversation.display.last_updated),
@@ -274,6 +285,13 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
.with_color(theme.sub_text_color(theme.background()).into())
.finish();
let bottom_row_left_padding = status_element_size
+ ICON_SPACING
+ if is_bulk_delete_mode {
BULK_CHECKBOX_SIZE + ICON_SPACING
} else {
0.
};
let bottom_row = if let Some(subtext) = format_item_subtext(conversation, app) {
let subtext_element = Shrinkable::new(
1.0,
@@ -292,7 +310,7 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
.with_child(timestamp)
.finish(),
)
.with_padding_left(status_element_size + ICON_SPACING)
.with_padding_left(bottom_row_left_padding)
.finish()
} else {
// If no subtext, still show timestamp in the bottom row
@@ -303,7 +321,7 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
.with_child(timestamp)
.finish(),
)
.with_padding_left(status_element_size + ICON_SPACING)
.with_padding_left(bottom_row_left_padding)
.finish()
};
@@ -313,7 +331,7 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
.with_child(bottom_row)
.finish();
let can_open = conversation.capabilities.can_open;
let can_open = conversation.capabilities.can_open && !is_bulk_delete_mode;
let tooltip_text = truncate_from_end(&conversation.display.title, MAX_TOOLTIP_LENGTH);
let overflow_button_state = state.overflow_button_state.clone();
let hoverable = Hoverable::new(state.mouse_state.clone(), move |_| {
@@ -332,7 +350,8 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
let mut stack = Stack::new().with_child(container.finish());
// We show the overflow menu button when the item is selected, or the overflow menu is already open.
if !is_renaming
if !is_bulk_delete_mode
&& !is_renaming
&& (is_selected || !matches!(overflow_menu_display, OverflowMenuDisplay::Closed))
{
let button_style = UiComponentStyles::default()
@@ -373,7 +392,8 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
}
// Hide the tooltip when the overflow menu is being shown so that they don't overlap.
if !is_renaming
if !is_bulk_delete_mode
&& !is_renaming
&& is_selected
&& matches!(overflow_menu_display, OverflowMenuDisplay::Closed)
{
@@ -396,6 +416,9 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
.on_right_click({
let list_position_id = list_position_id.to_string();
move |ctx, _, position| {
if is_bulk_delete_mode {
return;
}
let Some(parent_bounds) = ctx.element_position_by_id(&list_position_id) else {
log::warn!("Could not retrieve the position of the conversation list for overflow menu display.");
return;
@@ -410,7 +433,22 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
})
.with_defer_events_to_children();
let hoverable_element = if can_open && !is_renaming {
let hoverable_element = if is_bulk_delete_mode {
if conversation.capabilities.can_delete {
hoverable
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(
ConversationListViewAction::ToggleBulkDeleteSelection {
id: conversation_id,
},
);
})
.finish()
} else {
hoverable.finish()
}
} else if can_open && !is_renaming {
hoverable
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
@@ -468,6 +506,55 @@ pub fn render_item(props: ItemProps<'_>, app: &AppContext) -> Box<dyn Element> {
SavePosition::new(item_stack.finish(), &position_id).finish()
}
fn render_bulk_delete_checkbox(
mouse_state: MouseStateHandle,
is_selected: bool,
can_delete: bool,
appearance: &Appearance,
) -> Box<dyn Element> {
let theme = appearance.theme();
let zero_margin = galaxyui::ui_components::components::Coords::uniform(0.);
let border_color = if can_delete {
theme.sub_text_color(theme.background())
} else {
theme.disabled_text_color(theme.background())
};
let checkbox_default = UiComponentStyles {
font_size: Some(BULK_CHECKBOX_SIZE),
border_width: Some(1.),
border_color: Some(border_color.into()),
border_radius: Some(CornerRadius::with_all(Radius::Pixels(2.))),
margin: Some(zero_margin),
..Default::default()
};
let checkbox_checked = UiComponentStyles {
font_size: Some(BULK_CHECKBOX_SIZE),
background: Some(theme.accent_button_color().into()),
font_color: Some(theme.main_text_color(theme.accent_button_color()).into()),
border_width: Some(1.),
border_color: Some(theme.accent_button_color().into()),
border_radius: Some(CornerRadius::with_all(Radius::Pixels(2.))),
margin: Some(zero_margin),
..Default::default()
};
let mut checkbox = Checkbox::new(
mouse_state,
checkbox_default,
None,
Some(checkbox_checked),
None,
)
.check(is_selected)
.build();
if !can_delete {
checkbox = checkbox.disable();
}
checkbox.finish()
}
fn render_inline_rename_editor(
rename_editor: &ViewHandle<EditorView>,
appearance: &Appearance,
@@ -18,6 +18,8 @@ use galaxyui::keymap::macros::*;
use galaxyui::keymap::FixedBinding;
use galaxyui::platform::Cursor;
use galaxyui::text_layout::TextAlignment;
use galaxyui::ui_components::checkbox::Checkbox;
use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles};
use galaxyui::{
AppContext, BlurContext, Entity, EntityId, ModelHandle, SingletonEntity, TypedActionView, View,
ViewContext, ViewHandle, WindowId,
@@ -42,8 +44,11 @@ use crate::editor::{
};
use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields};
use crate::server::telemetry::SharingDialogSource;
use crate::view_components::action_button::{ActionButton, ButtonSize, SecondaryTheme};
use crate::view_components::action_button::{
ActionButton, ButtonSize, DangerSecondaryTheme, SecondaryTheme,
};
use crate::view_components::DismissibleToast;
use crate::workspace::delete_conversation_confirmation_dialog::DeleteConversationTarget;
use crate::workspace::global_actions::ForkedConversationDestination;
use crate::workspace::header_toolbar_item::HeaderToolbarItemKind;
use crate::workspace::tab_settings::TabSettings;
@@ -56,6 +61,7 @@ use crate::workspace::{ToastStack, WorkspaceAction};
const VIEW_ALL_LABEL: &str = "View all";
/// Maximum number of past items to show before the user toggles "view all".
const INITIAL_MAX_PAST_ITEMS: usize = 10;
const BULK_CHECKBOX_SIZE: f32 = 14.0;
/// State handles for tracking UI state (hover, scroll, list selection, etc.).
struct StateHandles {
@@ -67,6 +73,7 @@ struct StateHandles {
zero_state_button: MouseStateHandle,
active_header: MouseStateHandle,
past_header: MouseStateHandle,
bulk_select_all: MouseStateHandle,
}
impl Default for StateHandles {
@@ -80,6 +87,7 @@ impl Default for StateHandles {
zero_state_button: MouseStateHandle::default(),
active_header: MouseStateHandle::default(),
past_header: MouseStateHandle::default(),
bulk_select_all: MouseStateHandle::default(),
}
}
}
@@ -150,6 +158,13 @@ pub enum ConversationListViewAction {
},
FinishRename,
CancelRename,
EnterBulkDeleteMode,
ExitBulkDeleteMode,
ToggleBulkDeleteSelection {
id: AgentConversationEntryId,
},
ToggleSelectAllDeletable,
DeleteSelectedConversations,
}
pub enum Event {
@@ -159,6 +174,9 @@ pub enum Event {
conversation_title: String,
terminal_view_id: Option<EntityId>,
},
ShowBulkDeleteConfirmationDialog {
conversations: Vec<DeleteConversationTarget>,
},
}
pub struct ConversationListView {
@@ -167,6 +185,9 @@ pub struct ConversationListView {
view_model: ModelHandle<ConversationListViewModel>,
query_editor: ViewHandle<EditorView>,
toggle_view_all_button: ViewHandle<ActionButton>,
cleanup_button: ViewHandle<ActionButton>,
delete_selected_button: ViewHandle<ActionButton>,
cancel_bulk_delete_button: ViewHandle<ActionButton>,
item_overflow_menu: ViewHandle<Menu<ConversationListViewAction>>,
/// Tracks the overflow menu state (which item it's open for and where to position it).
overflow_menu_state: Option<OverflowMenuState>,
@@ -186,6 +207,8 @@ pub struct ConversationListView {
/// Total number of past items before truncation
/// (we use this to decide whether or not to show the view all button).
total_past_items: usize,
is_bulk_delete_mode: bool,
bulk_delete_selection: HashSet<AgentConversationEntryId>,
state_handles: StateHandles,
}
@@ -279,6 +302,33 @@ impl ConversationListView {
})
});
let cleanup_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Clean up sessions", SecondaryTheme)
.with_size(ButtonSize::Small)
.with_icon(Icon::Trash)
.on_click(|ctx| {
ctx.dispatch_typed_action(ConversationListViewAction::EnterBulkDeleteMode);
})
});
let delete_selected_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Delete selected", DangerSecondaryTheme)
.with_size(ButtonSize::Small)
.on_click(|ctx| {
ctx.dispatch_typed_action(
ConversationListViewAction::DeleteSelectedConversations,
);
})
});
let cancel_bulk_delete_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Cancel", SecondaryTheme)
.with_size(ButtonSize::Small)
.on_click(|ctx| {
ctx.dispatch_typed_action(ConversationListViewAction::ExitBulkDeleteMode);
})
});
let item_overflow_menu = ctx.add_typed_action_view(|_| {
Menu::new()
.prevent_interaction_with_other_elements()
@@ -309,6 +359,9 @@ impl ConversationListView {
view_model,
query_editor,
toggle_view_all_button,
cleanup_button,
delete_selected_button,
cancel_bulk_delete_button,
item_overflow_menu,
overflow_menu_state: None,
sharing_dialog,
@@ -320,6 +373,8 @@ impl ConversationListView {
list_items: Arc::new(Vec::new()),
view_all: false,
total_past_items: 0,
is_bulk_delete_mode: false,
bulk_delete_selection: HashSet::new(),
state_handles: StateHandles::default(),
};
view.sync_list_items(ctx);
@@ -675,13 +730,25 @@ impl ConversationListView {
.retain(|id, _| current_ids.contains(id));
// Add new entries
for id in current_ids {
self.state_handles.item_states.entry(id).or_default();
for id in &current_ids {
self.state_handles.item_states.entry(*id).or_default();
}
// Rebuild list_items with current collapse state
self.rebuild_list_items(ctx);
self.bulk_delete_selection.retain(|id| {
current_ids.contains(id)
&& self
.view_model
.as_ref(ctx)
.get_item_by_id(id, ctx)
.is_some_and(|entry| entry.capabilities.can_delete)
});
if self.is_bulk_delete_mode && self.bulk_delete_selection.is_empty() {
self.selected_index = None;
}
// Adjust selection if it's now invalid.
if let Some(index) = self.selected_index {
if index >= self.item_count() {
@@ -694,6 +761,146 @@ impl ConversationListView {
ctx.notify();
}
fn deletable_visible_conversation_ids(
&self,
ctx: &AppContext,
) -> Vec<AgentConversationEntryId> {
let model = self.view_model.as_ref(ctx);
self.list_items
.iter()
.filter_map(|item| match item {
ListItem::Conversation { entry, .. } => model
.get_item_by_id(&entry.id, ctx)
.filter(|entry| entry.capabilities.can_delete)
.map(|_| entry.id),
ListItem::SectionHeader(_)
| ListItem::StartNewConversation
| ListItem::ToggleViewAllButton => None,
})
.collect()
}
fn selected_delete_targets(&self, ctx: &AppContext) -> Vec<DeleteConversationTarget> {
let model = self.view_model.as_ref(ctx);
let active_views_model = ActiveAgentViewsModel::as_ref(ctx);
self.bulk_delete_selection
.iter()
.filter_map(|id| {
let entry = model.get_item_by_id(id, ctx)?;
if !entry.capabilities.can_delete {
return None;
}
let conversation_id = entry.identity.local_conversation_id?;
Some(DeleteConversationTarget {
conversation_id,
conversation_title: entry.display.title,
terminal_view_id: active_views_model
.get_terminal_view_id_for_conversation(conversation_id, ctx),
})
})
.collect()
}
fn toggle_bulk_delete_selection(
&mut self,
id: AgentConversationEntryId,
ctx: &mut ViewContext<Self>,
) {
let can_delete = self
.view_model
.as_ref(ctx)
.get_item_by_id(&id, ctx)
.is_some_and(|entry| entry.capabilities.can_delete);
if !can_delete {
return;
}
if !self.bulk_delete_selection.insert(id) {
self.bulk_delete_selection.remove(&id);
}
ctx.notify();
}
fn render_bulk_delete_toolbar(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let visible_deletable_ids = self.deletable_visible_conversation_ids(app);
let selected_count = self.bulk_delete_selection.len();
let all_selected = !visible_deletable_ids.is_empty()
&& visible_deletable_ids
.iter()
.all(|id| self.bulk_delete_selection.contains(id));
let zero_margin = Coords::uniform(0.);
let checkbox_default = UiComponentStyles {
font_size: Some(BULK_CHECKBOX_SIZE),
border_width: Some(1.),
border_color: Some(theme.sub_text_color(theme.background()).into()),
border_radius: Some(CornerRadius::with_all(Radius::Pixels(2.))),
margin: Some(zero_margin),
..Default::default()
};
let checkbox_checked = UiComponentStyles {
font_size: Some(BULK_CHECKBOX_SIZE),
background: Some(theme.accent_button_color().into()),
font_color: Some(theme.main_text_color(theme.accent_button_color()).into()),
border_width: Some(1.),
border_color: Some(theme.accent_button_color().into()),
border_radius: Some(CornerRadius::with_all(Radius::Pixels(2.))),
margin: Some(zero_margin),
..Default::default()
};
let select_all = Checkbox::new(
self.state_handles.bulk_select_all.clone(),
checkbox_default,
None,
Some(checkbox_checked),
None,
)
.check(all_selected)
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(ConversationListViewAction::ToggleSelectAllDeletable);
})
.with_cursor(Cursor::PointingHand)
.finish();
let label = Text::new_inline(
if selected_count == 0 {
"Select conversations to delete".to_string()
} else {
format!("{selected_count} selected")
},
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_color(theme.main_text_color(theme.background()).into())
.finish();
let buttons = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(6.)
.with_child(ChildView::new(&self.delete_selected_button).finish())
.with_child(ChildView::new(&self.cancel_bulk_delete_button).finish())
.finish();
Container::new(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_spacing(8.)
.with_child(select_all)
.with_child(Shrinkable::new(1., label).finish())
.with_child(buttons)
.finish(),
)
.with_horizontal_padding(12.)
.with_vertical_padding(8.)
.with_border(Border::bottom(1.).with_border_fill(theme.surface_3()))
.finish()
}
fn start_rename(&mut self, id: AgentConversationEntryId, ctx: &mut ViewContext<Self>) {
let Some(entry) = self.view_model.as_ref(ctx).get_item_by_id(&id, ctx) else {
return;
@@ -969,6 +1176,26 @@ fn render_list_action_button(button: &ViewHandle<ActionButton>) -> Box<dyn Eleme
.finish()
}
fn render_cleanup_action(
cleanup_button: &ViewHandle<ActionButton>,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
Container::new(
Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::End)
.with_child(ChildView::new(cleanup_button).finish())
.finish(),
)
.with_horizontal_padding(12.)
.with_vertical_padding(8.)
.with_border(Border::bottom(1.).with_border_fill(theme.surface_3()))
.finish()
}
impl Entity for ConversationListView {
type Event = Event;
}
@@ -1274,6 +1501,52 @@ impl TypedActionView for ConversationListView {
ConversationListViewAction::CancelRename => {
self.cancel_rename(ctx);
}
ConversationListViewAction::EnterBulkDeleteMode => {
self.is_bulk_delete_mode = true;
self.selected_index = None;
self.overflow_menu_state = None;
ctx.notify();
}
ConversationListViewAction::ExitBulkDeleteMode => {
self.is_bulk_delete_mode = false;
self.bulk_delete_selection.clear();
ctx.notify();
}
ConversationListViewAction::ToggleBulkDeleteSelection { id } => {
self.toggle_bulk_delete_selection(*id, ctx);
}
ConversationListViewAction::ToggleSelectAllDeletable => {
let visible_deletable_ids = self.deletable_visible_conversation_ids(ctx);
if visible_deletable_ids.is_empty() {
return;
}
let all_selected = visible_deletable_ids
.iter()
.all(|id| self.bulk_delete_selection.contains(id));
if all_selected {
for id in visible_deletable_ids {
self.bulk_delete_selection.remove(&id);
}
} else {
self.bulk_delete_selection.extend(visible_deletable_ids);
}
ctx.notify();
}
ConversationListViewAction::DeleteSelectedConversations => {
let targets = self.selected_delete_targets(ctx);
if targets.is_empty() {
return;
}
self.is_bulk_delete_mode = false;
self.bulk_delete_selection.clear();
self.selected_index = None;
ctx.emit(Event::ShowBulkDeleteConfirmationDialog {
conversations: targets,
});
ctx.notify();
}
}
}
}
@@ -1333,6 +1606,8 @@ impl View for ConversationListView {
let open_conversation_ids =
ActiveAgentViewsModel::as_ref(app).get_all_open_conversation_ids(app);
let share_dialog_open_for = self.share_dialog_open_for;
let is_bulk_delete_mode = self.is_bulk_delete_mode;
let bulk_delete_selection = self.bulk_delete_selection.clone();
let list_position_id = self.get_position_id();
let tooltip_opens_right = TabSettings::as_ref(app)
.header_toolbar_chip_selection
@@ -1413,6 +1688,8 @@ impl View for ConversationListView {
};
let is_share_dialog_open =
share_dialog_open_for == Some(entry.id);
let is_bulk_delete_selected =
bulk_delete_selection.contains(&entry.id);
Some(render_item(
ItemProps {
conversation: &conversation,
@@ -1429,6 +1706,8 @@ impl View for ConversationListView {
rename_editor: is_renaming.then_some(&rename_editor),
sharing_dialog: &sharing_dialog,
is_share_dialog_open,
is_bulk_delete_mode,
is_bulk_delete_selected,
list_position_id: &list_position_id,
tooltip_opens_right,
},
@@ -1481,6 +1760,11 @@ impl View for ConversationListView {
if has_conversations {
column = column.with_child(render_search_box(&self.query_editor, app));
if self.is_bulk_delete_mode {
column = column.with_child(self.render_bulk_delete_toolbar(app));
} else {
column = column.with_child(render_cleanup_action(&self.cleanup_button, app));
}
}
let column_element = column
+9
View File
@@ -49,6 +49,7 @@ use crate::util::openable_file_type::FileTarget;
use crate::util::openable_file_type::{
is_markdown_file, resolve_file_target_with_editor_choice, EditorLayout,
};
use crate::workspace::delete_conversation_confirmation_dialog::DeleteConversationTarget;
use crate::workspace::view::conversation_list::view::{
ConversationListView, Event as ConversationListViewEvent,
};
@@ -97,6 +98,9 @@ pub enum LeftPanelEvent {
conversation_title: String,
terminal_view_id: Option<galaxyui::EntityId>,
},
ShowBulkDeleteConfirmationDialog {
conversations: Vec<DeleteConversationTarget>,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -236,6 +240,11 @@ impl LeftPanelView {
terminal_view_id: *terminal_view_id,
});
}
ConversationListViewEvent::ShowBulkDeleteConfirmationDialog { conversations } => {
ctx.emit(LeftPanelEvent::ShowBulkDeleteConfirmationDialog {
conversations: conversations.clone(),
});
}
});
let active_view = views.first().copied().unwrap_or(ToolPanelView::WarpDrive);
+13
View File
@@ -103,6 +103,19 @@ impl Workspace {
true,
);
}
ConversationDetailsPanelEvent::ShowDeleteConfirmationDialog {
conversation_id,
conversation_title,
} => {
me.show_delete_conversation_confirmation_dialog(
crate::workspace::delete_conversation_confirmation_dialog::DeleteConversationDialogSource::single(
*conversation_id,
conversation_title.clone(),
None,
),
ctx,
);
}
});
panel