agent: Add new panel navigation dropdown (#29539)

- [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>
This commit is contained in:
Danilo Leal 2025-04-29 21:58:45 -03:00 committed by GitHub
parent 1a4d7249f6
commit b1395c5fdf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 740 additions and 229 deletions

View File

@ -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>

After

Width:  |  Height:  |  Size: 402 B

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
use std::ops::Range; use std::ops::Range;
use std::path::PathBuf; use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@ -18,8 +18,8 @@ use editor::{Anchor, AnchorRangeExt as _, Editor, EditorEvent, MultiBuffer};
use fs::Fs; use fs::Fs;
use gpui::{ use gpui::{
Action, Animation, AnimationExt as _, AnyElement, App, AsyncWindowContext, ClipboardItem, Action, Animation, AnimationExt as _, AnyElement, App, AsyncWindowContext, ClipboardItem,
Corner, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, KeyContext, Pixels, Corner, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, KeyContext,
Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between, Pixels, Subscription, Task, UpdateGlobal, WeakEntity, prelude::*, pulsating_between,
}; };
use language::LanguageRegistry; use language::LanguageRegistry;
use language_model::{LanguageModelProviderTosView, LanguageModelRegistry}; use language_model::{LanguageModelProviderTosView, LanguageModelRegistry};
@ -41,15 +41,16 @@ use zed_actions::assistant::{OpenRulesLibrary, ToggleFocus};
use crate::active_thread::{ActiveThread, ActiveThreadEvent}; use crate::active_thread::{ActiveThread, ActiveThreadEvent};
use crate::assistant_configuration::{AssistantConfiguration, AssistantConfigurationEvent}; 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::message_editor::{MessageEditor, MessageEditorEvent};
use crate::thread::{Thread, ThreadError, ThreadId, TokenUsageRatio}; use crate::thread::{Thread, ThreadError, ThreadId, TokenUsageRatio};
use crate::thread_history::{PastContext, PastThread, ThreadHistory}; use crate::thread_history::{PastContext, PastThread, ThreadHistory};
use crate::thread_store::ThreadStore; use crate::thread_store::ThreadStore;
use crate::ui::UsageBanner; use crate::ui::UsageBanner;
use crate::{ use crate::{
AddContextServer, AgentDiff, ExpandMessageEditor, InlineAssistant, NewTextThread, NewThread, AddContextServer, AgentDiff, DeleteRecentlyOpenThread, ExpandMessageEditor, InlineAssistant,
OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ThreadEvent, ToggleContextPicker, NewTextThread, NewThread, OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ThreadEvent,
ToggleContextPicker, ToggleNavigationMenu,
}; };
pub fn init(cx: &mut App) { 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 { enum ActiveView {
Thread { Thread {
change_title_editor: Entity<Editor>, change_title_editor: Entity<Editor>,
thread: WeakEntity<Thread>,
_subscriptions: Vec<gpui::Subscription>, _subscriptions: Vec<gpui::Subscription>,
}, },
PromptEditor { PromptEditor {
@ -130,7 +140,7 @@ impl ActiveView {
let editor = cx.new(|cx| { let editor = cx.new(|cx| {
let mut editor = Editor::single_line(window, cx); let mut editor = Editor::single_line(window, cx);
editor.set_text(summary, window, cx); editor.set_text(summary.clone(), window, cx);
editor editor
}); });
@ -176,6 +186,7 @@ impl ActiveView {
Self::Thread { Self::Thread {
change_title_editor: editor, change_title_editor: editor,
thread: thread.downgrade(),
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
} }
@ -279,6 +290,8 @@ pub struct AssistantPanel {
history_store: Entity<HistoryStore>, history_store: Entity<HistoryStore>,
history: Entity<ThreadHistory>, history: Entity<ThreadHistory>,
assistant_dropdown_menu_handle: PopoverMenuHandle<ContextMenu>, assistant_dropdown_menu_handle: PopoverMenuHandle<ContextMenu>,
assistant_navigation_menu_handle: PopoverMenuHandle<ContextMenu>,
assistant_navigation_menu: Option<Entity<ContextMenu>>,
width: Option<Pixels>, width: Option<Pixels>,
height: Option<Pixels>, height: Option<Pixels>,
} }
@ -380,8 +393,14 @@ impl AssistantPanel {
} }
}); });
let history_store = let history_store = cx.new(|cx| {
cx.new(|cx| HistoryStore::new(thread_store.clone(), context_store.clone(), cx)); HistoryStore::new(
thread_store.clone(),
context_store.clone(),
[RecentEntry::Thread(thread.clone())],
cx,
)
});
cx.observe(&history_store, |_, _, cx| cx.notify()).detach(); cx.observe(&history_store, |_, _, cx| cx.notify()).detach();
@ -392,7 +411,7 @@ impl AssistantPanel {
cx.notify(); cx.notify();
} }
}); });
let thread = cx.new(|cx| { let active_thread = cx.new(|cx| {
ActiveThread::new( ActiveThread::new(
thread.clone(), thread.clone(),
thread_store.clone(), thread_store.clone(),
@ -403,10 +422,111 @@ impl AssistantPanel {
) )
}); });
let active_thread_subscription = cx.subscribe(&thread, |_, _, event, cx| match &event { let active_thread_subscription =
ActiveThreadEvent::EditingMessageTokenCountChanged => { cx.subscribe(&active_thread, |_, _, event, cx| match &event {
cx.notify(); 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( let _default_model_subscription = cx.subscribe(
@ -431,7 +551,7 @@ impl AssistantPanel {
fs: fs.clone(), fs: fs.clone(),
language_registry, language_registry,
thread_store: thread_store.clone(), thread_store: thread_store.clone(),
thread, thread: active_thread,
message_editor, message_editor,
_active_thread_subscriptions: vec![ _active_thread_subscriptions: vec![
thread_subscription, thread_subscription,
@ -451,6 +571,8 @@ impl AssistantPanel {
history_store: history_store.clone(), history_store: history_store.clone(),
history: cx.new(|cx| ThreadHistory::new(weak_self, history_store, window, cx)), history: cx.new(|cx| ThreadHistory::new(weak_self, history_store, window, cx)),
assistant_dropdown_menu_handle: PopoverMenuHandle::default(), assistant_dropdown_menu_handle: PopoverMenuHandle::default(),
assistant_navigation_menu_handle: PopoverMenuHandle::default(),
assistant_navigation_menu: None,
width: None, width: None,
height: None, height: None,
} }
@ -645,13 +767,13 @@ impl AssistantPanel {
pub(crate) fn open_saved_prompt_editor( pub(crate) fn open_saved_prompt_editor(
&mut self, &mut self,
path: PathBuf, path: Arc<Path>,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Task<Result<()>> { ) -> Task<Result<()>> {
let context = self let context = self
.context_store .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 fs = self.fs.clone();
let project = self.project.clone(); let project = self.project.clone();
let workspace = self.workspace.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, &mut self,
thread_id: &ThreadId, thread_id: &ThreadId,
window: &mut Window, window: &mut Window,
@ -694,73 +816,83 @@ impl AssistantPanel {
let open_thread_task = self let open_thread_task = self
.thread_store .thread_store
.update(cx, |this, cx| this.open_thread(thread_id, cx)); .update(cx, |this, cx| this.open_thread(thread_id, cx));
cx.spawn_in(window, async move |this, cx| { cx.spawn_in(window, async move |this, cx| {
let thread = open_thread_task.await?; let thread = open_thread_task.await?;
this.update_in(cx, |this, window, cx| { this.update_in(cx, |this, window, cx| {
let thread_view = ActiveView::thread(thread.clone(), window, cx); this.open_thread(thread, window, cx);
this.set_active_view(thread_view, window, cx); anyhow::Ok(())
let message_editor_context_store = cx.new(|_cx| { })??;
crate::context_store::ContextStore::new( Ok(())
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.thread = cx.new(|cx| {
ActiveThread::new(
thread.clone(),
this.thread_store.clone(),
this.language_registry.clone(),
this.workspace.clone(),
window,
cx,
)
});
let active_thread_subscription =
cx.subscribe(&this.thread, |_, _, event, cx| match &event {
ActiveThreadEvent::EditingMessageTokenCountChanged => {
cx.notify();
}
});
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 message_editor_subscription =
cx.subscribe(&this.message_editor, |_, _, event, cx| match event {
MessageEditorEvent::Changed | MessageEditorEvent::EstimatedTokenCount => {
cx.notify();
}
});
this._active_thread_subscriptions = vec![
thread_subscription,
active_thread_subscription,
message_editor_subscription,
];
})
}) })
} }
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();
}
});
self.thread = cx.new(|cx| {
ActiveThread::new(
thread.clone(),
self.thread_store.clone(),
self.language_registry.clone(),
self.workspace.clone(),
window,
cx,
)
});
let active_thread_subscription =
cx.subscribe(&self.thread, |_, _, event, cx| match &event {
ActiveThreadEvent::EditingMessageTokenCountChanged => {
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);
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>) { pub fn go_back(&mut self, _: &workspace::GoBack, window: &mut Window, cx: &mut Context<Self>) {
match self.active_view { match self.active_view {
ActiveView::Configuration | ActiveView::History => { ActiveView::Configuration | ActiveView::History => {
@ -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( pub fn open_agent_diff(
&mut self, &mut self,
_: &OpenAgentDiff, _: &OpenAgentDiff,
@ -921,7 +1062,7 @@ impl AssistantPanel {
pub(crate) fn delete_context( pub(crate) fn delete_context(
&mut self, &mut self,
path: PathBuf, path: Arc<Path>,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Task<Result<()>> { ) -> Task<Result<()>> {
self.context_store self.context_store
@ -937,6 +1078,32 @@ impl AssistantPanel {
let current_is_history = matches!(self.active_view, ActiveView::History); let current_is_history = matches!(self.active_view, ActiveView::History);
let new_is_history = matches!(new_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 { if current_is_history && !new_is_history {
self.active_view = new_view; self.active_view = new_view;
} else if !current_is_history && new_is_history { } else if !current_is_history && new_is_history {
@ -1066,16 +1233,13 @@ impl AssistantPanel {
if is_empty { if is_empty {
Label::new(Thread::DEFAULT_SUMMARY.clone()) Label::new(Thread::DEFAULT_SUMMARY.clone())
.truncate() .truncate()
.ml_2()
.into_any_element() .into_any_element()
} else if summary.is_none() { } else if summary.is_none() {
Label::new(LOADING_SUMMARY_PLACEHOLDER) Label::new(LOADING_SUMMARY_PLACEHOLDER)
.ml_2()
.truncate() .truncate()
.into_any_element() .into_any_element()
} else { } else {
div() div()
.ml_2()
.w_full() .w_full()
.child(change_title_editor.clone()) .child(change_title_editor.clone())
.into_any_element() .into_any_element()
@ -1092,18 +1256,15 @@ impl AssistantPanel {
match summary { match summary {
None => Label::new(AssistantContext::DEFAULT_SUMMARY.clone()) None => Label::new(AssistantContext::DEFAULT_SUMMARY.clone())
.truncate() .truncate()
.ml_2()
.into_any_element(), .into_any_element(),
Some(summary) => { Some(summary) => {
if summary.done { if summary.done {
div() div()
.ml_2()
.w_full() .w_full()
.child(title_editor.clone()) .child(title_editor.clone())
.into_any_element() .into_any_element()
} else { } else {
Label::new(LOADING_SUMMARY_PLACEHOLDER) Label::new(LOADING_SUMMARY_PLACEHOLDER)
.ml_2()
.truncate() .truncate()
.into_any_element() .into_any_element()
} }
@ -1130,7 +1291,6 @@ impl AssistantPanel {
let thread = active_thread.thread().read(cx); let thread = active_thread.thread().read(cx);
let thread_id = thread.id().clone(); let thread_id = thread.id().clone();
let is_empty = active_thread.is_empty(); let is_empty = active_thread.is_empty();
let is_history = matches!(self.active_view, ActiveView::History);
let show_token_count = match &self.active_view { let show_token_count = match &self.active_view {
ActiveView::Thread { .. } => !is_empty, ActiveView::Thread { .. } => !is_empty,
@ -1140,30 +1300,98 @@ impl AssistantPanel {
let focus_handle = self.focus_handle(cx); let focus_handle = self.focus_handle(cx);
let go_back_button = match &self.active_view { let go_back_button = div().child(
ActiveView::History | ActiveView::Configuration => Some( IconButton::new("go-back", IconName::ArrowLeft)
div().pl_1().child( .icon_size(IconSize::Small)
IconButton::new("go-back", IconName::ArrowLeft) .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) .icon_size(IconSize::Small)
.on_click(cx.listener(|this, _, window, cx| { .style(ui::ButtonStyle::Subtle),
this.go_back(&workspace::GoBack, window, cx); {
})) let focus_handle = focus_handle.clone();
.tooltip({ move |window, cx| {
let focus_handle = focus_handle.clone(); Tooltip::for_action_in(
move |window, cx| { "Toggle Panel Menu",
Tooltip::for_action_in( &ToggleNavigationMenu,
"Go Back", &focus_handle,
&workspace::GoBack, window,
&focus_handle, cx,
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,
),
}), }),
), )
), .action("Add Custom Server", Box::new(AddContextServer))
_ => None, }))
}; });
h_flex() h_flex()
.id("assistant-toolbar") .id("assistant-toolbar")
@ -1177,18 +1405,22 @@ impl AssistantPanel {
.border_color(cx.theme().colors().border) .border_color(cx.theme().colors().border)
.child( .child(
h_flex() h_flex()
.w_full() .size_full()
.pl_1()
.gap_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(self.render_title_view(window, cx)),
) )
.child( .child(
h_flex() h_flex()
.h_full() .h_full()
.gap_2() .gap_2()
.when(show_token_count, |parent| .when(show_token_count, |parent| {
parent.children(self.render_token_count(&thread, cx)) parent.children(self.render_token_count(&thread, cx))
) })
.child( .child(
h_flex() h_flex()
.h_full() .h_full()
@ -1216,72 +1448,7 @@ impl AssistantPanel {
); );
}), }),
) )
.child( .child(agent_extra_menu),
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))
},
))
}),
),
), ),
) )
} }
@ -1982,6 +2149,7 @@ impl Render for AssistantPanel {
.on_action(cx.listener(Self::deploy_rules_library)) .on_action(cx.listener(Self::deploy_rules_library))
.on_action(cx.listener(Self::open_agent_diff)) .on_action(cx.listener(Self::open_agent_diff))
.on_action(cx.listener(Self::go_back)) .on_action(cx.listener(Self::go_back))
.on_action(cx.listener(Self::toggle_navigation_menu))
.child(self.render_toolbar(window, cx)) .child(self.render_toolbar(window, cx))
.map(|parent| match &self.active_view { .map(|parent| match &self.active_view {
ActiveView::Thread { .. } => parent ActiveView::Thread { .. } => parent
@ -2066,7 +2234,7 @@ impl AssistantPanelDelegate for ConcreteAssistantPanelDelegate {
fn open_saved_context( fn open_saved_context(
&self, &self,
workspace: &mut Workspace, workspace: &mut Workspace,
path: std::path::PathBuf, path: Arc<Path>,
window: &mut Window, window: &mut Window,
cx: &mut Context<Workspace>, cx: &mut Context<Workspace>,
) -> Task<Result<()>> { ) -> Task<Result<()>> {

View File

@ -267,7 +267,7 @@ impl ContextPicker {
context_picker.update(cx, |this, cx| this.select_entry(entry, window, cx)) 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| { cx.subscribe(&menu, move |_, _, _: &DismissEvent, cx| {

View File

@ -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 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_store::{SerializedThreadMetadata, ThreadStore}; use crate::{
Thread,
thread::ThreadId,
thread_store::{SerializedThreadMetadata, ThreadStore},
};
#[derive(Debug)] 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(Clone, Debug)]
pub enum HistoryEntry { pub enum HistoryEntry {
Thread(SerializedThreadMetadata), Thread(SerializedThreadMetadata),
Context(SavedContextMetadata), 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 { pub struct HistoryStore {
thread_store: Entity<ThreadStore>, thread_store: Entity<ThreadStore>,
context_store: Entity<assistant_context_editor::ContextStore>, context_store: Entity<assistant_context_editor::ContextStore>,
recently_opened_entries: VecDeque<RecentEntry>,
_subscriptions: Vec<gpui::Subscription>, _subscriptions: Vec<gpui::Subscription>,
_save_recently_opened_entries_task: Task<()>,
} }
impl HistoryStore { impl HistoryStore {
pub fn new( pub fn new(
thread_store: Entity<ThreadStore>, thread_store: Entity<ThreadStore>,
context_store: Entity<assistant_context_editor::ContextStore>, context_store: Entity<assistant_context_editor::ContextStore>,
initial_recent_entries: impl IntoIterator<Item = RecentEntry>,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Self { ) -> Self {
let subscriptions = vec![ let subscriptions = vec![
@ -36,10 +77,61 @@ impl HistoryStore {
cx.observe(&context_store, |_, _, cx| cx.notify()), 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 { Self {
thread_store, thread_store,
context_store, context_store,
recently_opened_entries: initial_recent_entries.into_iter().collect(),
_subscriptions: subscriptions, _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> { pub fn recent_entries(&self, limit: usize, cx: &mut Context<Self>) -> Vec<HistoryEntry> {
self.entries(cx).into_iter().take(limit).collect() 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()
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -156,6 +156,7 @@ pub enum IconName {
MailOpen, MailOpen,
Maximize, Maximize,
Menu, Menu,
MenuAlt,
MessageBubbles, MessageBubbles,
Mic, Mic,
MicMute, MicMute,

View File

@ -19,6 +19,7 @@ actions!(
SelectNext, SelectNext,
SelectFirst, SelectFirst,
SelectLast, SelectLast,
Restart Restart,
EndSlot,
] ]
); );

View File

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