agent: Add new panel navigation dropdown (#29539)

Danilo Leal , Bennet Bo Fenner , Cole Miller , Bennet Bo Fenner , and Cole Miller created

- [x] Ensure what appears in the dropdown is really what is accurate
- [x] Ensure keyboard navigation works:
  - [x] Switching tabs with `enter`
  - [x] Closing items from the menu item
  - [x] Opening the dropdown
  - [x] Focus assistant panel on dismiss
- [x] Add ability to close items from the dropdown menu
- [x] Persistence
- [x] Correct behavior when opening a text thread

Release Notes:

- agent: Added a navigation menu that shows the recently opened threads.
The button to see the full history view has been changed inside this
menu.

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>
Co-authored-by: Cole Miller <m@cole-miller.net>
Co-authored-by: Bennet Bo Fenner <bennet@zed.dev>
Co-authored-by: Cole Miller <cole@zed.dev>

Change summary

assets/icons/menu_alt.svg                                    |   5 
assets/keymaps/default-linux.json                            |   6 
assets/keymaps/default-macos.json                            |   7 
crates/agent/src/active_thread.rs                            |   4 
crates/agent/src/assistant.rs                                |   2 
crates/agent/src/assistant_panel.rs                          | 520 +++--
crates/agent/src/context_picker.rs                           |   2 
crates/agent/src/history_store.rs                            | 153 +
crates/agent/src/thread_history.rs                           |   9 
crates/assistant/src/assistant_panel.rs                      |   5 
crates/assistant_context_editor/src/context.rs               |  18 
crates/assistant_context_editor/src/context/context_tests.rs |   4 
crates/assistant_context_editor/src/context_editor.rs        |  11 
crates/assistant_context_editor/src/context_store.rs         |  19 
crates/icons/src/icons.rs                                    |   1 
crates/menu/src/menu.rs                                      |   3 
crates/ui/src/components/context_menu.rs                     | 192 ++
17 files changed, 736 insertions(+), 225 deletions(-)

Detailed changes

assets/icons/menu_alt.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4 12H16" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4 6H20" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M4 18H12" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

assets/keymaps/default-linux.json 🔗

@@ -250,6 +250,12 @@
       "ctrl-alt-e": "agent::RemoveAllContext"
     }
   },
+  {
+    "context": "AgentPanel > NavigationMenu",
+    "bindings": {
+      "ctrl-backspace": "agent::DeleteRecentlyOpenThread"
+    }
+  },
   {
     "context": "AgentPanel > Markdown",
     "bindings": {

assets/keymaps/default-macos.json 🔗

@@ -290,11 +290,18 @@
       "cmd-i": "agent::ToggleProfileSelector",
       "cmd-alt-/": "assistant::ToggleModelSelector",
       "cmd-shift-a": "agent::ToggleContextPicker",
+      "cmd-ctrl-a": "agent::ToggleNavigationMenu",
       "shift-escape": "agent::ExpandMessageEditor",
       "cmd-e": "agent::ChatMode",
       "cmd-alt-e": "agent::RemoveAllContext"
     }
   },
+  {
+    "context": "AgentPanel > NavigationMenu",
+    "bindings": {
+      "ctrl-backspace": "agent::DeleteRecentlyOpenThread"
+    }
+  },
   {
     "context": "AgentPanel > Markdown",
     "use_key_equivalents": true,

crates/agent/src/active_thread.rs 🔗

@@ -709,7 +709,7 @@ fn open_markdown_link(
             if let Some(panel) = workspace.panel::<AssistantPanel>(cx) {
                 panel.update(cx, |panel, cx| {
                     panel
-                        .open_thread(&thread_id, window, cx)
+                        .open_thread_by_id(&thread_id, window, cx)
                         .detach_and_log_err(cx)
                 });
             }
@@ -3275,7 +3275,7 @@ pub(crate) fn open_context(
                 panel.update(cx, |panel, cx| {
                     let thread_id = thread_context.thread.read(cx).id().clone();
                     panel
-                        .open_thread(&thread_id, window, cx)
+                        .open_thread_by_id(&thread_id, window, cx)
                         .detach_and_log_err(cx)
                 });
             }

crates/agent/src/assistant.rs 🔗

@@ -50,6 +50,8 @@ actions!(
     [
         NewTextThread,
         ToggleContextPicker,
+        ToggleNavigationMenu,
+        DeleteRecentlyOpenThread,
         ToggleProfileSelector,
         RemoveAllContext,
         ExpandMessageEditor,

crates/agent/src/assistant_panel.rs 🔗

@@ -1,5 +1,5 @@
 use std::ops::Range;
-use std::path::PathBuf;
+use std::path::Path;
 use std::sync::Arc;
 use std::time::Duration;
 
@@ -18,8 +18,8 @@ use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer};
 use fs::Fs;
 use gpui::{
     Action, Animation, AnimationExt as _, AnyElement, App, AsyncWindowContext, ClipboardItem,
-    Corner, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, KeyContext, Pixels,
-    Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between,
+    Corner, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, KeyContext,
+    Pixels, Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between,
 };
 use language::LanguageRegistry;
 use language_model::{LanguageModelProviderTosView, LanguageModelRegistry};
@@ -41,15 +41,16 @@ use zed_actions::assistant::{OpenRulesLibrary, ToggleFocus};
 
 use crate::active_thread::{ActiveThread, ActiveThreadEvent};
 use crate::assistant_configuration::{AssistantConfiguration, AssistantConfigurationEvent};
-use crate::history_store::{HistoryEntry, HistoryStore};
+use crate::history_store::{HistoryEntry, HistoryStore, RecentEntry};
 use crate::message_editor::{MessageEditor, MessageEditorEvent};
 use crate::thread::{Thread, ThreadError, ThreadId, TokenUsageRatio};
 use crate::thread_history::{PastContext, PastThread, ThreadHistory};
 use crate::thread_store::ThreadStore;
 use crate::ui::UsageBanner;
 use crate::{
-    AddContextServer, AgentDiff, ExpandMessageEditor, InlineAssistant, NewTextThread, NewThread,
-    OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ThreadEvent, ToggleContextPicker,
+    AddContextServer, AgentDiff, DeleteRecentlyOpenThread, ExpandMessageEditor, InlineAssistant,
+    NewTextThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ThreadEvent,
+    ToggleContextPicker, ToggleNavigationMenu,
 };
 
 pub fn init(cx: &mut App) {
@@ -104,6 +105,14 @@ pub fn init(cx: &mut App) {
                             });
                         });
                     }
+                })
+                .register_action(|workspace, _: &ToggleNavigationMenu, window, cx| {
+                    if let Some(panel) = workspace.panel::<AssistantPanel>(cx) {
+                        workspace.focus_panel::<AssistantPanel>(window, cx);
+                        panel.update(cx, |panel, cx| {
+                            panel.toggle_navigation_menu(&ToggleNavigationMenu, window, cx);
+                        });
+                    }
                 });
         },
     )
@@ -113,6 +122,7 @@ pub fn init(cx: &mut App) {
 enum ActiveView {
     Thread {
         change_title_editor: Entity<Editor>,
+        thread: WeakEntity<Thread>,
         _subscriptions: Vec<gpui::Subscription>,
     },
     PromptEditor {
@@ -130,7 +140,7 @@ impl ActiveView {
 
         let editor = cx.new(|cx| {
             let mut editor = Editor::single_line(window, cx);
-            editor.set_text(summary, window, cx);
+            editor.set_text(summary.clone(), window, cx);
             editor
         });
 
@@ -176,6 +186,7 @@ impl ActiveView {
 
         Self::Thread {
             change_title_editor: editor,
+            thread: thread.downgrade(),
             _subscriptions: subscriptions,
         }
     }
@@ -279,6 +290,8 @@ pub struct AssistantPanel {
     history_store: Entity<HistoryStore>,
     history: Entity<ThreadHistory>,
     assistant_dropdown_menu_handle: PopoverMenuHandle<ContextMenu>,
+    assistant_navigation_menu_handle: PopoverMenuHandle<ContextMenu>,
+    assistant_navigation_menu: Option<Entity<ContextMenu>>,
     width: Option<Pixels>,
     height: Option<Pixels>,
 }
@@ -380,8 +393,14 @@ impl AssistantPanel {
                 }
             });
 
-        let history_store =
-            cx.new(|cx| HistoryStore::new(thread_store.clone(), context_store.clone(), cx));
+        let history_store = cx.new(|cx| {
+            HistoryStore::new(
+                thread_store.clone(),
+                context_store.clone(),
+                [RecentEntry::Thread(thread.clone())],
+                cx,
+            )
+        });
 
         cx.observe(&history_store, |_, _, cx| cx.notify()).detach();
 
@@ -392,7 +411,7 @@ impl AssistantPanel {
                 cx.notify();
             }
         });
-        let thread = cx.new(|cx| {
+        let active_thread = cx.new(|cx| {
             ActiveThread::new(
                 thread.clone(),
                 thread_store.clone(),
@@ -403,10 +422,111 @@ impl AssistantPanel {
             )
         });
 
-        let active_thread_subscription = cx.subscribe(&thread, |_, _, event, cx| match &event {
-            ActiveThreadEvent::EditingMessageTokenCountChanged => {
-                cx.notify();
-            }
+        let active_thread_subscription =
+            cx.subscribe(&active_thread, |_, _, event, cx| match &event {
+                ActiveThreadEvent::EditingMessageTokenCountChanged => {
+                    cx.notify();
+                }
+            });
+
+        let weak_panel = weak_self.clone();
+
+        window.defer(cx, move |window, cx| {
+            let panel = weak_panel.clone();
+            let assistant_navigation_menu =
+                ContextMenu::build_persistent(window, cx, move |mut menu, _window, cx| {
+                    let recently_opened = panel
+                        .update(cx, |this, cx| {
+                            this.history_store.update(cx, |history_store, cx| {
+                                history_store.recently_opened_entries(cx)
+                            })
+                        })
+                        .unwrap_or_default();
+
+                    if !recently_opened.is_empty() {
+                        menu = menu.header("Recently Opened");
+
+                        for entry in recently_opened.iter() {
+                            let summary = entry.summary(cx);
+                            menu = menu.entry_with_end_slot(
+                                summary,
+                                None,
+                                {
+                                    let panel = panel.clone();
+                                    let entry = entry.clone();
+                                    move |window, cx| {
+                                        panel
+                                            .update(cx, {
+                                                let entry = entry.clone();
+                                                move |this, cx| match entry {
+                                                    RecentEntry::Thread(thread) => {
+                                                        this.open_thread(thread, window, cx)
+                                                    }
+                                                    RecentEntry::Context(context) => {
+                                                        let Some(path) = context.read(cx).path()
+                                                        else {
+                                                            return;
+                                                        };
+                                                        this.open_saved_prompt_editor(
+                                                            path.clone(),
+                                                            window,
+                                                            cx,
+                                                        )
+                                                        .detach_and_log_err(cx)
+                                                    }
+                                                }
+                                            })
+                                            .ok();
+                                    }
+                                },
+                                IconName::Close,
+                                "Close Entry".into(),
+                                {
+                                    let panel = panel.clone();
+                                    let entry = entry.clone();
+                                    move |_window, cx| {
+                                        panel
+                                            .update(cx, |this, cx| {
+                                                this.history_store.update(
+                                                    cx,
+                                                    |history_store, cx| {
+                                                        history_store.remove_recently_opened_entry(
+                                                            &entry, cx,
+                                                        );
+                                                    },
+                                                );
+                                            })
+                                            .ok();
+                                    }
+                                },
+                            );
+                        }
+
+                        menu = menu.separator();
+                    }
+
+                    menu.action("View All", Box::new(OpenHistory))
+                        .end_slot_action(DeleteRecentlyOpenThread.boxed_clone())
+                        .fixed_width(px(320.).into())
+                        .keep_open_on_confirm(false)
+                        .key_context("NavigationMenu")
+                });
+            weak_panel
+                .update(cx, |panel, cx| {
+                    cx.subscribe_in(
+                        &assistant_navigation_menu,
+                        window,
+                        |_, menu, _: &DismissEvent, window, cx| {
+                            menu.update(cx, |menu, _| {
+                                menu.clear_selected();
+                            });
+                            cx.focus_self(window);
+                        },
+                    )
+                    .detach();
+                    panel.assistant_navigation_menu = Some(assistant_navigation_menu);
+                })
+                .ok();
         });
 
         let _default_model_subscription = cx.subscribe(
@@ -431,7 +551,7 @@ impl AssistantPanel {
             fs: fs.clone(),
             language_registry,
             thread_store: thread_store.clone(),
-            thread,
+            thread: active_thread,
             message_editor,
             _active_thread_subscriptions: vec![
                 thread_subscription,
@@ -451,6 +571,8 @@ impl AssistantPanel {
             history_store: history_store.clone(),
             history: cx.new(|cx| ThreadHistory::new(weak_self, history_store, window, cx)),
             assistant_dropdown_menu_handle: PopoverMenuHandle::default(),
+            assistant_navigation_menu_handle: PopoverMenuHandle::default(),
+            assistant_navigation_menu: None,
             width: None,
             height: None,
         }
@@ -645,13 +767,13 @@ impl AssistantPanel {
 
     pub(crate) fn open_saved_prompt_editor(
         &mut self,
-        path: PathBuf,
+        path: Arc<Path>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Task<Result<()>> {
         let context = self
             .context_store
-            .update(cx, |store, cx| store.open_local_context(path.clone(), cx));
+            .update(cx, |store, cx| store.open_local_context(path, cx));
         let fs = self.fs.clone();
         let project = self.project.clone();
         let workspace = self.workspace.clone();
@@ -685,7 +807,7 @@ impl AssistantPanel {
         })
     }
 
-    pub(crate) fn open_thread(
+    pub(crate) fn open_thread_by_id(
         &mut self,
         thread_id: &ThreadId,
         window: &mut Window,
@@ -694,71 +816,81 @@ impl AssistantPanel {
         let open_thread_task = self
             .thread_store
             .update(cx, |this, cx| this.open_thread(thread_id, cx));
-
         cx.spawn_in(window, async move |this, cx| {
             let thread = open_thread_task.await?;
             this.update_in(cx, |this, window, cx| {
-                let thread_view = ActiveView::thread(thread.clone(), window, cx);
-                this.set_active_view(thread_view, window, cx);
-                let message_editor_context_store = cx.new(|_cx| {
-                    crate::context_store::ContextStore::new(
-                        this.project.downgrade(),
-                        Some(this.thread_store.downgrade()),
-                    )
-                });
-                let thread_subscription = cx.subscribe(&thread, |_, _, event, cx| {
-                    if let ThreadEvent::MessageAdded(_) = &event {
-                        // needed to leave empty state
-                        cx.notify();
-                    }
-                });
+                this.open_thread(thread, window, cx);
+                anyhow::Ok(())
+            })??;
+            Ok(())
+        })
+    }
 
-                this.thread = cx.new(|cx| {
-                    ActiveThread::new(
-                        thread.clone(),
-                        this.thread_store.clone(),
-                        this.language_registry.clone(),
-                        this.workspace.clone(),
-                        window,
-                        cx,
-                    )
-                });
+    pub(crate) fn open_thread(
+        &mut self,
+        thread: Entity<Thread>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let thread_view = ActiveView::thread(thread.clone(), window, cx);
+        self.set_active_view(thread_view, window, cx);
+        let message_editor_context_store = cx.new(|_cx| {
+            crate::context_store::ContextStore::new(
+                self.project.downgrade(),
+                Some(self.thread_store.downgrade()),
+            )
+        });
+        let thread_subscription = cx.subscribe(&thread, |_, _, event, cx| {
+            if let ThreadEvent::MessageAdded(_) = &event {
+                // needed to leave empty state
+                cx.notify();
+            }
+        });
 
-                let active_thread_subscription =
-                    cx.subscribe(&this.thread, |_, _, event, cx| match &event {
-                        ActiveThreadEvent::EditingMessageTokenCountChanged => {
-                            cx.notify();
-                        }
-                    });
+        self.thread = cx.new(|cx| {
+            ActiveThread::new(
+                thread.clone(),
+                self.thread_store.clone(),
+                self.language_registry.clone(),
+                self.workspace.clone(),
+                window,
+                cx,
+            )
+        });
 
-                this.message_editor = cx.new(|cx| {
-                    MessageEditor::new(
-                        this.fs.clone(),
-                        this.workspace.clone(),
-                        message_editor_context_store,
-                        this.prompt_store.clone(),
-                        this.thread_store.downgrade(),
-                        thread,
-                        window,
-                        cx,
-                    )
-                });
-                this.message_editor.focus_handle(cx).focus(window);
+        let active_thread_subscription =
+            cx.subscribe(&self.thread, |_, _, event, cx| match &event {
+                ActiveThreadEvent::EditingMessageTokenCountChanged => {
+                    cx.notify();
+                }
+            });
 
-                let message_editor_subscription =
-                    cx.subscribe(&this.message_editor, |_, _, event, cx| match event {
-                        MessageEditorEvent::Changed | MessageEditorEvent::EstimatedTokenCount => {
-                            cx.notify();
-                        }
-                    });
+        self.message_editor = cx.new(|cx| {
+            MessageEditor::new(
+                self.fs.clone(),
+                self.workspace.clone(),
+                message_editor_context_store,
+                self.prompt_store.clone(),
+                self.thread_store.downgrade(),
+                thread,
+                window,
+                cx,
+            )
+        });
+        self.message_editor.focus_handle(cx).focus(window);
 
-                this._active_thread_subscriptions = vec![
-                    thread_subscription,
-                    active_thread_subscription,
-                    message_editor_subscription,
-                ];
-            })
-        })
+        let message_editor_subscription =
+            cx.subscribe(&self.message_editor, |_, _, event, cx| match event {
+                MessageEditorEvent::Changed | MessageEditorEvent::EstimatedTokenCount => {
+                    cx.notify();
+                }
+            });
+
+        self._active_thread_subscriptions = vec![
+            thread_subscription,
+            active_thread_subscription,
+            message_editor_subscription,
+        ];
     }
 
     pub fn go_back(&mut self, _: &workspace::GoBack, window: &mut Window, cx: &mut Context<Self>) {
@@ -773,6 +905,15 @@ impl AssistantPanel {
         }
     }
 
+    pub fn toggle_navigation_menu(
+        &mut self,
+        _: &ToggleNavigationMenu,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.assistant_navigation_menu_handle.toggle(window, cx);
+    }
+
     pub fn open_agent_diff(
         &mut self,
         _: &OpenAgentDiff,
@@ -921,7 +1062,7 @@ impl AssistantPanel {
 
     pub(crate) fn delete_context(
         &mut self,
-        path: PathBuf,
+        path: Arc<Path>,
         cx: &mut Context<Self>,
     ) -> Task<Result<()>> {
         self.context_store
@@ -937,6 +1078,32 @@ impl AssistantPanel {
         let current_is_history = matches!(self.active_view, ActiveView::History);
         let new_is_history = matches!(new_view, ActiveView::History);
 
+        match &self.active_view {
+            ActiveView::Thread { thread, .. } => self.history_store.update(cx, |store, cx| {
+                if let Some(thread) = thread.upgrade() {
+                    if thread.read(cx).is_empty() {
+                        store.remove_recently_opened_entry(&RecentEntry::Thread(thread), cx);
+                    }
+                }
+            }),
+            _ => {}
+        }
+
+        match &new_view {
+            ActiveView::Thread { thread, .. } => self.history_store.update(cx, |store, cx| {
+                if let Some(thread) = thread.upgrade() {
+                    store.push_recently_opened_entry(RecentEntry::Thread(thread), cx);
+                }
+            }),
+            ActiveView::PromptEditor { context_editor, .. } => {
+                self.history_store.update(cx, |store, cx| {
+                    let context = context_editor.read(cx).context().clone();
+                    store.push_recently_opened_entry(RecentEntry::Context(context), cx)
+                })
+            }
+            _ => {}
+        }
+
         if current_is_history && !new_is_history {
             self.active_view = new_view;
         } else if !current_is_history && new_is_history {
@@ -1066,16 +1233,13 @@ impl AssistantPanel {
                 if is_empty {
                     Label::new(Thread::DEFAULT_SUMMARY.clone())
                         .truncate()
-                        .ml_2()
                         .into_any_element()
                 } else if summary.is_none() {
                     Label::new(LOADING_SUMMARY_PLACEHOLDER)
-                        .ml_2()
                         .truncate()
                         .into_any_element()
                 } else {
                     div()
-                        .ml_2()
                         .w_full()
                         .child(change_title_editor.clone())
                         .into_any_element()
@@ -1092,18 +1256,15 @@ impl AssistantPanel {
                 match summary {
                     None => Label::new(AssistantContext::DEFAULT_SUMMARY.clone())
                         .truncate()
-                        .ml_2()
                         .into_any_element(),
                     Some(summary) => {
                         if summary.done {
                             div()
-                                .ml_2()
                                 .w_full()
                                 .child(title_editor.clone())
                                 .into_any_element()
                         } else {
                             Label::new(LOADING_SUMMARY_PLACEHOLDER)
-                                .ml_2()
                                 .truncate()
                                 .into_any_element()
                         }
@@ -1130,7 +1291,6 @@ impl AssistantPanel {
         let thread = active_thread.thread().read(cx);
         let thread_id = thread.id().clone();
         let is_empty = active_thread.is_empty();
-        let is_history = matches!(self.active_view, ActiveView::History);
 
         let show_token_count = match &self.active_view {
             ActiveView::Thread { .. } => !is_empty,
@@ -1140,30 +1300,98 @@ impl AssistantPanel {
 
         let focus_handle = self.focus_handle(cx);
 
-        let go_back_button = match &self.active_view {
-            ActiveView::History | ActiveView::Configuration => Some(
-                div().pl_1().child(
-                    IconButton::new("go-back", IconName::ArrowLeft)
+        let go_back_button = div().child(
+            IconButton::new("go-back", IconName::ArrowLeft)
+                .icon_size(IconSize::Small)
+                .on_click(cx.listener(|this, _, window, cx| {
+                    this.go_back(&workspace::GoBack, window, cx);
+                }))
+                .tooltip({
+                    let focus_handle = focus_handle.clone();
+                    move |window, cx| {
+                        Tooltip::for_action_in(
+                            "Go Back",
+                            &workspace::GoBack,
+                            &focus_handle,
+                            window,
+                            cx,
+                        )
+                    }
+                }),
+        );
+
+        let recent_entries_menu = div().child(
+            PopoverMenu::new("agent-nav-menu")
+                .trigger_with_tooltip(
+                    IconButton::new("agent-nav-menu", IconName::MenuAlt)
                         .icon_size(IconSize::Small)
-                        .on_click(cx.listener(|this, _, window, cx| {
-                            this.go_back(&workspace::GoBack, window, cx);
-                        }))
-                        .tooltip({
-                            let focus_handle = focus_handle.clone();
-                            move |window, cx| {
-                                Tooltip::for_action_in(
-                                    "Go Back",
-                                    &workspace::GoBack,
-                                    &focus_handle,
-                                    window,
-                                    cx,
-                                )
-                            }
+                        .style(ui::ButtonStyle::Subtle),
+                    {
+                        let focus_handle = focus_handle.clone();
+                        move |window, cx| {
+                            Tooltip::for_action_in(
+                                "Toggle Panel Menu",
+                                &ToggleNavigationMenu,
+                                &focus_handle,
+                                window,
+                                cx,
+                            )
+                        }
+                    },
+                )
+                .anchor(Corner::TopLeft)
+                .with_handle(self.assistant_navigation_menu_handle.clone())
+                .menu({
+                    let menu = self.assistant_navigation_menu.clone();
+                    move |window, cx| {
+                        if let Some(menu) = menu.as_ref() {
+                            menu.update(cx, |_, cx| {
+                                cx.defer_in(window, |menu, window, cx| {
+                                    menu.rebuild(window, cx);
+                                });
+                            })
+                        }
+                        menu.clone()
+                    }
+                }),
+        );
+
+        let agent_extra_menu = PopoverMenu::new("assistant-menu")
+            .trigger_with_tooltip(
+                IconButton::new("new", IconName::Ellipsis)
+                    .icon_size(IconSize::Small)
+                    .style(ButtonStyle::Subtle),
+                Tooltip::text("Toggle Agent Menu"),
+            )
+            .anchor(Corner::TopRight)
+            .with_handle(self.assistant_dropdown_menu_handle.clone())
+            .menu(move |window, cx| {
+                Some(ContextMenu::build(window, cx, |menu, _window, _cx| {
+                    menu.when(!is_empty, |menu| {
+                        menu.action(
+                            "Start New From Summary",
+                            Box::new(NewThread {
+                                from_thread_id: Some(thread_id.clone()),
+                            }),
+                        )
+                        .separator()
+                    })
+                    .action("New Text Thread", NewTextThread.boxed_clone())
+                    .action("Rules Library", Box::new(OpenRulesLibrary::default()))
+                    .action("Settings", Box::new(OpenConfiguration))
+                    .separator()
+                    .header("MCPs")
+                    .action(
+                        "View Server Extensions",
+                        Box::new(zed_actions::Extensions {
+                            category_filter: Some(
+                                zed_actions::ExtensionCategoryFilter::ContextServers,
+                            ),
                         }),
-                ),
-            ),
-            _ => None,
-        };
+                    )
+                    .action("Add Custom Server", Box::new(AddContextServer))
+                }))
+            });
 
         h_flex()
             .id("assistant-toolbar")
@@ -1177,18 +1405,22 @@ impl AssistantPanel {
             .border_color(cx.theme().colors().border)
             .child(
                 h_flex()
-                    .w_full()
+                    .size_full()
+                    .pl_1()
                     .gap_1()
-                    .children(go_back_button)
+                    .child(match &self.active_view {
+                        ActiveView::History | ActiveView::Configuration => go_back_button,
+                        _ => recent_entries_menu,
+                    })
                     .child(self.render_title_view(window, cx)),
             )
             .child(
                 h_flex()
                     .h_full()
                     .gap_2()
-                    .when(show_token_count, |parent|
+                    .when(show_token_count, |parent| {
                         parent.children(self.render_token_count(&thread, cx))
-                    )
+                    })
                     .child(
                         h_flex()
                             .h_full()
@@ -1216,72 +1448,7 @@ impl AssistantPanel {
                                         );
                                     }),
                             )
-                            .child(
-                                IconButton::new("open-history", IconName::HistoryRerun)
-                                    .icon_size(IconSize::Small)
-                                    .toggle_state(is_history)
-                                    .selected_icon_color(Color::Accent)
-                                    .tooltip({
-                                        let focus_handle = self.focus_handle(cx);
-                                        move |window, cx| {
-                                            Tooltip::for_action_in(
-                                                "History",
-                                                &OpenHistory,
-                                                &focus_handle,
-                                                window,
-                                                cx,
-                                            )
-                                        }
-                                    })
-                                    .on_click(move |_event, window, cx| {
-                                        window.dispatch_action(OpenHistory.boxed_clone(), cx);
-                                    }),
-                            )
-                            .child(
-                                PopoverMenu::new("assistant-menu")
-                                    .trigger_with_tooltip(
-                                        IconButton::new("new", IconName::Ellipsis)
-                                            .icon_size(IconSize::Small)
-                                            .style(ButtonStyle::Subtle),
-                                        Tooltip::text("Toggle Agent Menu"),
-                                    )
-                                    .anchor(Corner::TopRight)
-                                    .with_handle(self.assistant_dropdown_menu_handle.clone())
-                                    .menu(move |window, cx| {
-                                        Some(ContextMenu::build(
-                                            window,
-                                            cx,
-                                            |menu, _window, _cx| {
-                                                menu
-                                                    .when(!is_empty, |menu| {
-                                                        menu.action(
-                                                            "Start New From Summary",
-                                                            Box::new(NewThread {
-                                                                from_thread_id: Some(thread_id.clone()),
-                                                            }),
-                                                        ).separator()
-                                                    })
-                                                    .action(
-                                                    "New Text Thread",
-                                                    NewTextThread.boxed_clone(),
-                                                )
-                                                .action("Rules Library", Box::new(OpenRulesLibrary::default()))
-                                                .action("Settings", Box::new(OpenConfiguration))
-                                                .separator()
-                                                .header("MCPs")
-                                                .action(
-                                                    "View Server Extensions",
-                                                    Box::new(zed_actions::Extensions {
-                                                        category_filter: Some(
-                                                            zed_actions::ExtensionCategoryFilter::ContextServers,
-                                                        ),
-                                                        }),
-                                                )
-                                                .action("Add Custom Server", Box::new(AddContextServer))
-                                            },
-                                        ))
-                                    }),
-                            ),
+                            .child(agent_extra_menu),
                     ),
             )
     }
@@ -1982,6 +2149,7 @@ impl Render for AssistantPanel {
             .on_action(cx.listener(Self::deploy_rules_library))
             .on_action(cx.listener(Self::open_agent_diff))
             .on_action(cx.listener(Self::go_back))
+            .on_action(cx.listener(Self::toggle_navigation_menu))
             .child(self.render_toolbar(window, cx))
             .map(|parent| match &self.active_view {
                 ActiveView::Thread { .. } => parent
@@ -2066,7 +2234,7 @@ impl AssistantPanelDelegate for ConcreteAssistantPanelDelegate {
     fn open_saved_context(
         &self,
         workspace: &mut Workspace,
-        path: std::path::PathBuf,
+        path: Arc<Path>,
         window: &mut Window,
         cx: &mut Context<Workspace>,
     ) -> Task<Result<()>> {

crates/agent/src/context_picker.rs 🔗

@@ -267,7 +267,7 @@ impl ContextPicker {
                         context_picker.update(cx, |this, cx| this.select_entry(entry, window, cx))
                     })
             }))
-            .keep_open_on_confirm()
+            .keep_open_on_confirm(true)
         });
 
         cx.subscribe(&menu, move |_, _, _: &DismissEvent, cx| {

crates/agent/src/history_store.rs 🔗

@@ -1,10 +1,27 @@
-use assistant_context_editor::SavedContextMetadata;
+use std::{collections::VecDeque, path::Path};
+
+use anyhow::{Context as _, anyhow};
+use assistant_context_editor::{AssistantContext, SavedContextMetadata};
 use chrono::{DateTime, Utc};
-use gpui::{Entity, prelude::*};
+use futures::future::{TryFutureExt as _, join_all};
+use gpui::{Entity, Task, prelude::*};
+use serde::{Deserialize, Serialize};
+use smol::future::FutureExt;
+use std::time::Duration;
+use ui::{App, SharedString};
+use util::ResultExt as _;
+
+use crate::{
+    Thread,
+    thread::ThreadId,
+    thread_store::{SerializedThreadMetadata, ThreadStore},
+};
 
-use crate::thread_store::{SerializedThreadMetadata, ThreadStore};
+const MAX_RECENTLY_OPENED_ENTRIES: usize = 6;
+const NAVIGATION_HISTORY_PATH: &str = "agent-navigation-history.json";
+const SAVE_RECENTLY_OPENED_ENTRIES_DEBOUNCE: Duration = Duration::from_millis(50);
 
-#[derive(Debug)]
+#[derive(Clone, Debug)]
 pub enum HistoryEntry {
     Thread(SerializedThreadMetadata),
     Context(SavedContextMetadata),
@@ -19,16 +36,40 @@ impl HistoryEntry {
     }
 }
 
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) enum RecentEntry {
+    Thread(Entity<Thread>),
+    Context(Entity<AssistantContext>),
+}
+
+impl RecentEntry {
+    pub(crate) fn summary(&self, cx: &App) -> SharedString {
+        match self {
+            RecentEntry::Thread(thread) => thread.read(cx).summary_or_default(),
+            RecentEntry::Context(context) => context.read(cx).summary_or_default(),
+        }
+    }
+}
+
+#[derive(Serialize, Deserialize)]
+enum SerializedRecentEntry {
+    Thread(String),
+    Context(String),
+}
+
 pub struct HistoryStore {
     thread_store: Entity<ThreadStore>,
     context_store: Entity<assistant_context_editor::ContextStore>,
+    recently_opened_entries: VecDeque<RecentEntry>,
     _subscriptions: Vec<gpui::Subscription>,
+    _save_recently_opened_entries_task: Task<()>,
 }
 
 impl HistoryStore {
     pub fn new(
         thread_store: Entity<ThreadStore>,
         context_store: Entity<assistant_context_editor::ContextStore>,
+        initial_recent_entries: impl IntoIterator<Item = RecentEntry>,
         cx: &mut Context<Self>,
     ) -> Self {
         let subscriptions = vec![
@@ -36,10 +77,61 @@ impl HistoryStore {
             cx.observe(&context_store, |_, _, cx| cx.notify()),
         ];
 
+        cx.spawn({
+            let thread_store = thread_store.downgrade();
+            let context_store = context_store.downgrade();
+            async move |this, cx| {
+                let path = paths::data_dir().join(NAVIGATION_HISTORY_PATH);
+                let contents = cx
+                    .background_spawn(async move { std::fs::read_to_string(path) })
+                    .await
+                    .context("reading persisted agent panel navigation history")?;
+                let entries = serde_json::from_str::<Vec<SerializedRecentEntry>>(&contents)
+                    .context("deserializing persisted agent panel navigation history")?
+                    .into_iter()
+                    .take(MAX_RECENTLY_OPENED_ENTRIES)
+                    .map(|serialized| match serialized {
+                        SerializedRecentEntry::Thread(id) => thread_store
+                            .update(cx, |thread_store, cx| {
+                                thread_store
+                                    .open_thread(&ThreadId::from(id.as_str()), cx)
+                                    .map_ok(RecentEntry::Thread)
+                                    .boxed()
+                            })
+                            .unwrap_or_else(|_| async { Err(anyhow!("no thread store")) }.boxed()),
+                        SerializedRecentEntry::Context(id) => context_store
+                            .update(cx, |context_store, cx| {
+                                context_store
+                                    .open_local_context(Path::new(&id).into(), cx)
+                                    .map_ok(RecentEntry::Context)
+                                    .boxed()
+                            })
+                            .unwrap_or_else(|_| async { Err(anyhow!("no context store")) }.boxed()),
+                    });
+                let entries = join_all(entries)
+                    .await
+                    .into_iter()
+                    .filter_map(|result| result.log_err())
+                    .collect::<VecDeque<_>>();
+
+                this.update(cx, |this, _| {
+                    this.recently_opened_entries.extend(entries);
+                    this.recently_opened_entries
+                        .truncate(MAX_RECENTLY_OPENED_ENTRIES);
+                })
+                .ok();
+
+                anyhow::Ok(())
+            }
+        })
+        .detach_and_log_err(cx);
+
         Self {
             thread_store,
             context_store,
+            recently_opened_entries: initial_recent_entries.into_iter().collect(),
             _subscriptions: subscriptions,
+            _save_recently_opened_entries_task: Task::ready(()),
         }
     }
 
@@ -69,4 +161,57 @@ impl HistoryStore {
     pub fn recent_entries(&self, limit: usize, cx: &mut Context<Self>) -> Vec<HistoryEntry> {
         self.entries(cx).into_iter().take(limit).collect()
     }
+
+    fn save_recently_opened_entries(&mut self, cx: &mut Context<Self>) {
+        let serialized_entries = self
+            .recently_opened_entries
+            .iter()
+            .filter_map(|entry| match entry {
+                RecentEntry::Context(context) => Some(SerializedRecentEntry::Context(
+                    context.read(cx).path()?.to_str()?.to_owned(),
+                )),
+                RecentEntry::Thread(thread) => Some(SerializedRecentEntry::Thread(
+                    thread.read(cx).id().to_string(),
+                )),
+            })
+            .collect::<Vec<_>>();
+
+        self._save_recently_opened_entries_task = cx.spawn(async move |_, cx| {
+            cx.background_executor()
+                .timer(SAVE_RECENTLY_OPENED_ENTRIES_DEBOUNCE)
+                .await;
+            cx.background_spawn(async move {
+                let path = paths::data_dir().join(NAVIGATION_HISTORY_PATH);
+                let content = serde_json::to_string(&serialized_entries)?;
+                std::fs::write(path, content)?;
+                anyhow::Ok(())
+            })
+            .await
+            .log_err();
+        });
+    }
+
+    pub fn push_recently_opened_entry(&mut self, entry: RecentEntry, cx: &mut Context<Self>) {
+        self.recently_opened_entries
+            .retain(|old_entry| old_entry != &entry);
+        self.recently_opened_entries.push_front(entry);
+        self.recently_opened_entries
+            .truncate(MAX_RECENTLY_OPENED_ENTRIES);
+        self.save_recently_opened_entries(cx);
+    }
+
+    pub fn remove_recently_opened_entry(&mut self, entry: &RecentEntry, cx: &mut Context<Self>) {
+        self.recently_opened_entries
+            .retain(|old_entry| old_entry != entry);
+        self.save_recently_opened_entries(cx);
+    }
+
+    pub fn recently_opened_entries(&self, _cx: &mut Context<Self>) -> VecDeque<RecentEntry> {
+        #[cfg(debug_assertions)]
+        if std::env::var("ZED_SIMULATE_NO_THREAD_HISTORY").is_ok() {
+            return VecDeque::new();
+        }
+
+        self.recently_opened_entries.clone()
+    }
 }

crates/agent/src/thread_history.rs 🔗

@@ -270,9 +270,9 @@ impl ThreadHistory {
     fn confirm(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context<Self>) {
         if let Some(entry) = self.get_match(self.selected_index) {
             let task_result = match entry {
-                HistoryEntry::Thread(thread) => self
-                    .assistant_panel
-                    .update(cx, move |this, cx| this.open_thread(&thread.id, window, cx)),
+                HistoryEntry::Thread(thread) => self.assistant_panel.update(cx, move |this, cx| {
+                    this.open_thread_by_id(&thread.id, window, cx)
+                }),
                 HistoryEntry::Context(context) => {
                     self.assistant_panel.update(cx, move |this, cx| {
                         this.open_saved_prompt_editor(context.path.clone(), window, cx)
@@ -525,7 +525,8 @@ impl RenderOnce for PastThread {
                 move |_event, window, cx| {
                     assistant_panel
                         .update(cx, |this, cx| {
-                            this.open_thread(&id, window, cx).detach_and_log_err(cx);
+                            this.open_thread_by_id(&id, window, cx)
+                                .detach_and_log_err(cx);
                         })
                         .ok();
                 }

crates/assistant/src/assistant_panel.rs 🔗

@@ -33,6 +33,7 @@ use settings::{Settings, update_settings_file};
 use smol::stream::StreamExt;
 
 use std::ops::Range;
+use std::path::Path;
 use std::{ops::ControlFlow, path::PathBuf, sync::Arc};
 use terminal_view::{TerminalView, terminal_panel::TerminalPanel};
 use ui::{ContextMenu, PopoverMenu, Tooltip, prelude::*};
@@ -1080,7 +1081,7 @@ impl AssistantPanel {
 
     pub fn open_saved_context(
         &mut self,
-        path: PathBuf,
+        path: Arc<Path>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Task<Result<()>> {
@@ -1391,7 +1392,7 @@ impl AssistantPanelDelegate for ConcreteAssistantPanelDelegate {
     fn open_saved_context(
         &self,
         workspace: &mut Workspace,
-        path: PathBuf,
+        path: Arc<Path>,
         window: &mut Window,
         cx: &mut Context<Workspace>,
     ) -> Task<Result<()>> {

crates/assistant_context_editor/src/context.rs 🔗

@@ -35,7 +35,7 @@ use std::{
     fmt::Debug,
     iter, mem,
     ops::Range,
-    path::{Path, PathBuf},
+    path::Path,
     str::FromStr as _,
     sync::Arc,
     time::{Duration, Instant},
@@ -46,7 +46,7 @@ use ui::IconName;
 use util::{ResultExt, TryFutureExt, post_inc};
 use uuid::Uuid;
 
-#[derive(Clone, Eq, PartialEq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
+#[derive(Clone, Debug, Eq, PartialEq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
 pub struct ContextId(String);
 
 impl ContextId {
@@ -648,7 +648,7 @@ pub struct AssistantContext {
     pending_token_count: Task<Option<()>>,
     pending_save: Task<Result<()>>,
     pending_cache_warming_task: Task<Option<()>>,
-    path: Option<PathBuf>,
+    path: Option<Arc<Path>>,
     _subscriptions: Vec<Subscription>,
     telemetry: Option<Arc<Telemetry>>,
     language_registry: Arc<LanguageRegistry>,
@@ -839,7 +839,7 @@ impl AssistantContext {
 
     pub fn deserialize(
         saved_context: SavedContext,
-        path: PathBuf,
+        path: Arc<Path>,
         language_registry: Arc<LanguageRegistry>,
         prompt_builder: Arc<PromptBuilder>,
         slash_commands: Arc<SlashCommandWorkingSet>,
@@ -1147,8 +1147,8 @@ impl AssistantContext {
         self.prompt_builder.clone()
     }
 
-    pub fn path(&self) -> Option<&Path> {
-        self.path.as_deref()
+    pub fn path(&self) -> Option<&Arc<Path>> {
+        self.path.as_ref()
     }
 
     pub fn summary(&self) -> Option<&ContextSummary> {
@@ -3181,7 +3181,7 @@ impl AssistantContext {
                 fs.atomic_write(new_path.clone(), serde_json::to_string(&context).unwrap())
                     .await?;
                 if let Some(old_path) = old_path {
-                    if new_path != old_path {
+                    if new_path.as_path() != old_path.as_ref() {
                         fs.remove_file(
                             &old_path,
                             RemoveOptions {
@@ -3193,7 +3193,7 @@ impl AssistantContext {
                     }
                 }
 
-                this.update(cx, |this, _| this.path = Some(new_path))?;
+                this.update(cx, |this, _| this.path = Some(new_path.into()))?;
             }
 
             Ok(())
@@ -3589,6 +3589,6 @@ impl SavedContextV0_1_0 {
 #[derive(Debug, Clone)]
 pub struct SavedContextMetadata {
     pub title: String,
-    pub path: PathBuf,
+    pub path: Arc<Path>,
     pub mtime: chrono::DateTime<chrono::Local>,
 }

crates/assistant_context_editor/src/context/context_tests.rs 🔗

@@ -959,7 +959,7 @@ async fn test_workflow_step_parsing(cx: &mut TestAppContext) {
     let deserialized_context = cx.new(|cx| {
         AssistantContext::deserialize(
             serialized_context,
-            Default::default(),
+            Path::new("").into(),
             registry.clone(),
             prompt_builder.clone(),
             Arc::new(SlashCommandWorkingSet::default()),
@@ -1120,7 +1120,7 @@ async fn test_serialization(cx: &mut TestAppContext) {
     let deserialized_context = cx.new(|cx| {
         AssistantContext::deserialize(
             serialized_context,
-            Default::default(),
+            Path::new("").into(),
             registry.clone(),
             prompt_builder.clone(),
             Arc::new(SlashCommandWorkingSet::default()),

crates/assistant_context_editor/src/context_editor.rs 🔗

@@ -48,7 +48,14 @@ use project::{Project, Worktree};
 use rope::Point;
 use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsStore, update_settings_file};
-use std::{any::TypeId, cmp, ops::Range, path::PathBuf, sync::Arc, time::Duration};
+use std::{
+    any::TypeId,
+    cmp,
+    ops::Range,
+    path::{Path, PathBuf},
+    sync::Arc,
+    time::Duration,
+};
 use text::SelectionGoal;
 use ui::{
     ButtonLike, Disclosure, ElevationIndex, KeyBinding, PopoverMenuHandle, TintColor, Tooltip,
@@ -139,7 +146,7 @@ pub trait AssistantPanelDelegate {
     fn open_saved_context(
         &self,
         workspace: &mut Workspace,
-        path: PathBuf,
+        path: Arc<Path>,
         window: &mut Window,
         cx: &mut Context<Workspace>,
     ) -> Task<Result<()>>;

crates/assistant_context_editor/src/context_store.rs 🔗

@@ -20,14 +20,7 @@ use prompt_store::PromptBuilder;
 use regex::Regex;
 use rpc::AnyProtoClient;
 use std::sync::LazyLock;
-use std::{
-    cmp::Reverse,
-    ffi::OsStr,
-    mem,
-    path::{Path, PathBuf},
-    sync::Arc,
-    time::Duration,
-};
+use std::{cmp::Reverse, ffi::OsStr, mem, path::Path, sync::Arc, time::Duration};
 use util::{ResultExt, TryFutureExt};
 
 pub(crate) fn init(client: &AnyProtoClient) {
@@ -430,7 +423,7 @@ impl ContextStore {
 
     pub fn open_local_context(
         &mut self,
-        path: PathBuf,
+        path: Arc<Path>,
         cx: &Context<Self>,
     ) -> Task<Result<Entity<AssistantContext>>> {
         if let Some(existing_context) = self.loaded_context_for_path(&path, cx) {
@@ -478,7 +471,7 @@ impl ContextStore {
 
     pub fn delete_local_context(
         &mut self,
-        path: PathBuf,
+        path: Arc<Path>,
         cx: &mut Context<Self>,
     ) -> Task<Result<()>> {
         let fs = self.fs.clone();
@@ -501,7 +494,7 @@ impl ContextStore {
                         != Some(&path)
                 });
                 this.contexts_metadata
-                    .retain(|context| context.path != path);
+                    .retain(|context| context.path.as_ref() != path.as_ref());
             })?;
 
             Ok(())
@@ -511,7 +504,7 @@ impl ContextStore {
     fn loaded_context_for_path(&self, path: &Path, cx: &App) -> Option<Entity<AssistantContext>> {
         self.contexts.iter().find_map(|context| {
             let context = context.upgrade()?;
-            if context.read(cx).path() == Some(path) {
+            if context.read(cx).path().map(Arc::as_ref) == Some(path) {
                 Some(context)
             } else {
                 None
@@ -794,7 +787,7 @@ impl ContextStore {
                     {
                         contexts.push(SavedContextMetadata {
                             title: title.to_string(),
-                            path,
+                            path: path.into(),
                             mtime: metadata.mtime.timestamp_for_user().into(),
                         });
                     }

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

@@ -1,6 +1,6 @@
 use crate::{
-    Icon, IconName, IconSize, KeyBinding, Label, List, ListItem, ListSeparator, ListSubHeader,
-    h_flex, prelude::*, utils::WithRemSize, v_flex,
+    Icon, IconButtonShape, IconName, IconSize, KeyBinding, Label, List, ListItem, ListSeparator,
+    ListSubHeader, h_flex, prelude::*, utils::WithRemSize, v_flex,
 };
 use gpui::{
     Action, AnyElement, App, AppContext as _, DismissEvent, Entity, EventEmitter, FocusHandle,
@@ -11,6 +11,8 @@ use settings::Settings;
 use std::{rc::Rc, time::Duration};
 use theme::ThemeSettings;
 
+use super::Tooltip;
+
 pub enum ContextMenuItem {
     Separator,
     Header(SharedString),
@@ -47,6 +49,9 @@ pub struct ContextMenuEntry {
     action: Option<Box<dyn Action>>,
     disabled: bool,
     documentation_aside: Option<Rc<dyn Fn(&mut App) -> AnyElement>>,
+    end_slot_icon: Option<IconName>,
+    end_slot_title: Option<SharedString>,
+    end_slot_handler: Option<Rc<dyn Fn(Option<&FocusHandle>, &mut Window, &mut App)>>,
 }
 
 impl ContextMenuEntry {
@@ -62,6 +67,9 @@ impl ContextMenuEntry {
             action: None,
             disabled: false,
             documentation_aside: None,
+            end_slot_icon: None,
+            end_slot_title: None,
+            end_slot_handler: None,
         }
     }
 
@@ -133,10 +141,13 @@ pub struct ContextMenu {
     selected_index: Option<usize>,
     delayed: bool,
     clicked: bool,
+    end_slot_action: Option<Box<dyn Action>>,
+    key_context: SharedString,
     _on_blur_subscription: Subscription,
     keep_open_on_confirm: bool,
     eager: bool,
     documentation_aside: Option<(usize, Rc<dyn Fn(&mut App) -> AnyElement>)>,
+    fixed_width: Option<DefiniteLength>,
 }
 
 impl Focusable for ContextMenu {
@@ -172,10 +183,13 @@ impl ContextMenu {
                     selected_index: None,
                     delayed: false,
                     clicked: false,
+                    key_context: "menu".into(),
                     _on_blur_subscription,
                     keep_open_on_confirm: false,
                     eager: false,
                     documentation_aside: None,
+                    fixed_width: None,
+                    end_slot_action: None,
                 },
                 window,
                 cx,
@@ -212,10 +226,13 @@ impl ContextMenu {
                     selected_index: None,
                     delayed: false,
                     clicked: false,
+                    key_context: "menu".into(),
                     _on_blur_subscription,
                     keep_open_on_confirm: true,
                     eager: false,
                     documentation_aside: None,
+                    fixed_width: None,
+                    end_slot_action: None,
                 },
                 window,
                 cx,
@@ -245,10 +262,13 @@ impl ContextMenu {
                     selected_index: None,
                     delayed: false,
                     clicked: false,
+                    key_context: "menu".into(),
                     _on_blur_subscription,
                     keep_open_on_confirm: false,
                     eager: true,
                     documentation_aside: None,
+                    fixed_width: None,
+                    end_slot_action: None,
                 },
                 window,
                 cx,
@@ -263,7 +283,7 @@ impl ContextMenu {
     ///
     /// This only works if the [`ContextMenu`] was constructed using [`ContextMenu::build_persistent`]. Otherwise it is
     /// a no-op.
-    fn rebuild(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+    pub fn rebuild(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         let Some(builder) = self.builder.clone() else {
             return;
         };
@@ -279,6 +299,7 @@ impl ContextMenu {
                 selected_index: None,
                 delayed: false,
                 clicked: false,
+                key_context: "menu".into(),
                 _on_blur_subscription: cx.on_blur(
                     &focus_handle,
                     window,
@@ -287,6 +308,8 @@ impl ContextMenu {
                 keep_open_on_confirm: false,
                 eager: false,
                 documentation_aside: None,
+                fixed_width: None,
+                end_slot_action: None,
             },
             window,
             cx,
@@ -339,6 +362,36 @@ impl ContextMenu {
             action,
             disabled: false,
             documentation_aside: None,
+            end_slot_icon: None,
+            end_slot_title: None,
+            end_slot_handler: None,
+        }));
+        self
+    }
+
+    pub fn entry_with_end_slot(
+        mut self,
+        label: impl Into<SharedString>,
+        action: Option<Box<dyn Action>>,
+        handler: impl Fn(&mut Window, &mut App) + 'static,
+        end_slot_icon: IconName,
+        end_slot_title: SharedString,
+        end_slot_handler: impl Fn(&mut Window, &mut App) + 'static,
+    ) -> Self {
+        self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
+            toggle: None,
+            label: label.into(),
+            handler: Rc::new(move |_, window, cx| handler(window, cx)),
+            icon: None,
+            icon_position: IconPosition::End,
+            icon_size: IconSize::Small,
+            icon_color: None,
+            action,
+            disabled: false,
+            documentation_aside: None,
+            end_slot_icon: Some(end_slot_icon),
+            end_slot_title: Some(end_slot_title),
+            end_slot_handler: Some(Rc::new(move |_, window, cx| end_slot_handler(window, cx))),
         }));
         self
     }
@@ -362,6 +415,9 @@ impl ContextMenu {
             action,
             disabled: false,
             documentation_aside: None,
+            end_slot_icon: None,
+            end_slot_title: None,
+            end_slot_handler: None,
         }));
         self
     }
@@ -413,6 +469,9 @@ impl ContextMenu {
             icon_color: None,
             disabled: false,
             documentation_aside: None,
+            end_slot_icon: None,
+            end_slot_title: None,
+            end_slot_handler: None,
         }));
         self
     }
@@ -438,6 +497,9 @@ impl ContextMenu {
             icon_color: None,
             disabled: true,
             documentation_aside: None,
+            end_slot_icon: None,
+            end_slot_title: None,
+            end_slot_handler: None,
         }));
         self
     }
@@ -454,12 +516,43 @@ impl ContextMenu {
             icon_color: None,
             disabled: false,
             documentation_aside: None,
+            end_slot_icon: None,
+            end_slot_title: None,
+            end_slot_handler: None,
         }));
         self
     }
 
-    pub fn keep_open_on_confirm(mut self) -> Self {
-        self.keep_open_on_confirm = true;
+    pub fn keep_open_on_confirm(mut self, keep_open: bool) -> Self {
+        self.keep_open_on_confirm = keep_open;
+        self
+    }
+
+    pub fn trigger_end_slot_handler(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(entry) = self.selected_index.and_then(|ix| self.items.get(ix)) else {
+            return;
+        };
+        let ContextMenuItem::Entry(entry) = entry else {
+            return;
+        };
+        let Some(handler) = entry.end_slot_handler.as_ref() else {
+            return;
+        };
+        handler(None, window, cx);
+    }
+
+    pub fn fixed_width(mut self, width: DefiniteLength) -> Self {
+        self.fixed_width = Some(width);
+        self
+    }
+
+    pub fn end_slot_action(mut self, action: Box<dyn Action>) -> Self {
+        self.end_slot_action = Some(action);
+        self
+    }
+
+    pub fn key_context(mut self, context: impl Into<SharedString>) -> Self {
+        self.key_context = context.into();
         self
     }
 
@@ -492,6 +585,25 @@ impl ContextMenu {
         cx.emit(DismissEvent);
     }
 
+    pub fn end_slot(&mut self, _: &dyn Action, window: &mut Window, cx: &mut Context<Self>) {
+        let Some(item) = self.selected_index.and_then(|ix| self.items.get(ix)) else {
+            return;
+        };
+        let ContextMenuItem::Entry(entry) = item else {
+            return;
+        };
+        let Some(handler) = entry.end_slot_handler.as_ref() else {
+            return;
+        };
+        handler(None, window, cx);
+        self.rebuild(window, cx);
+        cx.notify();
+    }
+
+    pub fn clear_selected(&mut self) {
+        self.selected_index = None;
+    }
+
     fn select_first(&mut self, _: &SelectFirst, window: &mut Window, cx: &mut Context<Self>) {
         if let Some(ix) = self.items.iter().position(|item| item.is_selectable()) {
             self.select_index(ix, window, cx);
@@ -707,7 +819,11 @@ impl ContextMenu {
             action,
             disabled,
             documentation_aside,
+            end_slot_icon,
+            end_slot_title,
+            end_slot_handler,
         } = entry;
+        let this = cx.weak_entity();
 
         let handler = handler.clone();
         let menu = cx.entity().downgrade();
@@ -733,7 +849,7 @@ impl ContextMenu {
                     *icon_position == IconPosition::Start && toggle.is_none(),
                     |flex| flex.child(Icon::new(*icon_name).size(*icon_size).color(icon_color)),
                 )
-                .child(Label::new(label.clone()).color(label_color))
+                .child(Label::new(label.clone()).color(label_color).truncate())
                 .when(*icon_position == IconPosition::End, |flex| {
                     flex.child(Icon::new(*icon_name).size(*icon_size).color(icon_color))
                 })
@@ -741,6 +857,7 @@ impl ContextMenu {
         } else {
             Label::new(label.clone())
                 .color(label_color)
+                .truncate()
                 .into_any_element()
         };
 
@@ -818,6 +935,56 @@ impl ContextMenu {
                                 },
                             ),
                     )
+                    .when_some(
+                        end_slot_icon
+                            .as_ref()
+                            .zip(self.end_slot_action.as_ref())
+                            .zip(end_slot_title.as_ref())
+                            .zip(end_slot_handler.as_ref()),
+                        |el, (((icon, action), title), handler)| {
+                            el.end_slot(
+                                IconButton::new("end-slot-icon", *icon)
+                                    .shape(IconButtonShape::Square)
+                                    .tooltip({
+                                        let action_context = self.action_context.clone();
+                                        let title = title.clone();
+                                        let action = action.boxed_clone();
+                                        move |window, cx| {
+                                            action_context
+                                                .as_ref()
+                                                .map(|focus| {
+                                                    Tooltip::for_action_in(
+                                                        title.clone(),
+                                                        &*action,
+                                                        focus,
+                                                        window,
+                                                        cx,
+                                                    )
+                                                })
+                                                .unwrap_or_else(|| {
+                                                    Tooltip::for_action(
+                                                        title.clone(),
+                                                        &*action,
+                                                        window,
+                                                        cx,
+                                                    )
+                                                })
+                                        }
+                                    })
+                                    .on_click({
+                                        let handler = handler.clone();
+                                        move |_, window, cx| {
+                                            handler(None, window, cx);
+                                            this.update(cx, |this, cx| {
+                                                this.rebuild(window, cx);
+                                                cx.notify();
+                                            })
+                                            .ok();
+                                        }
+                                    }),
+                            )
+                        },
+                    )
                     .on_click({
                         let context = self.action_context.clone();
                         let keep_open_on_confirm = self.keep_open_on_confirm;
@@ -888,21 +1055,28 @@ impl Render for ContextMenu {
                     .child(
                         v_flex()
                             .id("context-menu")
-                            .min_w(px(200.))
                             .max_h(vh(0.75, window))
-                            .flex_1()
+                            .when_some(self.fixed_width, |this, width| {
+                                this.w(width).overflow_x_hidden()
+                            })
+                            .when(self.fixed_width.is_none(), |this| {
+                                this.min_w(px(200.)).flex_1()
+                            })
                             .overflow_y_scroll()
                             .track_focus(&self.focus_handle(cx))
                             .on_mouse_down_out(cx.listener(|this, _, window, cx| {
                                 this.cancel(&menu::Cancel, window, cx)
                             }))
-                            .key_context("menu")
+                            .key_context(self.key_context.as_ref())
                             .on_action(cx.listener(ContextMenu::select_first))
                             .on_action(cx.listener(ContextMenu::handle_select_last))
                             .on_action(cx.listener(ContextMenu::select_next))
                             .on_action(cx.listener(ContextMenu::select_previous))
                             .on_action(cx.listener(ContextMenu::confirm))
                             .on_action(cx.listener(ContextMenu::cancel))
+                            .when_some(self.end_slot_action.as_ref(), |el, action| {
+                                el.on_boxed_action(&**action, cx.listener(ContextMenu::end_slot))
+                            })
                             .when(!self.delayed, |mut el| {
                                 for item in self.items.iter() {
                                     if let ContextMenuItem::Entry(ContextMenuEntry {