assistant2: Make context pills clickable (#27680)

Bennet Bo Fenner created

Release Notes:

- N/A

Change summary

crates/assistant2/src/active_thread.rs   | 174 +++++++++++++++++++++----
crates/assistant2/src/context.rs         |  16 +-
crates/assistant2/src/context_store.rs   |  19 +-
crates/assistant2/src/context_strip.rs   |  29 +++-
crates/assistant2/src/ui/context_pill.rs |   4 
5 files changed, 189 insertions(+), 53 deletions(-)

Detailed changes

crates/assistant2/src/active_thread.rs 🔗

@@ -1,3 +1,4 @@
+use crate::context::{AssistantContext, ContextId};
 use crate::thread::{
     LastRestoreCheckpoint, MessageId, MessageSegment, RequestKind, Thread, ThreadError,
     ThreadEvent, ThreadFeedback,
@@ -19,9 +20,12 @@ use gpui::{
 use language::{Buffer, LanguageRegistry};
 use language_model::{LanguageModelRegistry, LanguageModelToolUseId, Role};
 use markdown::{Markdown, MarkdownStyle};
+use project::ProjectItem as _;
 use settings::Settings as _;
+use std::rc::Rc;
 use std::sync::Arc;
 use std::time::Duration;
+use text::ToPoint;
 use theme::ThemeSettings;
 use ui::{prelude::*, Disclosure, IconButton, KeyBinding, Scrollbar, ScrollbarState, Tooltip};
 use util::ResultExt as _;
@@ -778,6 +782,9 @@ impl ActiveThread {
             return Empty.into_any();
         };
 
+        let context_store = self.context_store.clone();
+        let workspace = self.workspace.clone();
+
         let thread = self.thread.read(cx);
         // Get all the data we need from thread before we start using it in closures
         let checkpoint = thread.checkpoint_for_message(message_id);
@@ -901,36 +908,53 @@ impl ActiveThread {
                 .into_any_element(),
         };
 
-        let message_content = v_flex()
-            .gap_1p5()
-            .child(
-                if let Some(edit_message_editor) = edit_message_editor.clone() {
-                    div()
-                        .key_context("EditMessageEditor")
-                        .on_action(cx.listener(Self::cancel_editing_message))
-                        .on_action(cx.listener(Self::confirm_editing_message))
-                        .min_h_6()
-                        .child(edit_message_editor)
-                } else {
-                    div()
-                        .min_h_6()
-                        .text_ui(cx)
-                        .child(self.render_message_content(message_id, rendered_message, cx))
-                },
-            )
-            .when_some(context, |parent, context| {
-                if !context.is_empty() {
-                    parent.child(
-                        h_flex().flex_wrap().gap_1().children(
-                            context
-                                .into_iter()
-                                .map(|context| ContextPill::added(context, false, false, None)),
-                        ),
-                    )
-                } else {
-                    parent
-                }
-            });
+        let message_content =
+            v_flex()
+                .gap_1p5()
+                .child(
+                    if let Some(edit_message_editor) = edit_message_editor.clone() {
+                        div()
+                            .key_context("EditMessageEditor")
+                            .on_action(cx.listener(Self::cancel_editing_message))
+                            .on_action(cx.listener(Self::confirm_editing_message))
+                            .min_h_6()
+                            .child(edit_message_editor)
+                    } else {
+                        div()
+                            .min_h_6()
+                            .text_ui(cx)
+                            .child(self.render_message_content(message_id, rendered_message, cx))
+                    },
+                )
+                .when_some(context, |parent, context| {
+                    if !context.is_empty() {
+                        parent.child(h_flex().flex_wrap().gap_1().children(
+                            context.into_iter().map(|context| {
+                                let context_id = context.id;
+                                ContextPill::added(context, false, false, None).on_click(Rc::new(
+                                    cx.listener({
+                                        let workspace = workspace.clone();
+                                        let context_store = context_store.clone();
+                                        move |_, _, window, cx| {
+                                            if let Some(workspace) = workspace.upgrade() {
+                                                open_context(
+                                                    context_id,
+                                                    context_store.clone(),
+                                                    workspace,
+                                                    window,
+                                                    cx,
+                                                );
+                                                cx.notify();
+                                            }
+                                        }
+                                    }),
+                                ))
+                            }),
+                        ))
+                    } else {
+                        parent
+                    }
+                });
 
         let styled_message = match message.role {
             Role::User => v_flex()
@@ -1823,3 +1847,93 @@ impl Render for ActiveThread {
             .child(self.render_vertical_scrollbar(cx))
     }
 }
+
+pub(crate) fn open_context(
+    id: ContextId,
+    context_store: Entity<ContextStore>,
+    workspace: Entity<Workspace>,
+    window: &mut Window,
+    cx: &mut App,
+) {
+    let Some(context) = context_store.read(cx).context_for_id(id) else {
+        return;
+    };
+
+    match context {
+        AssistantContext::File(file_context) => {
+            if let Some(project_path) = file_context.context_buffer.buffer.read(cx).project_path(cx)
+            {
+                workspace.update(cx, |workspace, cx| {
+                    workspace
+                        .open_path(project_path, None, true, window, cx)
+                        .detach_and_log_err(cx);
+                });
+            }
+        }
+        AssistantContext::Directory(directory_context) => {
+            let path = directory_context.path.clone();
+            workspace.update(cx, |workspace, cx| {
+                workspace.project().update(cx, |project, cx| {
+                    if let Some(entry) = project.entry_for_path(&path, cx) {
+                        cx.emit(project::Event::RevealInProjectPanel(entry.id));
+                    }
+                })
+            })
+        }
+        AssistantContext::Symbol(symbol_context) => {
+            if let Some(project_path) = symbol_context
+                .context_symbol
+                .buffer
+                .read(cx)
+                .project_path(cx)
+            {
+                let snapshot = symbol_context.context_symbol.buffer.read(cx).snapshot();
+                let target_position = symbol_context
+                    .context_symbol
+                    .id
+                    .range
+                    .start
+                    .to_point(&snapshot);
+
+                let open_task = workspace.update(cx, |workspace, cx| {
+                    workspace.open_path(project_path, None, true, window, cx)
+                });
+                window
+                    .spawn(cx, async move |cx| {
+                        if let Some(active_editor) = open_task
+                            .await
+                            .log_err()
+                            .and_then(|item| item.downcast::<Editor>())
+                        {
+                            active_editor
+                                .downgrade()
+                                .update_in(cx, |editor, window, cx| {
+                                    editor.go_to_singleton_buffer_point(
+                                        target_position,
+                                        window,
+                                        cx,
+                                    );
+                                })
+                                .log_err();
+                        }
+                    })
+                    .detach();
+            }
+        }
+        AssistantContext::FetchedUrl(fetched_url_context) => {
+            cx.open_url(&fetched_url_context.url);
+        }
+        AssistantContext::Thread(thread_context) => {
+            let thread_id = thread_context.thread.read(cx).id().clone();
+            workspace.update(cx, |workspace, cx| {
+                if let Some(panel) = workspace.panel::<AssistantPanel>(cx) {
+                    panel.update(cx, |panel, cx| {
+                        panel
+                            .open_thread(&thread_id, window, cx)
+                            .detach_and_log_err(cx)
+                    });
+                }
+            })
+        }
+    }
+}

crates/assistant2/src/context.rs 🔗

@@ -1,5 +1,4 @@
-use std::rc::Rc;
-use std::{ops::Range, path::Path};
+use std::ops::Range;
 
 use file_icons::FileIcons;
 use gpui::{App, Entity, SharedString};
@@ -85,7 +84,7 @@ pub struct FileContext {
 
 #[derive(Debug)]
 pub struct DirectoryContext {
-    pub path: Rc<Path>,
+    pub path: ProjectPath,
     pub context_buffers: Vec<ContextBuffer>,
     pub snapshot: ContextSnapshot,
 }
@@ -185,17 +184,18 @@ impl FileContext {
 impl DirectoryContext {
     pub fn new(
         id: ContextId,
-        path: &Path,
+        project_path: ProjectPath,
         context_buffers: Vec<ContextBuffer>,
     ) -> DirectoryContext {
-        let full_path: SharedString = path.to_string_lossy().into_owned().into();
+        let full_path: SharedString = project_path.path.to_string_lossy().into_owned().into();
 
-        let name = match path.file_name() {
+        let name = match project_path.path.file_name() {
             Some(name) => name.to_string_lossy().into_owned().into(),
             None => full_path.clone(),
         };
 
-        let parent = path
+        let parent = project_path
+            .path
             .parent()
             .and_then(|p| p.file_name())
             .map(|p| p.to_string_lossy().into_owned().into());
@@ -208,7 +208,7 @@ impl DirectoryContext {
             .into();
 
         DirectoryContext {
-            path: path.into(),
+            path: project_path,
             context_buffers,
             snapshot: ContextSnapshot {
                 id,

crates/assistant2/src/context_store.rs 🔗

@@ -60,6 +60,10 @@ impl ContextStore {
         &self.context
     }
 
+    pub fn context_for_id(&self, id: ContextId) -> Option<&AssistantContext> {
+        self.context().iter().find(|context| context.id() == id)
+    }
+
     pub fn clear(&mut self) {
         self.context.clear();
         self.files.clear();
@@ -253,21 +257,21 @@ impl ContextStore {
             }
 
             this.update(cx, |this, _| {
-                this.insert_directory(&project_path.path, context_buffers);
+                this.insert_directory(project_path, context_buffers);
             })?;
 
             anyhow::Ok(())
         })
     }
 
-    fn insert_directory(&mut self, path: &Path, context_buffers: Vec<ContextBuffer>) {
+    fn insert_directory(&mut self, project_path: ProjectPath, context_buffers: Vec<ContextBuffer>) {
         let id = self.next_context_id.post_inc();
-        self.directories.insert(path.to_path_buf(), id);
+        self.directories.insert(project_path.path.to_path_buf(), id);
 
         self.context
             .push(AssistantContext::Directory(DirectoryContext::new(
                 id,
-                path,
+                project_path,
                 context_buffers,
             )));
     }
@@ -704,8 +708,9 @@ pub fn refresh_context_store_text(
                         || changed_buffers.iter().any(|buffer| {
                             let buffer = buffer.read(cx);
 
-                            buffer_path_log_err(&buffer)
-                                .map_or(false, |path| path.starts_with(&directory_context.path))
+                            buffer_path_log_err(&buffer).map_or(false, |path| {
+                                path.starts_with(&directory_context.path.path)
+                            })
                         });
 
                     if should_refresh {
@@ -797,7 +802,7 @@ fn refresh_directory_text(
         let context_buffers = context_buffers.await;
         context_store
             .update(cx, |context_store, _| {
-                let new_directory_context = DirectoryContext::new(id, &path, context_buffers);
+                let new_directory_context = DirectoryContext::new(id, path, context_buffers);
                 context_store.replace_context(AssistantContext::Directory(new_directory_context));
             })
             .ok();

crates/assistant2/src/context_strip.rs 🔗

@@ -4,15 +4,15 @@ use collections::HashSet;
 use editor::Editor;
 use file_icons::FileIcons;
 use gpui::{
-    App, Bounds, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Subscription,
-    WeakEntity,
+    App, Bounds, ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
+    Subscription, WeakEntity,
 };
 use itertools::Itertools;
 use language::Buffer;
 use ui::{prelude::*, KeyBinding, PopoverMenu, PopoverMenuHandle, Tooltip};
 use workspace::{notifications::NotifyResultExt, Workspace};
 
-use crate::context::ContextKind;
+use crate::context::{ContextId, ContextKind};
 use crate::context_picker::{ConfirmBehavior, ContextPicker};
 use crate::context_store::ContextStore;
 use crate::thread::Thread;
@@ -277,6 +277,14 @@ impl ContextStrip {
         best.map(|(index, _, _)| index)
     }
 
+    fn open_context(&mut self, id: ContextId, window: &mut Window, cx: &mut App) {
+        let Some(workspace) = self.workspace.upgrade() else {
+            return;
+        };
+
+        crate::active_thread::open_context(id, self.context_store.clone(), workspace, window, cx);
+    }
+
     fn remove_focused_context(
         &mut self,
         _: &RemoveFocusedContext,
@@ -458,6 +466,7 @@ impl Render for ContextStrip {
                 }
             })
             .children(context.iter().enumerate().map(|(i, context)| {
+                let id = context.id;
                 ContextPill::added(
                     context.clone(),
                     dupe_names.contains(&context.name),
@@ -473,10 +482,16 @@ impl Render for ContextStrip {
                         }))
                     }),
                 )
-                .on_click(Rc::new(cx.listener(move |this, _, _window, cx| {
-                    this.focused_index = Some(i);
-                    cx.notify();
-                })))
+                .on_click(Rc::new(cx.listener(
+                    move |this, event: &ClickEvent, window, cx| {
+                        if event.down.click_count > 1 {
+                            this.open_context(id, window, cx);
+                        } else {
+                            this.focused_index = Some(i);
+                        }
+                        cx.notify();
+                    },
+                )))
             }))
             .when_some(suggested_context, |el, suggested| {
                 el.child(

crates/assistant2/src/ui/context_pill.rs 🔗

@@ -162,7 +162,9 @@ impl RenderOnce for ContextPill {
                 })
                 .when_some(on_click.as_ref(), |element, on_click| {
                     let on_click = on_click.clone();
-                    element.on_click(move |event, window, cx| on_click(event, window, cx))
+                    element
+                        .cursor_pointer()
+                        .on_click(move |event, window, cx| on_click(event, window, cx))
                 }),
             ContextPill::Suggested {
                 name,