live fixes
This commit is contained in:
@@ -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
@@ -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 => {
|
||||
|
||||
@@ -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 ¤t_ids {
|
||||
self.state_handles.item_states.entry(*id).or_default();
|
||||
}
|
||||
|
||||
// Rebuild list_items with current collapse state
|
||||
self.rebuild_list_items(ctx);
|
||||
|
||||
self.bulk_delete_selection.retain(|id| {
|
||||
current_ids.contains(id)
|
||||
&& self
|
||||
.view_model
|
||||
.as_ref(ctx)
|
||||
.get_item_by_id(id, ctx)
|
||||
.is_some_and(|entry| entry.capabilities.can_delete)
|
||||
});
|
||||
if self.is_bulk_delete_mode && self.bulk_delete_selection.is_empty() {
|
||||
self.selected_index = None;
|
||||
}
|
||||
|
||||
// Adjust selection if it's now invalid.
|
||||
if let Some(index) = self.selected_index {
|
||||
if index >= self.item_count() {
|
||||
@@ -694,6 +761,146 @@ impl ConversationListView {
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn deletable_visible_conversation_ids(
|
||||
&self,
|
||||
ctx: &AppContext,
|
||||
) -> Vec<AgentConversationEntryId> {
|
||||
let model = self.view_model.as_ref(ctx);
|
||||
self.list_items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
ListItem::Conversation { entry, .. } => model
|
||||
.get_item_by_id(&entry.id, ctx)
|
||||
.filter(|entry| entry.capabilities.can_delete)
|
||||
.map(|_| entry.id),
|
||||
ListItem::SectionHeader(_)
|
||||
| ListItem::StartNewConversation
|
||||
| ListItem::ToggleViewAllButton => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn selected_delete_targets(&self, ctx: &AppContext) -> Vec<DeleteConversationTarget> {
|
||||
let model = self.view_model.as_ref(ctx);
|
||||
let active_views_model = ActiveAgentViewsModel::as_ref(ctx);
|
||||
self.bulk_delete_selection
|
||||
.iter()
|
||||
.filter_map(|id| {
|
||||
let entry = model.get_item_by_id(id, ctx)?;
|
||||
if !entry.capabilities.can_delete {
|
||||
return None;
|
||||
}
|
||||
let conversation_id = entry.identity.local_conversation_id?;
|
||||
Some(DeleteConversationTarget {
|
||||
conversation_id,
|
||||
conversation_title: entry.display.title,
|
||||
terminal_view_id: active_views_model
|
||||
.get_terminal_view_id_for_conversation(conversation_id, ctx),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn toggle_bulk_delete_selection(
|
||||
&mut self,
|
||||
id: AgentConversationEntryId,
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) {
|
||||
let can_delete = self
|
||||
.view_model
|
||||
.as_ref(ctx)
|
||||
.get_item_by_id(&id, ctx)
|
||||
.is_some_and(|entry| entry.capabilities.can_delete);
|
||||
if !can_delete {
|
||||
return;
|
||||
}
|
||||
|
||||
if !self.bulk_delete_selection.insert(id) {
|
||||
self.bulk_delete_selection.remove(&id);
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
|
||||
fn render_bulk_delete_toolbar(&self, app: &AppContext) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
let visible_deletable_ids = self.deletable_visible_conversation_ids(app);
|
||||
let selected_count = self.bulk_delete_selection.len();
|
||||
let all_selected = !visible_deletable_ids.is_empty()
|
||||
&& visible_deletable_ids
|
||||
.iter()
|
||||
.all(|id| self.bulk_delete_selection.contains(id));
|
||||
|
||||
let zero_margin = Coords::uniform(0.);
|
||||
let checkbox_default = UiComponentStyles {
|
||||
font_size: Some(BULK_CHECKBOX_SIZE),
|
||||
border_width: Some(1.),
|
||||
border_color: Some(theme.sub_text_color(theme.background()).into()),
|
||||
border_radius: Some(CornerRadius::with_all(Radius::Pixels(2.))),
|
||||
margin: Some(zero_margin),
|
||||
..Default::default()
|
||||
};
|
||||
let checkbox_checked = UiComponentStyles {
|
||||
font_size: Some(BULK_CHECKBOX_SIZE),
|
||||
background: Some(theme.accent_button_color().into()),
|
||||
font_color: Some(theme.main_text_color(theme.accent_button_color()).into()),
|
||||
border_width: Some(1.),
|
||||
border_color: Some(theme.accent_button_color().into()),
|
||||
border_radius: Some(CornerRadius::with_all(Radius::Pixels(2.))),
|
||||
margin: Some(zero_margin),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let select_all = Checkbox::new(
|
||||
self.state_handles.bulk_select_all.clone(),
|
||||
checkbox_default,
|
||||
None,
|
||||
Some(checkbox_checked),
|
||||
None,
|
||||
)
|
||||
.check(all_selected)
|
||||
.build()
|
||||
.on_click(|ctx, _, _| {
|
||||
ctx.dispatch_typed_action(ConversationListViewAction::ToggleSelectAllDeletable);
|
||||
})
|
||||
.with_cursor(Cursor::PointingHand)
|
||||
.finish();
|
||||
|
||||
let label = Text::new_inline(
|
||||
if selected_count == 0 {
|
||||
"Select conversations to delete".to_string()
|
||||
} else {
|
||||
format!("{selected_count} selected")
|
||||
},
|
||||
appearance.ui_font_family(),
|
||||
appearance.ui_font_size(),
|
||||
)
|
||||
.with_color(theme.main_text_color(theme.background()).into())
|
||||
.finish();
|
||||
|
||||
let buttons = Flex::row()
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_spacing(6.)
|
||||
.with_child(ChildView::new(&self.delete_selected_button).finish())
|
||||
.with_child(ChildView::new(&self.cancel_bulk_delete_button).finish())
|
||||
.finish();
|
||||
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
||||
.with_spacing(8.)
|
||||
.with_child(select_all)
|
||||
.with_child(Shrinkable::new(1., label).finish())
|
||||
.with_child(buttons)
|
||||
.finish(),
|
||||
)
|
||||
.with_horizontal_padding(12.)
|
||||
.with_vertical_padding(8.)
|
||||
.with_border(Border::bottom(1.).with_border_fill(theme.surface_3()))
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn start_rename(&mut self, id: AgentConversationEntryId, ctx: &mut ViewContext<Self>) {
|
||||
let Some(entry) = self.view_model.as_ref(ctx).get_item_by_id(&id, ctx) else {
|
||||
return;
|
||||
@@ -969,6 +1176,26 @@ fn render_list_action_button(button: &ViewHandle<ActionButton>) -> Box<dyn Eleme
|
||||
.finish()
|
||||
}
|
||||
|
||||
fn render_cleanup_action(
|
||||
cleanup_button: &ViewHandle<ActionButton>,
|
||||
app: &AppContext,
|
||||
) -> Box<dyn Element> {
|
||||
let appearance = Appearance::as_ref(app);
|
||||
let theme = appearance.theme();
|
||||
|
||||
Container::new(
|
||||
Flex::row()
|
||||
.with_main_axis_size(MainAxisSize::Max)
|
||||
.with_main_axis_alignment(MainAxisAlignment::End)
|
||||
.with_child(ChildView::new(cleanup_button).finish())
|
||||
.finish(),
|
||||
)
|
||||
.with_horizontal_padding(12.)
|
||||
.with_vertical_padding(8.)
|
||||
.with_border(Border::bottom(1.).with_border_fill(theme.surface_3()))
|
||||
.finish()
|
||||
}
|
||||
|
||||
impl Entity for ConversationListView {
|
||||
type Event = Event;
|
||||
}
|
||||
@@ -1274,6 +1501,52 @@ impl TypedActionView for ConversationListView {
|
||||
ConversationListViewAction::CancelRename => {
|
||||
self.cancel_rename(ctx);
|
||||
}
|
||||
ConversationListViewAction::EnterBulkDeleteMode => {
|
||||
self.is_bulk_delete_mode = true;
|
||||
self.selected_index = None;
|
||||
self.overflow_menu_state = None;
|
||||
ctx.notify();
|
||||
}
|
||||
ConversationListViewAction::ExitBulkDeleteMode => {
|
||||
self.is_bulk_delete_mode = false;
|
||||
self.bulk_delete_selection.clear();
|
||||
ctx.notify();
|
||||
}
|
||||
ConversationListViewAction::ToggleBulkDeleteSelection { id } => {
|
||||
self.toggle_bulk_delete_selection(*id, ctx);
|
||||
}
|
||||
ConversationListViewAction::ToggleSelectAllDeletable => {
|
||||
let visible_deletable_ids = self.deletable_visible_conversation_ids(ctx);
|
||||
if visible_deletable_ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let all_selected = visible_deletable_ids
|
||||
.iter()
|
||||
.all(|id| self.bulk_delete_selection.contains(id));
|
||||
if all_selected {
|
||||
for id in visible_deletable_ids {
|
||||
self.bulk_delete_selection.remove(&id);
|
||||
}
|
||||
} else {
|
||||
self.bulk_delete_selection.extend(visible_deletable_ids);
|
||||
}
|
||||
ctx.notify();
|
||||
}
|
||||
ConversationListViewAction::DeleteSelectedConversations => {
|
||||
let targets = self.selected_delete_targets(ctx);
|
||||
if targets.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
self.is_bulk_delete_mode = false;
|
||||
self.bulk_delete_selection.clear();
|
||||
self.selected_index = None;
|
||||
ctx.emit(Event::ShowBulkDeleteConfirmationDialog {
|
||||
conversations: targets,
|
||||
});
|
||||
ctx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1333,6 +1606,8 @@ impl View for ConversationListView {
|
||||
let open_conversation_ids =
|
||||
ActiveAgentViewsModel::as_ref(app).get_all_open_conversation_ids(app);
|
||||
let share_dialog_open_for = self.share_dialog_open_for;
|
||||
let is_bulk_delete_mode = self.is_bulk_delete_mode;
|
||||
let bulk_delete_selection = self.bulk_delete_selection.clone();
|
||||
let list_position_id = self.get_position_id();
|
||||
let tooltip_opens_right = TabSettings::as_ref(app)
|
||||
.header_toolbar_chip_selection
|
||||
@@ -1413,6 +1688,8 @@ impl View for ConversationListView {
|
||||
};
|
||||
let is_share_dialog_open =
|
||||
share_dialog_open_for == Some(entry.id);
|
||||
let is_bulk_delete_selected =
|
||||
bulk_delete_selection.contains(&entry.id);
|
||||
Some(render_item(
|
||||
ItemProps {
|
||||
conversation: &conversation,
|
||||
@@ -1429,6 +1706,8 @@ impl View for ConversationListView {
|
||||
rename_editor: is_renaming.then_some(&rename_editor),
|
||||
sharing_dialog: &sharing_dialog,
|
||||
is_share_dialog_open,
|
||||
is_bulk_delete_mode,
|
||||
is_bulk_delete_selected,
|
||||
list_position_id: &list_position_id,
|
||||
tooltip_opens_right,
|
||||
},
|
||||
@@ -1481,6 +1760,11 @@ impl View for ConversationListView {
|
||||
|
||||
if has_conversations {
|
||||
column = column.with_child(render_search_box(&self.query_editor, app));
|
||||
if self.is_bulk_delete_mode {
|
||||
column = column.with_child(self.render_bulk_delete_toolbar(app));
|
||||
} else {
|
||||
column = column.with_child(render_cleanup_action(&self.cleanup_button, app));
|
||||
}
|
||||
}
|
||||
|
||||
let column_element = column
|
||||
|
||||
@@ -49,6 +49,7 @@ use crate::util::openable_file_type::FileTarget;
|
||||
use crate::util::openable_file_type::{
|
||||
is_markdown_file, resolve_file_target_with_editor_choice, EditorLayout,
|
||||
};
|
||||
use crate::workspace::delete_conversation_confirmation_dialog::DeleteConversationTarget;
|
||||
use crate::workspace::view::conversation_list::view::{
|
||||
ConversationListView, Event as ConversationListViewEvent,
|
||||
};
|
||||
@@ -97,6 +98,9 @@ pub enum LeftPanelEvent {
|
||||
conversation_title: String,
|
||||
terminal_view_id: Option<galaxyui::EntityId>,
|
||||
},
|
||||
ShowBulkDeleteConfirmationDialog {
|
||||
conversations: Vec<DeleteConversationTarget>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -236,6 +240,11 @@ impl LeftPanelView {
|
||||
terminal_view_id: *terminal_view_id,
|
||||
});
|
||||
}
|
||||
ConversationListViewEvent::ShowBulkDeleteConfirmationDialog { conversations } => {
|
||||
ctx.emit(LeftPanelEvent::ShowBulkDeleteConfirmationDialog {
|
||||
conversations: conversations.clone(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let active_view = views.first().copied().unwrap_or(ToolPanelView::WarpDrive);
|
||||
|
||||
@@ -103,6 +103,19 @@ impl Workspace {
|
||||
true,
|
||||
);
|
||||
}
|
||||
ConversationDetailsPanelEvent::ShowDeleteConfirmationDialog {
|
||||
conversation_id,
|
||||
conversation_title,
|
||||
} => {
|
||||
me.show_delete_conversation_confirmation_dialog(
|
||||
crate::workspace::delete_conversation_confirmation_dialog::DeleteConversationDialogSource::single(
|
||||
*conversation_id,
|
||||
conversation_title.clone(),
|
||||
None,
|
||||
),
|
||||
ctx,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
panel
|
||||
|
||||
Reference in New Issue
Block a user