Fix profile menu hover flickering due to documentation asides (#29958)

Mikayla Maki and Nathan created

Fixes https://github.com/zed-industries/zed/issues/29909 

🍐'd with @nathansobo 

Release Notes:

- N/A

---------

Co-authored-by: Nathan <nathan@zed.dev>

Change summary

crates/agent/src/assistant_panel.rs                             |  25 
crates/agent/src/message_editor.rs                              |  30 
crates/agent/src/profile_selector.rs                            |  23 
crates/inline_completion_button/src/inline_completion_button.rs |  14 
crates/ui/src/components/context_menu.rs                        | 103 +-
crates/zed/src/zed/quick_action_bar.rs                          |   6 
6 files changed, 131 insertions(+), 70 deletions(-)

Detailed changes

crates/agent/src/assistant_panel.rs 🔗

@@ -466,6 +466,7 @@ impl AssistantPanel {
                 thread_store.downgrade(),
                 context_store.downgrade(),
                 thread.clone(),
+                agent_panel_dock_position(cx),
                 window,
                 cx,
             )
@@ -795,6 +796,7 @@ impl AssistantPanel {
                 self.thread_store.downgrade(),
                 self.context_store.downgrade(),
                 thread,
+                agent_panel_dock_position(cx),
                 window,
                 cx,
             )
@@ -1003,6 +1005,7 @@ impl AssistantPanel {
                 self.thread_store.downgrade(),
                 self.context_store.downgrade(),
                 thread,
+                agent_panel_dock_position(cx),
                 window,
                 cx,
             )
@@ -1330,6 +1333,14 @@ impl Focusable for AssistantPanel {
     }
 }
 
+fn agent_panel_dock_position(cx: &App) -> DockPosition {
+    match AssistantSettings::get_global(cx).dock {
+        AssistantDockPosition::Left => DockPosition::Left,
+        AssistantDockPosition::Bottom => DockPosition::Bottom,
+        AssistantDockPosition::Right => DockPosition::Right,
+    }
+}
+
 impl EventEmitter<PanelEvent> for AssistantPanel {}
 
 impl Panel for AssistantPanel {
@@ -1338,18 +1349,18 @@ impl Panel for AssistantPanel {
     }
 
     fn position(&self, _window: &Window, cx: &App) -> DockPosition {
-        match AssistantSettings::get_global(cx).dock {
-            AssistantDockPosition::Left => DockPosition::Left,
-            AssistantDockPosition::Bottom => DockPosition::Bottom,
-            AssistantDockPosition::Right => DockPosition::Right,
-        }
+        agent_panel_dock_position(cx)
     }
 
-    fn position_is_valid(&self, _: DockPosition) -> bool {
-        true
+    fn position_is_valid(&self, position: DockPosition) -> bool {
+        position != DockPosition::Bottom
     }
 
     fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
+        self.message_editor.update(cx, |message_editor, cx| {
+            message_editor.set_dock_position(position, cx);
+        });
+
         settings::update_settings_file::<AssistantSettings>(
             self.fs.clone(),
             cx,

crates/agent/src/message_editor.rs 🔗

@@ -35,8 +35,9 @@ use proto::Plan;
 use settings::Settings;
 use std::time::Duration;
 use theme::ThemeSettings;
-use ui::{Disclosure, KeyBinding, PopoverMenuHandle, Tooltip, prelude::*};
+use ui::{Disclosure, DocumentationSide, KeyBinding, PopoverMenuHandle, Tooltip, prelude::*};
 use util::{ResultExt as _, maybe};
+use workspace::dock::DockPosition;
 use workspace::{CollaboratorId, Workspace};
 use zed_llm_client::CompletionMode;
 
@@ -130,6 +131,14 @@ pub(crate) fn create_editor(
     editor
 }
 
+fn documentation_side(position: DockPosition) -> DocumentationSide {
+    match position {
+        DockPosition::Left => DocumentationSide::Right,
+        DockPosition::Bottom => DocumentationSide::Left,
+        DockPosition::Right => DocumentationSide::Left,
+    }
+}
+
 impl MessageEditor {
     pub fn new(
         fs: Arc<dyn Fs>,
@@ -140,6 +149,7 @@ impl MessageEditor {
         thread_store: WeakEntity<ThreadStore>,
         text_thread_store: WeakEntity<TextThreadStore>,
         thread: Entity<Thread>,
+        dock_position: DockPosition,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
@@ -213,8 +223,15 @@ impl MessageEditor {
             model_selector,
             edits_expanded: false,
             editor_is_expanded: false,
-            profile_selector: cx
-                .new(|cx| ProfileSelector::new(fs, thread_store, editor.focus_handle(cx), cx)),
+            profile_selector: cx.new(|cx| {
+                ProfileSelector::new(
+                    fs,
+                    thread_store,
+                    editor.focus_handle(cx),
+                    documentation_side(dock_position),
+                    cx,
+                )
+            }),
             last_estimated_token_count: None,
             update_token_count_task: None,
             _subscriptions: subscriptions,
@@ -1253,6 +1270,12 @@ impl MessageEditor {
             .ok();
         }));
     }
+
+    pub fn set_dock_position(&mut self, position: DockPosition, cx: &mut Context<Self>) {
+        self.profile_selector.update(cx, |profile_selector, cx| {
+            profile_selector.set_documentation_side(documentation_side(position), cx)
+        });
+    }
 }
 
 pub fn extract_message_creases(
@@ -1426,6 +1449,7 @@ impl AgentPreview for MessageEditor {
                     thread_store.downgrade(),
                     text_thread_store.downgrade(),
                     thread,
+                    DockPosition::Left,
                     window,
                     cx,
                 )

crates/agent/src/profile_selector.rs 🔗

@@ -7,7 +7,10 @@ use fs::Fs;
 use gpui::{Action, Entity, FocusHandle, Subscription, WeakEntity, prelude::*};
 use language_model::LanguageModelRegistry;
 use settings::{Settings as _, SettingsStore, update_settings_file};
-use ui::{ContextMenu, ContextMenuEntry, PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*};
+use ui::{
+    ContextMenu, ContextMenuEntry, DocumentationSide, PopoverMenu, PopoverMenuHandle, Tooltip,
+    prelude::*,
+};
 use util::ResultExt as _;
 
 use crate::{ManageProfiles, ThreadStore, ToggleProfileSelector};
@@ -19,6 +22,7 @@ pub struct ProfileSelector {
     menu_handle: PopoverMenuHandle<ContextMenu>,
     focus_handle: FocusHandle,
     _subscriptions: Vec<Subscription>,
+    documentation_side: DocumentationSide,
 }
 
 impl ProfileSelector {
@@ -26,6 +30,7 @@ impl ProfileSelector {
         fs: Arc<dyn Fs>,
         thread_store: WeakEntity<ThreadStore>,
         focus_handle: FocusHandle,
+        documentation_side: DocumentationSide,
         cx: &mut Context<Self>,
     ) -> Self {
         let settings_subscription = cx.observe_global::<SettingsStore>(move |this, cx| {
@@ -39,9 +44,15 @@ impl ProfileSelector {
             menu_handle: PopoverMenuHandle::default(),
             focus_handle,
             _subscriptions: vec![settings_subscription],
+            documentation_side,
         }
     }
 
+    pub fn set_documentation_side(&mut self, side: DocumentationSide, cx: &mut Context<Self>) {
+        self.documentation_side = side;
+        cx.notify();
+    }
+
     pub fn menu_handle(&self) -> PopoverMenuHandle<ContextMenu> {
         self.menu_handle.clone()
     }
@@ -101,7 +112,9 @@ impl ProfileSelector {
             .toggleable(IconPosition::End, profile_id == settings.default_profile);
 
         let entry = if let Some(doc_text) = documentation {
-            entry.documentation_aside(move |_| Label::new(doc_text).into_any_element())
+            entry.documentation_aside(self.documentation_side, move |_| {
+                Label::new(doc_text).into_any_element()
+            })
         } else {
             entry
         };
@@ -175,7 +188,11 @@ impl Render for ProfileSelector {
                     )
                 }
             })
-            .anchor(gpui::Corner::BottomRight)
+            .anchor(if self.documentation_side == DocumentationSide::Left {
+                gpui::Corner::BottomRight
+            } else {
+                gpui::Corner::BottomLeft
+            })
             .with_handle(self.menu_handle.clone())
             .menu(move |window, cx| {
                 Some(this.update(cx, |this, cx| this.build_context_menu(window, cx)))

crates/inline_completion_button/src/inline_completion_button.rs 🔗

@@ -27,8 +27,8 @@ use std::{
 };
 use supermaven::{AccountStatus, Supermaven};
 use ui::{
-    Clickable, ContextMenu, ContextMenuEntry, IconButton, IconButtonShape, Indicator, PopoverMenu,
-    PopoverMenuHandle, ProgressBar, Tooltip, prelude::*,
+    Clickable, ContextMenu, ContextMenuEntry, DocumentationSide, IconButton, IconButtonShape,
+    Indicator, PopoverMenu, PopoverMenuHandle, ProgressBar, Tooltip, prelude::*,
 };
 use util::maybe;
 use workspace::{
@@ -485,7 +485,7 @@ impl InlineCompletionButton {
                     menu = menu.item(
                         entry
                             .disabled(true)
-                            .documentation_aside(move |_cx| {
+                            .documentation_aside(DocumentationSide::Left, move |_cx| {
                                 Label::new(format!("Edit predictions cannot be toggled for this buffer because they are disabled for {}", language.name()))
                                     .into_any_element()
                             })
@@ -529,7 +529,7 @@ impl InlineCompletionButton {
                 .item(
                     ContextMenuEntry::new("Eager")
                         .toggleable(IconPosition::Start, eager_mode)
-                        .documentation_aside(move |_| {
+                        .documentation_aside(DocumentationSide::Left, move |_| {
                             Label::new("Display predictions inline when there are no language server completions available.").into_any_element()
                         })
                         .handler({
@@ -542,7 +542,7 @@ impl InlineCompletionButton {
                 .item(
                     ContextMenuEntry::new("Subtle")
                         .toggleable(IconPosition::Start, subtle_mode)
-                        .documentation_aside(move |_| {
+                        .documentation_aside(DocumentationSide::Left, move |_| {
                             Label::new("Display predictions inline only when holding a modifier key (alt by default).").into_any_element()
                         })
                         .handler({
@@ -573,7 +573,7 @@ impl InlineCompletionButton {
                         .toggleable(IconPosition::Start, data_collection.is_enabled())
                         .icon(icon_name)
                         .icon_color(icon_color)
-                        .documentation_aside(move |cx| {
+                        .documentation_aside(DocumentationSide::Left, move |cx| {
                             let (msg, label_color, icon_name, icon_color) = match (is_open_source, is_collecting) {
                                 (true, true) => (
                                     "Project identified as open source, and you're sharing data.",
@@ -654,7 +654,7 @@ impl InlineCompletionButton {
             ContextMenuEntry::new("Configure Excluded Files")
                 .icon(IconName::LockOutlined)
                 .icon_color(Color::Muted)
-                .documentation_aside(|_| {
+                .documentation_aside(DocumentationSide::Left, |_| {
                     Label::new(indoc!{"
                         Open your settings to add sensitive paths for which Zed will never predict edits."}).into_any_element()
                 })

crates/ui/src/components/context_menu.rs 🔗

@@ -50,7 +50,7 @@ pub struct ContextMenuEntry {
     handler: Rc<dyn Fn(Option<&FocusHandle>, &mut Window, &mut App)>,
     action: Option<Box<dyn Action>>,
     disabled: bool,
-    documentation_aside: Option<Rc<dyn Fn(&mut App) -> AnyElement>>,
+    documentation_aside: Option<DocumentationAside>,
     end_slot_icon: Option<IconName>,
     end_slot_title: Option<SharedString>,
     end_slot_handler: Option<Rc<dyn Fn(Option<&FocusHandle>, &mut Window, &mut App)>>,
@@ -124,9 +124,14 @@ impl ContextMenuEntry {
 
     pub fn documentation_aside(
         mut self,
-        element: impl Fn(&mut App) -> AnyElement + 'static,
+        side: DocumentationSide,
+        render: impl Fn(&mut App) -> AnyElement + 'static,
     ) -> Self {
-        self.documentation_aside = Some(Rc::new(element));
+        self.documentation_aside = Some(DocumentationAside {
+            side,
+            render: Rc::new(render),
+        });
+
         self
     }
 }
@@ -150,10 +155,22 @@ pub struct ContextMenu {
     _on_blur_subscription: Subscription,
     keep_open_on_confirm: bool,
     eager: bool,
-    documentation_aside: Option<(usize, Rc<dyn Fn(&mut App) -> AnyElement>)>,
+    documentation_aside: Option<(usize, DocumentationAside)>,
     fixed_width: Option<DefiniteLength>,
 }
 
+#[derive(Copy, Clone, PartialEq, Eq)]
+pub enum DocumentationSide {
+    Left,
+    Right,
+}
+
+#[derive(Clone)]
+pub struct DocumentationAside {
+    side: DocumentationSide,
+    render: Rc<dyn Fn(&mut App) -> AnyElement>,
+}
+
 impl Focusable for ContextMenu {
     fn focus_handle(&self, _cx: &App) -> FocusHandle {
         self.focus_handle.clone()
@@ -933,27 +950,19 @@ impl ContextMenu {
                 .into_any_element()
         };
 
-        let documentation_aside_callback = documentation_aside.clone();
-
         div()
             .id(("context-menu-child", ix))
-            .when_some(
-                documentation_aside_callback.clone(),
-                |this, documentation_aside_callback| {
-                    this.occlude()
-                        .on_hover(cx.listener(move |menu, hovered, _, cx| {
-                            if *hovered {
-                                menu.documentation_aside =
-                                    Some((ix, documentation_aside_callback.clone()));
-                                cx.notify();
-                            } else if matches!(menu.documentation_aside, Some((id, _)) if id == ix)
-                            {
-                                menu.documentation_aside = None;
-                                cx.notify();
-                            }
-                        }))
-                },
-            )
+            .when_some(documentation_aside.clone(), |this, documentation_aside| {
+                this.occlude()
+                    .on_hover(cx.listener(move |menu, hovered, _, cx| {
+                        if *hovered {
+                            menu.documentation_aside = Some((ix, documentation_aside.clone()));
+                        } else if matches!(menu.documentation_aside, Some((id, _)) if id == ix) {
+                            menu.documentation_aside = None;
+                        }
+                        cx.notify();
+                    }))
+            })
             .child(
                 ListItem::new(ix)
                     .group_name("label_container")
@@ -992,21 +1001,18 @@ impl ContextMenu {
                                     })
                                     .map(|binding| {
                                         div().ml_4().child(binding.disabled(*disabled)).when(
-                                            *disabled && documentation_aside_callback.is_some(),
+                                            *disabled && documentation_aside.is_some(),
                                             |parent| parent.invisible(),
                                         )
                                     })
                             }))
-                            .when(
-                                *disabled && documentation_aside_callback.is_some(),
-                                |parent| {
-                                    parent.child(
-                                        Icon::new(IconName::Info)
-                                            .size(IconSize::XSmall)
-                                            .color(Color::Muted),
-                                    )
-                                },
-                            ),
+                            .when(*disabled && documentation_aside.is_some(), |parent| {
+                                parent.child(
+                                    Icon::new(IconName::Info)
+                                        .size(IconSize::XSmall)
+                                        .color(Color::Muted),
+                                )
+                            }),
                     )
                     .when_some(
                         end_slot_icon
@@ -1108,10 +1114,17 @@ impl Render for ContextMenu {
         let rem_size = window.rem_size();
         let is_wide_window = window_size.width / rem_size > rems_from_px(800.).0;
 
-        let aside = self
-            .documentation_aside
-            .as_ref()
-            .map(|(_, callback)| callback.clone());
+        let aside = self.documentation_aside.clone();
+        let render_aside = |aside: DocumentationAside, cx: &mut Context<Self>| {
+            WithRemSize::new(ui_font_size)
+                .occlude()
+                .elevation_2(cx)
+                .p_2()
+                .overflow_hidden()
+                .when(is_wide_window, |this| this.max_w_96())
+                .when(!is_wide_window, |this| this.max_w_48())
+                .child((aside.render)(cx))
+        };
 
         h_flex()
             .when(is_wide_window, |this| this.flex_row())
@@ -1119,15 +1132,8 @@ impl Render for ContextMenu {
             .w_full()
             .items_start()
             .gap_1()
-            .child(div().children(aside.map(|aside| {
-                WithRemSize::new(ui_font_size)
-                    .occlude()
-                    .elevation_2(cx)
-                    .p_2()
-                    .overflow_hidden()
-                    .when(is_wide_window, |this| this.max_w_96())
-                    .when(!is_wide_window, |this| this.max_w_48())
-                    .child(aside(cx))
+            .child(div().children(aside.clone().and_then(|(_, aside)| {
+                (aside.side == DocumentationSide::Left).then(|| render_aside(aside, cx))
             })))
             .child(
                 WithRemSize::new(ui_font_size)
@@ -1185,5 +1191,8 @@ impl Render for ContextMenu {
                             ),
                     ),
             )
+            .child(div().children(aside.and_then(|(_, aside)| {
+                (aside.side == DocumentationSide::Right).then(|| render_aside(aside, cx))
+            })))
     }
 }

crates/zed/src/zed/quick_action_bar.rs 🔗

@@ -15,8 +15,8 @@ use gpui::{
 use search::{BufferSearchBar, buffer_search};
 use settings::{Settings, SettingsStore};
 use ui::{
-    ButtonStyle, ContextMenu, ContextMenuEntry, IconButton, IconName, IconSize, PopoverMenu,
-    PopoverMenuHandle, Tooltip, prelude::*,
+    ButtonStyle, ContextMenu, ContextMenuEntry, DocumentationSide, IconButton, IconName, IconSize,
+    PopoverMenu, PopoverMenuHandle, Tooltip, prelude::*,
 };
 use vim_mode_setting::VimModeSetting;
 use workspace::{
@@ -291,7 +291,7 @@ impl Render for QuickActionBar {
                                         }
                                     });
                                 if !edit_predictions_enabled_at_cursor {
-                                    inline_completion_entry = inline_completion_entry.documentation_aside(|_| {
+                                    inline_completion_entry = inline_completion_entry.documentation_aside(DocumentationSide::Left, |_| {
                                         Label::new("You can't toggle edit predictions for this file as it is within the excluded files list.").into_any_element()
                                     });
                                 }