assistant2: Suggest recent files and threads as context (#22959)

Agus Zubiaga , Danilo , Piotr , and Nathan created

The context picker will now display up to 6 recent files/threads to add
as a context:

<img
src="https://github.com/user-attachments/assets/80c87bf9-70ad-4e81-ba24-7a624378b991"
width=400>



Note: We decided to use a `ContextMenu` instead of `Picker` for the
initial one since the latter didn't quite fit the design for the
"Recent" section.

Release Notes:

- N/A

---------

Co-authored-by: Danilo <danilo@zed.dev>
Co-authored-by: Piotr <piotr@zed.dev>
Co-authored-by: Nathan <nathan@zed.dev>

Change summary

crates/assistant2/src/active_thread.rs                           |   4 
crates/assistant2/src/assistant_panel.rs                         |   9 
crates/assistant2/src/context.rs                                 |  18 
crates/assistant2/src/context_picker.rs                          | 472 +
crates/assistant2/src/context_picker/directory_context_picker.rs |   2 
crates/assistant2/src/context_picker/fetch_context_picker.rs     |   2 
crates/assistant2/src/context_picker/file_context_picker.rs      | 165 
crates/assistant2/src/context_picker/thread_context_picker.rs    |  66 
crates/assistant2/src/context_store.rs                           |  19 
crates/assistant2/src/context_strip.rs                           |  12 
crates/assistant2/src/thread.rs                                  |   5 
crates/assistant2/src/thread_history.rs                          |   6 
crates/ui/src/components/context_menu.rs                         | 176 
crates/ui/src/utils/with_rem_size.rs                             |  12 
crates/workspace/src/workspace.rs                                |  15 
15 files changed, 641 insertions(+), 342 deletions(-)

Detailed changes

crates/assistant2/src/active_thread.rs πŸ”—

@@ -76,6 +76,10 @@ impl ActiveThread {
         self.thread.read(cx).summary()
     }
 
+    pub fn summary_or_default(&self, cx: &AppContext) -> SharedString {
+        self.thread.read(cx).summary_or_default()
+    }
+
     pub fn last_error(&self) -> Option<ThreadError> {
         self.last_error.clone()
     }

crates/assistant2/src/assistant_panel.rs πŸ”—

@@ -300,11 +300,12 @@ impl AssistantPanel {
     fn render_toolbar(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
         let focus_handle = self.focus_handle(cx);
 
-        let title = if self.thread.read(cx).is_empty() {
-            SharedString::from("New Thread")
+        let thread = self.thread.read(cx);
+
+        let title = if thread.is_empty() {
+            thread.summary_or_default(cx)
         } else {
-            self.thread
-                .read(cx)
+            thread
                 .summary(cx)
                 .unwrap_or_else(|| SharedString::from("Loading Summary…"))
         };

crates/assistant2/src/context.rs πŸ”—

@@ -43,6 +43,24 @@ pub enum ContextKind {
 }
 
 impl ContextKind {
+    pub fn all() -> &'static [ContextKind] {
+        &[
+            ContextKind::File,
+            ContextKind::Directory,
+            ContextKind::FetchedUrl,
+            ContextKind::Thread,
+        ]
+    }
+
+    pub fn label(&self) -> &'static str {
+        match self {
+            ContextKind::File => "File",
+            ContextKind::Directory => "Folder",
+            ContextKind::FetchedUrl => "Fetch",
+            ContextKind::Thread => "Thread",
+        }
+    }
+
     pub fn icon(&self) -> IconName {
         match self {
             ContextKind::File => IconName::File,

crates/assistant2/src/context_picker.rs πŸ”—

@@ -3,15 +3,17 @@ mod fetch_context_picker;
 mod file_context_picker;
 mod thread_context_picker;
 
+use std::path::PathBuf;
 use std::sync::Arc;
 
+use editor::Editor;
+use file_context_picker::render_file_context_entry;
 use gpui::{
-    AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, SharedString, Task, View,
-    WeakModel, WeakView,
+    AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, View, WeakModel, WeakView,
 };
-use picker::{Picker, PickerDelegate};
-use ui::{prelude::*, ListItem, ListItemSpacing};
-use util::ResultExt;
+use project::ProjectPath;
+use thread_context_picker::{render_thread_context_entry, ThreadContextEntry};
+use ui::{prelude::*, ContextMenu, ContextMenuEntry, ContextMenuItem};
 use workspace::Workspace;
 
 use crate::context::ContextKind;
@@ -21,6 +23,7 @@ use crate::context_picker::file_context_picker::FileContextPicker;
 use crate::context_picker::thread_context_picker::ThreadContextPicker;
 use crate::context_store::ContextStore;
 use crate::thread_store::ThreadStore;
+use crate::AssistantPanel;
 
 #[derive(Debug, Clone, Copy)]
 pub enum ConfirmBehavior {
@@ -30,7 +33,7 @@ pub enum ConfirmBehavior {
 
 #[derive(Debug, Clone)]
 enum ContextPickerMode {
-    Default,
+    Default(View<ContextMenu>),
     File(View<FileContextPicker>),
     Directory(View<DirectoryContextPicker>),
     Fetch(View<FetchContextPicker>),
@@ -39,7 +42,10 @@ enum ContextPickerMode {
 
 pub(super) struct ContextPicker {
     mode: ContextPickerMode,
-    picker: View<Picker<ContextPickerDelegate>>,
+    workspace: WeakView<Workspace>,
+    context_store: WeakModel<ContextStore>,
+    thread_store: Option<WeakModel<ThreadStore>>,
+    confirm_behavior: ConfirmBehavior,
 }
 
 impl ContextPicker {
@@ -50,53 +56,287 @@ impl ContextPicker {
         confirm_behavior: ConfirmBehavior,
         cx: &mut ViewContext<Self>,
     ) -> Self {
-        let mut entries = Vec::new();
-        entries.push(ContextPickerEntry {
-            name: "File".into(),
-            kind: ContextKind::File,
-            icon: IconName::File,
-        });
-        entries.push(ContextPickerEntry {
-            name: "Folder".into(),
-            kind: ContextKind::Directory,
-            icon: IconName::Folder,
+        ContextPicker {
+            mode: ContextPickerMode::Default(ContextMenu::build(cx, |menu, _cx| menu)),
+            workspace,
+            context_store,
+            thread_store,
+            confirm_behavior,
+        }
+    }
+
+    pub fn reset_mode(&mut self, cx: &mut ViewContext<Self>) {
+        self.mode = ContextPickerMode::Default(self.build(cx));
+    }
+
+    fn build(&mut self, cx: &mut ViewContext<Self>) -> View<ContextMenu> {
+        let context_picker = cx.view().clone();
+
+        ContextMenu::build(cx, move |menu, cx| {
+            let kind_entry = |kind: &'static ContextKind| {
+                let context_picker = context_picker.clone();
+
+                ContextMenuEntry::new(kind.label())
+                    .icon(kind.icon())
+                    .handler(move |cx| {
+                        context_picker.update(cx, |this, cx| this.select_kind(*kind, cx))
+                    })
+            };
+
+            let recent = self.recent_entries(cx);
+            let has_recent = !recent.is_empty();
+            let recent_entries = recent
+                .into_iter()
+                .enumerate()
+                .map(|(ix, entry)| self.recent_menu_item(context_picker.clone(), ix, entry));
+
+            menu.when(has_recent, |menu| menu.label("Recent"))
+                .extend(recent_entries)
+                .when(has_recent, |menu| menu.separator())
+                .extend(ContextKind::all().into_iter().map(kind_entry))
+        })
+    }
+
+    fn select_kind(&mut self, kind: ContextKind, cx: &mut ViewContext<Self>) {
+        let context_picker = cx.view().downgrade();
+
+        match kind {
+            ContextKind::File => {
+                self.mode = ContextPickerMode::File(cx.new_view(|cx| {
+                    FileContextPicker::new(
+                        context_picker.clone(),
+                        self.workspace.clone(),
+                        self.context_store.clone(),
+                        self.confirm_behavior,
+                        cx,
+                    )
+                }));
+            }
+            ContextKind::Directory => {
+                self.mode = ContextPickerMode::Directory(cx.new_view(|cx| {
+                    DirectoryContextPicker::new(
+                        context_picker.clone(),
+                        self.workspace.clone(),
+                        self.context_store.clone(),
+                        self.confirm_behavior,
+                        cx,
+                    )
+                }));
+            }
+            ContextKind::FetchedUrl => {
+                self.mode = ContextPickerMode::Fetch(cx.new_view(|cx| {
+                    FetchContextPicker::new(
+                        context_picker.clone(),
+                        self.workspace.clone(),
+                        self.context_store.clone(),
+                        self.confirm_behavior,
+                        cx,
+                    )
+                }));
+            }
+            ContextKind::Thread => {
+                if let Some(thread_store) = self.thread_store.as_ref() {
+                    self.mode = ContextPickerMode::Thread(cx.new_view(|cx| {
+                        ThreadContextPicker::new(
+                            thread_store.clone(),
+                            context_picker.clone(),
+                            self.context_store.clone(),
+                            self.confirm_behavior,
+                            cx,
+                        )
+                    }));
+                }
+            }
+        }
+
+        cx.notify();
+        cx.focus_self();
+    }
+
+    fn recent_menu_item(
+        &self,
+        context_picker: View<ContextPicker>,
+        ix: usize,
+        entry: RecentEntry,
+    ) -> ContextMenuItem {
+        match entry {
+            RecentEntry::File {
+                project_path,
+                path_prefix,
+            } => {
+                let context_store = self.context_store.clone();
+                let path = project_path.path.clone();
+
+                ContextMenuItem::custom_entry(
+                    move |cx| {
+                        render_file_context_entry(
+                            ElementId::NamedInteger("ctx-recent".into(), ix),
+                            &path,
+                            &path_prefix,
+                            context_store.clone(),
+                            cx,
+                        )
+                        .into_any()
+                    },
+                    move |cx| {
+                        context_picker.update(cx, |this, cx| {
+                            this.add_recent_file(project_path.clone(), cx);
+                        })
+                    },
+                )
+            }
+            RecentEntry::Thread(thread) => {
+                let context_store = self.context_store.clone();
+                let view_thread = thread.clone();
+
+                ContextMenuItem::custom_entry(
+                    move |cx| {
+                        render_thread_context_entry(&view_thread, context_store.clone(), cx)
+                            .into_any()
+                    },
+                    move |cx| {
+                        context_picker.update(cx, |this, cx| {
+                            this.add_recent_thread(thread.clone(), cx);
+                        })
+                    },
+                )
+            }
+        }
+    }
+
+    fn add_recent_file(&self, project_path: ProjectPath, cx: &mut ViewContext<Self>) {
+        let Some(context_store) = self.context_store.upgrade() else {
+            return;
+        };
+
+        let task = context_store.update(cx, |context_store, cx| {
+            context_store.add_file_from_path(project_path.clone(), cx)
         });
-        entries.push(ContextPickerEntry {
-            name: "Fetch".into(),
-            kind: ContextKind::FetchedUrl,
-            icon: IconName::Globe,
+
+        let workspace = self.workspace.clone();
+
+        cx.spawn(|_, mut cx| async move {
+            match task.await {
+                Ok(_) => {
+                    return anyhow::Ok(());
+                }
+                Err(err) => {
+                    let Some(workspace) = workspace.upgrade() else {
+                        return anyhow::Ok(());
+                    };
+
+                    workspace.update(&mut cx, |workspace, cx| {
+                        workspace.show_error(&err, cx);
+                    })
+                }
+            }
+        })
+        .detach_and_log_err(cx);
+
+        cx.notify();
+    }
+
+    fn add_recent_thread(&self, thread: ThreadContextEntry, cx: &mut ViewContext<Self>) {
+        let Some(context_store) = self.context_store.upgrade() else {
+            return;
+        };
+
+        let Some(thread) = self
+            .thread_store
+            .clone()
+            .and_then(|this| this.upgrade())
+            .and_then(|this| this.update(cx, |this, cx| this.open_thread(&thread.id, cx)))
+        else {
+            return;
+        };
+
+        context_store.update(cx, |context_store, cx| {
+            context_store.add_thread(thread, cx);
         });
 
-        if thread_store.is_some() {
-            entries.push(ContextPickerEntry {
-                name: "Thread".into(),
-                kind: ContextKind::Thread,
-                icon: IconName::MessageCircle,
-            });
+        cx.notify();
+    }
+
+    fn recent_entries(&self, cx: &mut WindowContext) -> Vec<RecentEntry> {
+        let Some(workspace) = self.workspace.upgrade().map(|w| w.read(cx)) else {
+            return vec![];
+        };
+
+        let Some(context_store) = self.context_store.upgrade().map(|cs| cs.read(cx)) else {
+            return vec![];
+        };
+
+        let mut recent = Vec::with_capacity(6);
+
+        let mut current_files = context_store.file_paths(cx);
+
+        if let Some(active_path) = Self::active_singleton_buffer_path(&workspace, cx) {
+            current_files.insert(active_path);
         }
 
-        let delegate = ContextPickerDelegate {
-            context_picker: cx.view().downgrade(),
-            workspace,
-            thread_store,
-            context_store,
-            confirm_behavior,
-            entries,
-            selected_ix: 0,
+        let project = workspace.project().read(cx);
+
+        recent.extend(
+            workspace
+                .recent_navigation_history_iter(cx)
+                .filter(|(path, _)| !current_files.contains(&path.path.to_path_buf()))
+                .take(4)
+                .filter_map(|(project_path, _)| {
+                    project
+                        .worktree_for_id(project_path.worktree_id, cx)
+                        .map(|worktree| RecentEntry::File {
+                            project_path,
+                            path_prefix: worktree.read(cx).root_name().into(),
+                        })
+                }),
+        );
+
+        let mut current_threads = context_store.thread_ids();
+
+        if let Some(active_thread) = workspace
+            .panel::<AssistantPanel>(cx)
+            .map(|panel| panel.read(cx).active_thread(cx))
+        {
+            current_threads.insert(active_thread.read(cx).id().clone());
+        }
+
+        let Some(thread_store) = self
+            .thread_store
+            .as_ref()
+            .and_then(|thread_store| thread_store.upgrade())
+        else {
+            return recent;
         };
 
-        let picker = cx.new_view(|cx| {
-            Picker::nonsearchable_uniform_list(delegate, cx).max_height(Some(rems(20.).into()))
+        thread_store.update(cx, |thread_store, cx| {
+            recent.extend(
+                thread_store
+                    .threads(cx)
+                    .into_iter()
+                    .filter(|thread| !current_threads.contains(thread.read(cx).id()))
+                    .take(2)
+                    .map(|thread| {
+                        let thread = thread.read(cx);
+
+                        RecentEntry::Thread(ThreadContextEntry {
+                            id: thread.id().clone(),
+                            summary: thread.summary_or_default(),
+                        })
+                    }),
+            )
         });
 
-        ContextPicker {
-            mode: ContextPickerMode::Default,
-            picker,
-        }
+        recent
     }
 
-    pub fn reset_mode(&mut self) {
-        self.mode = ContextPickerMode::Default;
+    fn active_singleton_buffer_path(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
+        let active_item = workspace.active_item(cx)?;
+
+        let editor = active_item.to_any().downcast::<Editor>().ok()?.read(cx);
+        let buffer = editor.buffer().read(cx).as_singleton()?;
+
+        let path = buffer.read(cx).file()?.path().to_path_buf();
+        Some(path)
     }
 }
 
@@ -105,7 +345,7 @@ impl EventEmitter<DismissEvent> for ContextPicker {}
 impl FocusableView for ContextPicker {
     fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
         match &self.mode {
-            ContextPickerMode::Default => self.picker.focus_handle(cx),
+            ContextPickerMode::Default(menu) => menu.focus_handle(cx),
             ContextPickerMode::File(file_picker) => file_picker.focus_handle(cx),
             ContextPickerMode::Directory(directory_picker) => directory_picker.focus_handle(cx),
             ContextPickerMode::Fetch(fetch_picker) => fetch_picker.focus_handle(cx),
@@ -120,7 +360,7 @@ impl Render for ContextPicker {
             .w(px(400.))
             .min_w(px(400.))
             .map(|parent| match &self.mode {
-                ContextPickerMode::Default => parent.child(self.picker.clone()),
+                ContextPickerMode::Default(menu) => parent.child(menu.clone()),
                 ContextPickerMode::File(file_picker) => parent.child(file_picker.clone()),
                 ContextPickerMode::Directory(directory_picker) => {
                     parent.child(directory_picker.clone())
@@ -130,140 +370,10 @@ impl Render for ContextPicker {
             })
     }
 }
-
-#[derive(Clone)]
-struct ContextPickerEntry {
-    name: SharedString,
-    kind: ContextKind,
-    icon: IconName,
-}
-
-pub(crate) struct ContextPickerDelegate {
-    context_picker: WeakView<ContextPicker>,
-    workspace: WeakView<Workspace>,
-    thread_store: Option<WeakModel<ThreadStore>>,
-    context_store: WeakModel<ContextStore>,
-    confirm_behavior: ConfirmBehavior,
-    entries: Vec<ContextPickerEntry>,
-    selected_ix: usize,
-}
-
-impl PickerDelegate for ContextPickerDelegate {
-    type ListItem = ListItem;
-
-    fn match_count(&self) -> usize {
-        self.entries.len()
-    }
-
-    fn selected_index(&self) -> usize {
-        self.selected_ix
-    }
-
-    fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {
-        self.selected_ix = ix.min(self.entries.len().saturating_sub(1));
-        cx.notify();
-    }
-
-    fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
-        "Select a context source…".into()
-    }
-
-    fn update_matches(&mut self, _query: String, _cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
-        Task::ready(())
-    }
-
-    fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext<Picker<Self>>) {
-        if let Some(entry) = self.entries.get(self.selected_ix) {
-            self.context_picker
-                .update(cx, |this, cx| {
-                    match entry.kind {
-                        ContextKind::File => {
-                            this.mode = ContextPickerMode::File(cx.new_view(|cx| {
-                                FileContextPicker::new(
-                                    self.context_picker.clone(),
-                                    self.workspace.clone(),
-                                    self.context_store.clone(),
-                                    self.confirm_behavior,
-                                    cx,
-                                )
-                            }));
-                        }
-                        ContextKind::Directory => {
-                            this.mode = ContextPickerMode::Directory(cx.new_view(|cx| {
-                                DirectoryContextPicker::new(
-                                    self.context_picker.clone(),
-                                    self.workspace.clone(),
-                                    self.context_store.clone(),
-                                    self.confirm_behavior,
-                                    cx,
-                                )
-                            }));
-                        }
-                        ContextKind::FetchedUrl => {
-                            this.mode = ContextPickerMode::Fetch(cx.new_view(|cx| {
-                                FetchContextPicker::new(
-                                    self.context_picker.clone(),
-                                    self.workspace.clone(),
-                                    self.context_store.clone(),
-                                    self.confirm_behavior,
-                                    cx,
-                                )
-                            }));
-                        }
-                        ContextKind::Thread => {
-                            if let Some(thread_store) = self.thread_store.as_ref() {
-                                this.mode = ContextPickerMode::Thread(cx.new_view(|cx| {
-                                    ThreadContextPicker::new(
-                                        thread_store.clone(),
-                                        self.context_picker.clone(),
-                                        self.context_store.clone(),
-                                        self.confirm_behavior,
-                                        cx,
-                                    )
-                                }));
-                            }
-                        }
-                    }
-
-                    cx.focus_self();
-                })
-                .log_err();
-        }
-    }
-
-    fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
-        self.context_picker
-            .update(cx, |this, cx| match this.mode {
-                ContextPickerMode::Default => cx.emit(DismissEvent),
-                ContextPickerMode::File(_)
-                | ContextPickerMode::Directory(_)
-                | ContextPickerMode::Fetch(_)
-                | ContextPickerMode::Thread(_) => {}
-            })
-            .log_err();
-    }
-
-    fn render_match(
-        &self,
-        ix: usize,
-        selected: bool,
-        _cx: &mut ViewContext<Picker<Self>>,
-    ) -> Option<Self::ListItem> {
-        let entry = &self.entries[ix];
-
-        Some(
-            ListItem::new(ix)
-                .inset(true)
-                .spacing(ListItemSpacing::Dense)
-                .toggle_state(selected)
-                .child(
-                    h_flex()
-                        .min_w(px(250.))
-                        .max_w(px(400.))
-                        .gap_2()
-                        .child(Icon::new(entry.icon).size(IconSize::Small))
-                        .child(Label::new(entry.name.clone()).single_line()),
-                ),
-        )
-    }
+enum RecentEntry {
+    File {
+        project_path: ProjectPath,
+        path_prefix: Arc<str>,
+    },
+    Thread(ThreadContextEntry),
 }

crates/assistant2/src/context_picker/file_context_picker.rs πŸ”—

@@ -4,7 +4,9 @@ use std::sync::Arc;
 
 use file_icons::FileIcons;
 use fuzzy::PathMatch;
-use gpui::{AppContext, DismissEvent, FocusHandle, FocusableView, Task, View, WeakModel, WeakView};
+use gpui::{
+    AppContext, DismissEvent, FocusHandle, FocusableView, Stateful, Task, View, WeakModel, WeakView,
+};
 use picker::{Picker, PickerDelegate};
 use project::{PathMatchCandidateSet, ProjectPath, WorktreeId};
 use ui::{prelude::*, ListItem, Tooltip};
@@ -238,7 +240,7 @@ impl PickerDelegate for FileContextPickerDelegate {
     fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
         self.context_picker
             .update(cx, |this, cx| {
-                this.reset_mode();
+                this.reset_mode(cx);
                 cx.emit(DismissEvent);
             })
             .ok();
@@ -252,82 +254,97 @@ impl PickerDelegate for FileContextPickerDelegate {
     ) -> Option<Self::ListItem> {
         let path_match = &self.matches[ix];
 
-        let (file_name, directory) = if path_match.path.as_ref() == Path::new("") {
-            (SharedString::from(path_match.path_prefix.clone()), None)
-        } else {
-            let file_name = path_match
-                .path
-                .file_name()
-                .unwrap_or_default()
-                .to_string_lossy()
-                .to_string()
-                .into();
-
-            let mut directory = format!("{}/", path_match.path_prefix);
-            if let Some(parent) = path_match
-                .path
-                .parent()
-                .filter(|parent| parent != &Path::new(""))
-            {
-                directory.push_str(&parent.to_string_lossy());
-                directory.push('/');
-            }
-
-            (file_name, Some(directory))
-        };
-
-        let added = self.context_store.upgrade().and_then(|context_store| {
-            context_store
-                .read(cx)
-                .will_include_file_path(&path_match.path, cx)
-        });
-
-        let file_icon = FileIcons::get_icon(&path_match.path.clone(), cx)
-            .map(Icon::from_path)
-            .unwrap_or_else(|| Icon::new(IconName::File));
-
         Some(
             ListItem::new(ix)
                 .inset(true)
                 .toggle_state(selected)
-                .child(
-                    h_flex()
-                        .gap_2()
-                        .child(file_icon.size(IconSize::Small))
-                        .child(Label::new(file_name))
-                        .children(directory.map(|directory| {
-                            Label::new(directory)
-                                .size(LabelSize::Small)
-                                .color(Color::Muted)
-                        })),
-                )
-                .when_some(added, |el, added| match added {
-                    FileInclusion::Direct(_) => el.end_slot(
-                        h_flex()
-                            .gap_1()
-                            .child(
-                                Icon::new(IconName::Check)
-                                    .size(IconSize::Small)
-                                    .color(Color::Success),
-                            )
-                            .child(Label::new("Added").size(LabelSize::Small)),
-                    ),
-                    FileInclusion::InDirectory(dir_name) => {
-                        let dir_name = dir_name.to_string_lossy().into_owned();
-
-                        el.end_slot(
-                            h_flex()
-                                .gap_1()
-                                .child(
-                                    Icon::new(IconName::Check)
-                                        .size(IconSize::Small)
-                                        .color(Color::Success),
-                                )
-                                .child(Label::new("Included").size(LabelSize::Small)),
-                        )
-                        .tooltip(move |cx| Tooltip::text(format!("in {dir_name}"), cx))
-                    }
-                }),
+                .child(render_file_context_entry(
+                    ElementId::NamedInteger("file-ctx-picker".into(), ix),
+                    &path_match.path,
+                    &path_match.path_prefix,
+                    self.context_store.clone(),
+                    cx,
+                )),
         )
     }
 }
+
+pub fn render_file_context_entry(
+    id: ElementId,
+    path: &Path,
+    path_prefix: &Arc<str>,
+    context_store: WeakModel<ContextStore>,
+    cx: &WindowContext,
+) -> Stateful<Div> {
+    let (file_name, directory) = if path == Path::new("") {
+        (SharedString::from(path_prefix.clone()), None)
+    } else {
+        let file_name = path
+            .file_name()
+            .unwrap_or_default()
+            .to_string_lossy()
+            .to_string()
+            .into();
+
+        let mut directory = format!("{}/", path_prefix);
+
+        if let Some(parent) = path.parent().filter(|parent| parent != &Path::new("")) {
+            directory.push_str(&parent.to_string_lossy());
+            directory.push('/');
+        }
+
+        (file_name, Some(directory))
+    };
+
+    let added = context_store
+        .upgrade()
+        .and_then(|context_store| context_store.read(cx).will_include_file_path(path, cx));
+
+    let file_icon = FileIcons::get_icon(&path, cx)
+        .map(Icon::from_path)
+        .unwrap_or_else(|| Icon::new(IconName::File));
+
+    h_flex()
+        .id(id)
+        .gap_1()
+        .w_full()
+        .child(file_icon.size(IconSize::Small))
+        .child(
+            h_flex()
+                .gap_2()
+                .child(Label::new(file_name))
+                .children(directory.map(|directory| {
+                    Label::new(directory)
+                        .size(LabelSize::Small)
+                        .color(Color::Muted)
+                })),
+        )
+        .child(div().w_full())
+        .when_some(added, |el, added| match added {
+            FileInclusion::Direct(_) => el.child(
+                h_flex()
+                    .gap_1()
+                    .child(
+                        Icon::new(IconName::Check)
+                            .size(IconSize::Small)
+                            .color(Color::Success),
+                    )
+                    .child(Label::new("Added").size(LabelSize::Small)),
+            ),
+            FileInclusion::InDirectory(dir_name) => {
+                let dir_name = dir_name.to_string_lossy().into_owned();
+
+                el.child(
+                    h_flex()
+                        .gap_1()
+                        .child(
+                            Icon::new(IconName::Check)
+                                .size(IconSize::Small)
+                                .color(Color::Success),
+                        )
+                        .child(Label::new("Included").size(LabelSize::Small)),
+                )
+                .tooltip(move |cx| Tooltip::text(format!("in {dir_name}"), cx))
+            }
+        })
+}

crates/assistant2/src/context_picker/thread_context_picker.rs πŸ”—

@@ -6,7 +6,7 @@ use picker::{Picker, PickerDelegate};
 use ui::{prelude::*, ListItem};
 
 use crate::context_picker::{ConfirmBehavior, ContextPicker};
-use crate::context_store;
+use crate::context_store::{self, ContextStore};
 use crate::thread::ThreadId;
 use crate::thread_store::ThreadStore;
 
@@ -47,9 +47,9 @@ impl Render for ThreadContextPicker {
 }
 
 #[derive(Debug, Clone)]
-struct ThreadContextEntry {
-    id: ThreadId,
-    summary: SharedString,
+pub struct ThreadContextEntry {
+    pub id: ThreadId,
+    pub summary: SharedString,
 }
 
 pub struct ThreadContextPickerDelegate {
@@ -103,10 +103,8 @@ impl PickerDelegate for ThreadContextPickerDelegate {
             this.threads(cx)
                 .into_iter()
                 .map(|thread| {
-                    const DEFAULT_SUMMARY: SharedString = SharedString::new_static("New Thread");
-
                     let id = thread.read(cx).id().clone();
-                    let summary = thread.read(cx).summary().unwrap_or(DEFAULT_SUMMARY);
+                    let summary = thread.read(cx).summary_or_default();
                     ThreadContextEntry { id, summary }
                 })
                 .collect::<Vec<_>>()
@@ -179,7 +177,7 @@ impl PickerDelegate for ThreadContextPickerDelegate {
     fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
         self.context_picker
             .update(cx, |this, cx| {
-                this.reset_mode();
+                this.reset_mode(cx);
                 cx.emit(DismissEvent);
             })
             .ok();
@@ -193,27 +191,37 @@ impl PickerDelegate for ThreadContextPickerDelegate {
     ) -> Option<Self::ListItem> {
         let thread = &self.matches[ix];
 
-        let added = self.context_store.upgrade().map_or(false, |context_store| {
-            context_store.read(cx).includes_thread(&thread.id).is_some()
-        });
+        Some(ListItem::new(ix).inset(true).toggle_state(selected).child(
+            render_thread_context_entry(thread, self.context_store.clone(), cx),
+        ))
+    }
+}
 
-        Some(
-            ListItem::new(ix)
-                .inset(true)
-                .toggle_state(selected)
-                .child(Label::new(thread.summary.clone()))
-                .when(added, |el| {
-                    el.end_slot(
-                        h_flex()
-                            .gap_1()
-                            .child(
-                                Icon::new(IconName::Check)
-                                    .size(IconSize::Small)
-                                    .color(Color::Success),
-                            )
-                            .child(Label::new("Added").size(LabelSize::Small)),
+pub fn render_thread_context_entry(
+    thread: &ThreadContextEntry,
+    context_store: WeakModel<ContextStore>,
+    cx: &mut WindowContext,
+) -> Div {
+    let added = context_store.upgrade().map_or(false, |ctx_store| {
+        ctx_store.read(cx).includes_thread(&thread.id).is_some()
+    });
+
+    h_flex()
+        .gap_1()
+        .w_full()
+        .child(Icon::new(IconName::MessageCircle).size(IconSize::Small))
+        .child(Label::new(thread.summary.clone()))
+        .child(div().w_full())
+        .when(added, |el| {
+            el.child(
+                h_flex()
+                    .gap_1()
+                    .child(
+                        Icon::new(IconName::Check)
+                            .size(IconSize::Small)
+                            .color(Color::Success),
                     )
-                }),
-        )
-    }
+                    .child(Label::new("Added").size(LabelSize::Small)),
+            )
+        })
 }

crates/assistant2/src/context_store.rs πŸ”—

@@ -2,7 +2,7 @@ use std::path::{Path, PathBuf};
 use std::sync::Arc;
 
 use anyhow::{anyhow, bail, Result};
-use collections::{BTreeMap, HashMap};
+use collections::{BTreeMap, HashMap, HashSet};
 use futures::{self, future, Future, FutureExt};
 use gpui::{AppContext, AsyncAppContext, Model, ModelContext, SharedString, Task, WeakView};
 use language::Buffer;
@@ -372,6 +372,23 @@ impl ContextStore {
             }
         }
     }
+
+    pub fn file_paths(&self, cx: &AppContext) -> HashSet<PathBuf> {
+        self.context
+            .iter()
+            .filter_map(|context| match context {
+                Context::File(file) => {
+                    let buffer = file.context_buffer.buffer.read(cx);
+                    buffer_path_log_err(buffer).map(|p| p.to_path_buf())
+                }
+                Context::Directory(_) | Context::FetchedUrl(_) | Context::Thread(_) => None,
+            })
+            .collect()
+    }
+
+    pub fn thread_ids(&self) -> HashSet<ThreadId> {
+        self.threads.keys().cloned().collect()
+    }
 }
 
 pub enum FileInclusion {

crates/assistant2/src/context_strip.rs πŸ”—

@@ -23,7 +23,7 @@ use crate::{AssistantPanel, RemoveAllContext, ToggleContextPicker};
 
 pub struct ContextStrip {
     context_store: Model<ContextStore>,
-    context_picker: View<ContextPicker>,
+    pub context_picker: View<ContextPicker>,
     context_picker_menu_handle: PopoverMenuHandle<ContextPicker>,
     focus_handle: FocusHandle,
     suggest_context_kind: SuggestContextKind,
@@ -126,7 +126,7 @@ impl ContextStrip {
         }
 
         Some(SuggestedContext::Thread {
-            name: active_thread.summary().unwrap_or("New Thread".into()),
+            name: active_thread.summary_or_default(),
             thread: weak_active_thread,
         })
     }
@@ -168,7 +168,13 @@ impl Render for ContextStrip {
             .gap_1()
             .child(
                 PopoverMenu::new("context-picker")
-                    .menu(move |_cx| Some(context_picker.clone()))
+                    .menu(move |cx| {
+                        context_picker.update(cx, |this, cx| {
+                            this.reset_mode(cx);
+                        });
+
+                        Some(context_picker.clone())
+                    })
                     .trigger(
                         IconButton::new("add-context", IconName::Plus)
                             .icon_size(IconSize::Small)

crates/assistant2/src/thread.rs πŸ”—

@@ -114,6 +114,11 @@ impl Thread {
         self.summary.clone()
     }
 
+    pub fn summary_or_default(&self) -> SharedString {
+        const DEFAULT: SharedString = SharedString::new_static("New Thread");
+        self.summary.clone().unwrap_or(DEFAULT)
+    }
+
     pub fn set_summary(&mut self, summary: impl Into<SharedString>, cx: &mut ModelContext<Self>) {
         self.summary = Some(summary.into());
         cx.emit(ThreadEvent::SummaryChanged);

crates/assistant2/src/thread_history.rs πŸ”—

@@ -100,12 +100,8 @@ impl PastThread {
 impl RenderOnce for PastThread {
     fn render(self, cx: &mut WindowContext) -> impl IntoElement {
         let (id, summary) = {
-            const DEFAULT_SUMMARY: SharedString = SharedString::new_static("New Thread");
             let thread = self.thread.read(cx);
-            (
-                thread.id().clone(),
-                thread.summary().unwrap_or(DEFAULT_SUMMARY),
-            )
+            (thread.id().clone(), thread.summary_or_default())
         };
 
         let thread_timestamp = time_format::format_localized_timestamp(

crates/ui/src/components/context_menu.rs πŸ”—

@@ -12,19 +12,11 @@ use settings::Settings;
 use std::{rc::Rc, time::Duration};
 use theme::ThemeSettings;
 
-enum ContextMenuItem {
+pub enum ContextMenuItem {
     Separator,
     Header(SharedString),
     Label(SharedString),
-    Entry {
-        toggle: Option<(IconPosition, bool)>,
-        label: SharedString,
-        icon: Option<IconName>,
-        icon_size: IconSize,
-        handler: Rc<dyn Fn(Option<&FocusHandle>, &mut WindowContext)>,
-        action: Option<Box<dyn Action>>,
-        disabled: bool,
-    },
+    Entry(ContextMenuEntry),
     CustomEntry {
         entry_render: Box<dyn Fn(&mut WindowContext) -> AnyElement>,
         handler: Rc<dyn Fn(Option<&FocusHandle>, &mut WindowContext)>,
@@ -32,6 +24,86 @@ enum ContextMenuItem {
     },
 }
 
+impl ContextMenuItem {
+    pub fn custom_entry(
+        entry_render: impl Fn(&mut WindowContext) -> AnyElement + 'static,
+        handler: impl Fn(&mut WindowContext) + 'static,
+    ) -> Self {
+        Self::CustomEntry {
+            entry_render: Box::new(entry_render),
+            handler: Rc::new(move |_, cx| handler(cx)),
+            selectable: true,
+        }
+    }
+}
+
+pub struct ContextMenuEntry {
+    toggle: Option<(IconPosition, bool)>,
+    label: SharedString,
+    icon: Option<IconName>,
+    icon_size: IconSize,
+    icon_position: IconPosition,
+    handler: Rc<dyn Fn(Option<&FocusHandle>, &mut WindowContext)>,
+    action: Option<Box<dyn Action>>,
+    disabled: bool,
+}
+
+impl ContextMenuEntry {
+    pub fn new(label: impl Into<SharedString>) -> Self {
+        ContextMenuEntry {
+            toggle: None,
+            label: label.into(),
+            icon: None,
+            icon_size: IconSize::Small,
+            icon_position: IconPosition::Start,
+            handler: Rc::new(|_, _| {}),
+            action: None,
+            disabled: false,
+        }
+    }
+
+    pub fn icon(mut self, icon: IconName) -> Self {
+        self.icon = Some(icon);
+        self
+    }
+
+    pub fn icon_position(mut self, position: IconPosition) -> Self {
+        self.icon_position = position;
+        self
+    }
+
+    pub fn icon_size(mut self, icon_size: IconSize) -> Self {
+        self.icon_size = icon_size;
+        self
+    }
+
+    pub fn toggle(mut self, toggle_position: IconPosition, toggled: bool) -> Self {
+        self.toggle = Some((toggle_position, toggled));
+        self
+    }
+
+    pub fn action(mut self, action: Option<Box<dyn Action>>) -> Self {
+        self.action = action;
+        self
+    }
+
+    pub fn handler(mut self, handler: impl Fn(&mut WindowContext) + 'static) -> Self {
+        self.handler = Rc::new(move |_, cx| handler(cx));
+        self
+    }
+
+    pub fn disabled(mut self, disabled: bool) -> Self {
+        self.disabled = disabled;
+        self
+    }
+}
+
+impl From<ContextMenuEntry> for ContextMenuItem {
+    fn from(entry: ContextMenuEntry) -> Self {
+        ContextMenuItem::Entry(entry)
+    }
+}
+
 pub struct ContextMenu {
     items: Vec<ContextMenuItem>,
     focus_handle: FocusHandle,
@@ -93,21 +165,32 @@ impl ContextMenu {
         self
     }
 
+    pub fn extend<I: Into<ContextMenuItem>>(mut self, items: impl IntoIterator<Item = I>) -> Self {
+        self.items.extend(items.into_iter().map(Into::into));
+        self
+    }
+
+    pub fn item(mut self, item: impl Into<ContextMenuItem>) -> Self {
+        self.items.push(item.into());
+        self
+    }
+
     pub fn entry(
         mut self,
         label: impl Into<SharedString>,
         action: Option<Box<dyn Action>>,
         handler: impl Fn(&mut WindowContext) + 'static,
     ) -> Self {
-        self.items.push(ContextMenuItem::Entry {
+        self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
             toggle: None,
             label: label.into(),
             handler: Rc::new(move |_, cx| handler(cx)),
             icon: None,
             icon_size: IconSize::Small,
+            icon_position: IconPosition::End,
             action,
             disabled: false,
-        });
+        }));
         self
     }
 
@@ -119,15 +202,16 @@ impl ContextMenu {
         action: Option<Box<dyn Action>>,
         handler: impl Fn(&mut WindowContext) + 'static,
     ) -> Self {
-        self.items.push(ContextMenuItem::Entry {
+        self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
             toggle: Some((position, toggled)),
             label: label.into(),
             handler: Rc::new(move |_, cx| handler(cx)),
             icon: None,
             icon_size: IconSize::Small,
+            icon_position: position,
             action,
             disabled: false,
-        });
+        }));
         self
     }
 
@@ -162,7 +246,7 @@ impl ContextMenu {
     }
 
     pub fn action(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
-        self.items.push(ContextMenuItem::Entry {
+        self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
             toggle: None,
             label: label.into(),
             action: Some(action.boxed_clone()),
@@ -174,9 +258,10 @@ impl ContextMenu {
                 cx.dispatch_action(action.boxed_clone());
             }),
             icon: None,
+            icon_position: IconPosition::End,
             icon_size: IconSize::Small,
             disabled: false,
-        });
+        }));
         self
     }
 
@@ -185,7 +270,7 @@ impl ContextMenu {
         label: impl Into<SharedString>,
         action: Box<dyn Action>,
     ) -> Self {
-        self.items.push(ContextMenuItem::Entry {
+        self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
             toggle: None,
             label: label.into(),
             action: Some(action.boxed_clone()),
@@ -198,13 +283,14 @@ impl ContextMenu {
             }),
             icon: None,
             icon_size: IconSize::Small,
+            icon_position: IconPosition::End,
             disabled: true,
-        });
+        }));
         self
     }
 
     pub fn link(mut self, label: impl Into<SharedString>, action: Box<dyn Action>) -> Self {
-        self.items.push(ContextMenuItem::Entry {
+        self.items.push(ContextMenuItem::Entry(ContextMenuEntry {
             toggle: None,
             label: label.into(),
 
@@ -212,19 +298,20 @@ impl ContextMenu {
             handler: Rc::new(move |_, cx| cx.dispatch_action(action.boxed_clone())),
             icon: Some(IconName::ArrowUpRight),
             icon_size: IconSize::XSmall,
+            icon_position: IconPosition::End,
             disabled: false,
-        });
+        }));
         self
     }
 
     pub fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext<Self>) {
         let context = self.action_context.as_ref();
         if let Some(
-            ContextMenuItem::Entry {
+            ContextMenuItem::Entry(ContextMenuEntry {
                 handler,
                 disabled: false,
                 ..
-            }
+            })
             | ContextMenuItem::CustomEntry { handler, .. },
         ) = self.selected_index.and_then(|ix| self.items.get(ix))
         {
@@ -304,11 +391,11 @@ impl ContextMenu {
         }
 
         if let Some(ix) = self.items.iter().position(|item| {
-            if let ContextMenuItem::Entry {
+            if let ContextMenuItem::Entry(ContextMenuEntry {
                 action: Some(action),
                 disabled: false,
                 ..
-            } = item
+            }) = item
             {
                 action.partial_eq(dispatched)
             } else {
@@ -346,7 +433,7 @@ impl ContextMenuItem {
             ContextMenuItem::Header(_)
             | ContextMenuItem::Separator
             | ContextMenuItem::Label { .. } => false,
-            ContextMenuItem::Entry { disabled, .. } => !disabled,
+            ContextMenuItem::Entry(ContextMenuEntry { disabled, .. }) => !disabled,
             ContextMenuItem::CustomEntry { selectable, .. } => *selectable,
         }
     }
@@ -356,12 +443,17 @@ impl Render for ContextMenu {
     fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
         let ui_font_size = ThemeSettings::get_global(cx).ui_font_size;
 
-        div().occlude().elevation_2(cx).flex().flex_row().child(
-            WithRemSize::new(ui_font_size).flex().child(
+        WithRemSize::new(ui_font_size)
+            .occlude()
+            .elevation_2(cx)
+            .flex()
+            .flex_row()
+            .child(
                 v_flex()
                     .id("context-menu")
                     .min_w(px(200.))
                     .max_h(vh(0.75, cx))
+                    .flex_1()
                     .overflow_y_scroll()
                     .track_focus(&self.focus_handle(cx))
                     .on_mouse_down_out(cx.listener(|this, _, cx| this.cancel(&menu::Cancel, cx)))
@@ -374,11 +466,11 @@ impl Render for ContextMenu {
                     .on_action(cx.listener(ContextMenu::cancel))
                     .when(!self.delayed, |mut el| {
                         for item in self.items.iter() {
-                            if let ContextMenuItem::Entry {
+                            if let ContextMenuItem::Entry(ContextMenuEntry {
                                 action: Some(action),
                                 disabled: false,
                                 ..
-                            } = item
+                            }) = item
                             {
                                 el = el.on_boxed_action(
                                     &**action,
@@ -388,7 +480,6 @@ impl Render for ContextMenu {
                         }
                         el
                     })
-                    .flex_none()
                     .child(List::new().children(self.items.iter_mut().enumerate().map(
                         |(ix, item)| {
                             match item {
@@ -403,15 +494,16 @@ impl Render for ContextMenu {
                                     .disabled(true)
                                     .child(Label::new(label.clone()))
                                     .into_any_element(),
-                                ContextMenuItem::Entry {
+                                ContextMenuItem::Entry(ContextMenuEntry {
                                     toggle,
                                     label,
                                     handler,
                                     icon,
                                     icon_size,
+                                    icon_position,
                                     action,
                                     disabled,
-                                } => {
+                                }) => {
                                     let handler = handler.clone();
                                     let menu = cx.view().downgrade();
                                     let color = if *disabled {
@@ -422,10 +514,21 @@ impl Render for ContextMenu {
                                     let label_element = if let Some(icon_name) = icon {
                                         h_flex()
                                             .gap_1()
+                                            .when(*icon_position == IconPosition::Start, |flex| {
+                                                flex.child(
+                                                    Icon::new(*icon_name)
+                                                        .size(*icon_size)
+                                                        .color(color),
+                                                )
+                                            })
                                             .child(Label::new(label.clone()).color(color))
-                                            .child(
-                                                Icon::new(*icon_name).size(*icon_size).color(color),
-                                            )
+                                            .when(*icon_position == IconPosition::End, |flex| {
+                                                flex.child(
+                                                    Icon::new(*icon_name)
+                                                        .size(*icon_size)
+                                                        .color(color),
+                                                )
+                                            })
                                             .into_any_element()
                                     } else {
                                         Label::new(label.clone()).color(color).into_any_element()
@@ -520,7 +623,6 @@ impl Render for ContextMenu {
                             }
                         },
                     ))),
-            ),
-        )
+            )
     }
 }

crates/ui/src/utils/with_rem_size.rs πŸ”—

@@ -1,6 +1,7 @@
 use gpui::{
     div, AnyElement, Bounds, Div, DivFrameState, Element, ElementId, GlobalElementId, Hitbox,
-    IntoElement, LayoutId, ParentElement, Pixels, StyleRefinement, Styled, WindowContext,
+    InteractiveElement as _, IntoElement, LayoutId, ParentElement, Pixels, StyleRefinement, Styled,
+    WindowContext,
 };
 
 /// An element that sets a particular rem size for its children.
@@ -18,6 +19,13 @@ impl WithRemSize {
             rem_size: rem_size.into(),
         }
     }
+
+    /// Block the mouse from interacting with this element or any of its children
+    /// The fluent API equivalent to [`Interactivity::occlude_mouse`]
+    pub fn occlude(mut self) -> Self {
+        self.div = self.div.occlude();
+        self
+    }
 }
 
 impl Styled for WithRemSize {
@@ -37,7 +45,7 @@ impl Element for WithRemSize {
     type PrepaintState = Option<Hitbox>;
 
     fn id(&self) -> Option<ElementId> {
-        self.div.id()
+        Element::id(&self.div)
     }
 
     fn request_layout(

crates/workspace/src/workspace.rs πŸ”—

@@ -1316,11 +1316,10 @@ impl Workspace {
         &self.project
     }
 
-    pub fn recent_navigation_history(
+    pub fn recent_navigation_history_iter(
         &self,
-        limit: Option<usize>,
         cx: &AppContext,
-    ) -> Vec<(ProjectPath, Option<PathBuf>)> {
+    ) -> impl Iterator<Item = (ProjectPath, Option<PathBuf>)> {
         let mut abs_paths_opened: HashMap<PathBuf, HashSet<ProjectPath>> = HashMap::default();
         let mut history: HashMap<ProjectPath, (Option<PathBuf>, usize)> = HashMap::default();
         for pane in &self.panes {
@@ -1353,7 +1352,7 @@ impl Workspace {
             .sorted_by_key(|(_, (_, timestamp))| *timestamp)
             .map(|(project_path, (fs_path, _))| (project_path, fs_path))
             .rev()
-            .filter(|(history_path, abs_path)| {
+            .filter(move |(history_path, abs_path)| {
                 let latest_project_path_opened = abs_path
                     .as_ref()
                     .and_then(|abs_path| abs_paths_opened.get(abs_path))
@@ -1368,6 +1367,14 @@ impl Workspace {
                     None => true,
                 }
             })
+    }
+
+    pub fn recent_navigation_history(
+        &self,
+        limit: Option<usize>,
+        cx: &AppContext,
+    ) -> Vec<(ProjectPath, Option<PathBuf>)> {
+        self.recent_navigation_history_iter(cx)
             .take(limit.unwrap_or(usize::MAX))
             .collect()
     }