assistant2: Allow clicking on `@mentions` (#27846)

Bennet Bo Fenner created

https://github.com/user-attachments/assets/f6f7c115-5c40-48f9-a099-2b691993967b

Release Notes:

- N/A

Change summary

crates/assistant2/src/active_thread.rs                      | 147 ++++++
crates/assistant2/src/context_picker.rs                     |  78 +++
crates/assistant2/src/context_picker/completion_provider.rs |  74 ++-
crates/assistant2/src/thread.rs                             |   6 
4 files changed, 266 insertions(+), 39 deletions(-)

Detailed changes

crates/assistant2/src/active_thread.rs 🔗

@@ -1,5 +1,6 @@
 use crate::AssistantPanel;
 use crate::context::{AssistantContext, ContextId};
+use crate::context_picker::MentionLink;
 use crate::thread::{
     LastRestoreCheckpoint, MessageId, MessageSegment, RequestKind, Thread, ThreadError,
     ThreadEvent, ThreadFeedback,
@@ -7,8 +8,10 @@ use crate::thread::{
 use crate::thread_store::ThreadStore;
 use crate::tool_use::{PendingToolUseStatus, ToolUse, ToolUseStatus};
 use crate::ui::{AddedContext, AgentNotification, AgentNotificationEvent, ContextPill};
+use anyhow::Context as _;
 use assistant_settings::{AssistantSettings, NotifyWhenAgentWaiting};
 use collections::HashMap;
+use editor::scroll::Autoscroll;
 use editor::{Editor, MultiBuffer};
 use gpui::{
     AbsoluteLength, Animation, AnimationExt, AnyElement, App, ClickEvent, DefiniteLength,
@@ -63,6 +66,7 @@ impl RenderedMessage {
     fn from_segments(
         segments: &[MessageSegment],
         language_registry: Arc<LanguageRegistry>,
+        workspace: WeakEntity<Workspace>,
         window: &Window,
         cx: &mut App,
     ) -> Self {
@@ -71,12 +75,18 @@ impl RenderedMessage {
             segments: Vec::with_capacity(segments.len()),
         };
         for segment in segments {
-            this.push_segment(segment, window, cx);
+            this.push_segment(segment, workspace.clone(), window, cx);
         }
         this
     }
 
-    fn append_thinking(&mut self, text: &String, window: &Window, cx: &mut App) {
+    fn append_thinking(
+        &mut self,
+        text: &String,
+        workspace: WeakEntity<Workspace>,
+        window: &Window,
+        cx: &mut App,
+    ) {
         if let Some(RenderedMessageSegment::Thinking {
             content,
             scroll_handle,
@@ -88,13 +98,25 @@ impl RenderedMessage {
             scroll_handle.scroll_to_bottom();
         } else {
             self.segments.push(RenderedMessageSegment::Thinking {
-                content: render_markdown(text.into(), self.language_registry.clone(), window, cx),
+                content: render_markdown(
+                    text.into(),
+                    self.language_registry.clone(),
+                    workspace,
+                    window,
+                    cx,
+                ),
                 scroll_handle: ScrollHandle::default(),
             });
         }
     }
 
-    fn append_text(&mut self, text: &String, window: &Window, cx: &mut App) {
+    fn append_text(
+        &mut self,
+        text: &String,
+        workspace: WeakEntity<Workspace>,
+        window: &Window,
+        cx: &mut App,
+    ) {
         if let Some(RenderedMessageSegment::Text(markdown)) = self.segments.last_mut() {
             markdown.update(cx, |markdown, cx| markdown.append(text, cx));
         } else {
@@ -102,21 +124,35 @@ impl RenderedMessage {
                 .push(RenderedMessageSegment::Text(render_markdown(
                     SharedString::from(text),
                     self.language_registry.clone(),
+                    workspace,
                     window,
                     cx,
                 )));
         }
     }
 
-    fn push_segment(&mut self, segment: &MessageSegment, window: &Window, cx: &mut App) {
+    fn push_segment(
+        &mut self,
+        segment: &MessageSegment,
+        workspace: WeakEntity<Workspace>,
+        window: &Window,
+        cx: &mut App,
+    ) {
         let rendered_segment = match segment {
             MessageSegment::Thinking(text) => RenderedMessageSegment::Thinking {
-                content: render_markdown(text.into(), self.language_registry.clone(), window, cx),
+                content: render_markdown(
+                    text.into(),
+                    self.language_registry.clone(),
+                    workspace,
+                    window,
+                    cx,
+                ),
                 scroll_handle: ScrollHandle::default(),
             },
             MessageSegment::Text(text) => RenderedMessageSegment::Text(render_markdown(
                 text.into(),
                 self.language_registry.clone(),
+                workspace,
                 window,
                 cx,
             )),
@@ -136,6 +172,7 @@ enum RenderedMessageSegment {
 fn render_markdown(
     text: SharedString,
     language_registry: Arc<LanguageRegistry>,
+    workspace: WeakEntity<Workspace>,
     window: &Window,
     cx: &mut App,
 ) -> Entity<Markdown> {
@@ -210,7 +247,80 @@ fn render_markdown(
         ..Default::default()
     };
 
-    cx.new(|cx| Markdown::new(text, markdown_style, Some(language_registry), None, cx))
+    cx.new(|cx| {
+        Markdown::new(text, markdown_style, Some(language_registry), None, cx).open_url(
+            move |text, window, cx| {
+                open_markdown_link(text, workspace.clone(), window, cx);
+            },
+        )
+    })
+}
+
+fn open_markdown_link(
+    text: SharedString,
+    workspace: WeakEntity<Workspace>,
+    window: &mut Window,
+    cx: &mut App,
+) {
+    let Some(workspace) = workspace.upgrade() else {
+        cx.open_url(&text);
+        return;
+    };
+
+    match MentionLink::try_parse(&text, &workspace, cx) {
+        Some(MentionLink::File(path, entry)) => workspace.update(cx, |workspace, cx| {
+            if entry.is_dir() {
+                workspace.project().update(cx, |_, cx| {
+                    cx.emit(project::Event::RevealInProjectPanel(entry.id));
+                })
+            } else {
+                workspace
+                    .open_path(path, None, true, window, cx)
+                    .detach_and_log_err(cx);
+            }
+        }),
+        Some(MentionLink::Symbol(path, symbol_name)) => {
+            let open_task = workspace.update(cx, |workspace, cx| {
+                workspace.open_path(path, None, true, window, cx)
+            });
+            window
+                .spawn(cx, async move |cx| {
+                    let active_editor = open_task
+                        .await?
+                        .downcast::<Editor>()
+                        .context("Item is not an editor")?;
+                    active_editor.update_in(cx, |editor, window, cx| {
+                        let symbol_range = editor
+                            .buffer()
+                            .read(cx)
+                            .snapshot(cx)
+                            .outline(None)
+                            .and_then(|outline| {
+                                outline
+                                    .find_most_similar(&symbol_name)
+                                    .map(|(_, item)| item.range.clone())
+                            })
+                            .context("Could not find matching symbol")?;
+
+                        editor.change_selections(Some(Autoscroll::center()), window, cx, |s| {
+                            s.select_anchor_ranges([symbol_range.start..symbol_range.start])
+                        });
+                        anyhow::Ok(())
+                    })
+                })
+                .detach_and_log_err(cx);
+        }
+        Some(MentionLink::Thread(thread_id)) => 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)
+                });
+            }
+        }),
+        None => cx.open_url(&text),
+    }
 }
 
 struct EditMessageState {
@@ -318,8 +428,13 @@ impl ActiveThread {
         self.messages.push(*id);
         self.list_state.splice(old_len..old_len, 1);
 
-        let rendered_message =
-            RenderedMessage::from_segments(segments, self.language_registry.clone(), window, cx);
+        let rendered_message = RenderedMessage::from_segments(
+            segments,
+            self.language_registry.clone(),
+            self.workspace.clone(),
+            window,
+            cx,
+        );
         self.rendered_messages_by_id.insert(*id, rendered_message);
     }
 
@@ -334,8 +449,13 @@ impl ActiveThread {
             return;
         };
         self.list_state.splice(index..index + 1, 1);
-        let rendered_message =
-            RenderedMessage::from_segments(segments, self.language_registry.clone(), window, cx);
+        let rendered_message = RenderedMessage::from_segments(
+            segments,
+            self.language_registry.clone(),
+            self.workspace.clone(),
+            window,
+            cx,
+        );
         self.rendered_messages_by_id.insert(*id, rendered_message);
     }
 
@@ -360,6 +480,7 @@ impl ActiveThread {
             render_markdown(
                 tool_label.into(),
                 self.language_registry.clone(),
+                self.workspace.clone(),
                 window,
                 cx,
             ),
@@ -401,12 +522,12 @@ impl ActiveThread {
             }
             ThreadEvent::StreamedAssistantText(message_id, text) => {
                 if let Some(rendered_message) = self.rendered_messages_by_id.get_mut(&message_id) {
-                    rendered_message.append_text(text, window, cx);
+                    rendered_message.append_text(text, self.workspace.clone(), window, cx);
                 }
             }
             ThreadEvent::StreamedAssistantThinking(message_id, text) => {
                 if let Some(rendered_message) = self.rendered_messages_by_id.get_mut(&message_id) {
-                    rendered_message.append_thinking(text, window, cx);
+                    rendered_message.append_thinking(text, self.workspace.clone(), window, cx);
                 }
             }
             ThreadEvent::MessageAdded(message_id) => {

crates/assistant2/src/context_picker.rs 🔗

@@ -16,7 +16,7 @@ use gpui::{
     App, DismissEvent, Empty, Entity, EventEmitter, FocusHandle, Focusable, Task, WeakEntity,
 };
 use multi_buffer::MultiBufferRow;
-use project::ProjectPath;
+use project::{Entry, ProjectPath};
 use symbol_context_picker::SymbolContextPicker;
 use thread_context_picker::{ThreadContextEntry, render_thread_context_entry};
 use ui::{
@@ -30,6 +30,7 @@ use crate::context_picker::fetch_context_picker::FetchContextPicker;
 use crate::context_picker::file_context_picker::FileContextPicker;
 use crate::context_picker::thread_context_picker::ThreadContextPicker;
 use crate::context_store::ContextStore;
+use crate::thread::ThreadId;
 use crate::thread_store::ThreadStore;
 
 #[derive(Debug, Clone, Copy)]
@@ -568,6 +569,7 @@ pub(crate) fn insert_crease_for_mention(
             return;
         };
 
+        let start = start.bias_right(&snapshot);
         let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
 
         let placeholder = FoldPlaceholder {
@@ -677,3 +679,77 @@ fn fold_toggle(
             .into_any_element()
     }
 }
+
+pub enum MentionLink {
+    File(ProjectPath, Entry),
+    Symbol(ProjectPath, String),
+    Thread(ThreadId),
+}
+
+impl MentionLink {
+    pub fn for_file(file_name: &str, full_path: &str) -> String {
+        format!("[@{}](file:{})", file_name, full_path)
+    }
+
+    pub fn for_symbol(symbol_name: &str, full_path: &str) -> String {
+        format!("[@{}](symbol:{}:{})", symbol_name, full_path, symbol_name)
+    }
+
+    pub fn for_fetch(url: &str) -> String {
+        format!("[@{}]({})", url, url)
+    }
+
+    pub fn for_thread(thread: &ThreadContextEntry) -> String {
+        format!("[@{}](thread:{})", thread.summary, thread.id)
+    }
+
+    pub fn try_parse(link: &str, workspace: &Entity<Workspace>, cx: &App) -> Option<Self> {
+        fn extract_project_path_from_link(
+            path: &str,
+            workspace: &Entity<Workspace>,
+            cx: &App,
+        ) -> Option<ProjectPath> {
+            let path = PathBuf::from(path);
+            let worktree_name = path.iter().next()?;
+            let path: PathBuf = path.iter().skip(1).collect();
+            let worktree_id = workspace
+                .read(cx)
+                .visible_worktrees(cx)
+                .find(|worktree| worktree.read(cx).root_name() == worktree_name)
+                .map(|worktree| worktree.read(cx).id())?;
+            Some(ProjectPath {
+                worktree_id,
+                path: path.into(),
+            })
+        }
+
+        let (prefix, link, target) = {
+            let mut parts = link.splitn(3, ':');
+            let prefix = parts.next();
+            let link = parts.next();
+            let target = parts.next();
+            (prefix, link, target)
+        };
+
+        match (prefix, link, target) {
+            (Some("file"), Some(path), _) => {
+                let project_path = extract_project_path_from_link(path, workspace, cx)?;
+                let entry = workspace
+                    .read(cx)
+                    .project()
+                    .read(cx)
+                    .entry_for_path(&project_path, cx)?;
+                Some(MentionLink::File(project_path, entry))
+            }
+            (Some("symbol"), Some(path), Some(symbol_name)) => {
+                let project_path = extract_project_path_from_link(path, workspace, cx)?;
+                Some(MentionLink::Symbol(project_path, symbol_name.to_string()))
+            }
+            (Some("thread"), Some(thread_id), _) => {
+                let thread_id = ThreadId::from(thread_id);
+                Some(MentionLink::Thread(thread_id))
+            }
+            _ => None,
+        }
+    }
+}

crates/assistant2/src/context_picker/completion_provider.rs 🔗

@@ -24,7 +24,9 @@ use crate::thread_store::ThreadStore;
 
 use super::fetch_context_picker::fetch_url_content;
 use super::thread_context_picker::ThreadContextEntry;
-use super::{ContextPickerMode, recent_context_picker_entries, supported_context_picker_modes};
+use super::{
+    ContextPickerMode, MentionLink, recent_context_picker_entries, supported_context_picker_modes,
+};
 
 pub struct ContextPickerCompletionProvider {
     workspace: WeakEntity<Workspace>,
@@ -154,7 +156,7 @@ impl ContextPickerCompletionProvider {
         } else {
             IconName::MessageBubbles
         };
-        let new_text = format!("@thread {}", thread_entry.summary);
+        let new_text = MentionLink::for_thread(&thread_entry);
         let new_text_len = new_text.len();
         Completion {
             old_range: source_range.clone(),
@@ -198,7 +200,7 @@ impl ContextPickerCompletionProvider {
         context_store: Entity<ContextStore>,
         http_client: Arc<HttpClientWithUrl>,
     ) -> Completion {
-        let new_text = format!("@fetch {}", url_to_fetch);
+        let new_text = MentionLink::for_fetch(&url_to_fetch);
         let new_text_len = new_text.len();
         Completion {
             old_range: source_range.clone(),
@@ -279,7 +281,7 @@ impl ContextPickerCompletionProvider {
             crease_icon_path.clone()
         };
 
-        let new_text = format!("@file {}", full_path);
+        let new_text = MentionLink::for_file(&file_name, &full_path);
         let new_text_len = new_text.len();
         Completion {
             old_range: source_range.clone(),
@@ -326,17 +328,22 @@ impl ContextPickerCompletionProvider {
             .read(cx)
             .root_name();
 
-        let (file_name, _) = super::file_context_picker::extract_file_name_and_directory(
+        let (file_name, directory) = super::file_context_picker::extract_file_name_and_directory(
             &symbol.path.path,
             path_prefix,
         );
+        let full_path = if let Some(directory) = directory {
+            format!("{}{}", directory, file_name)
+        } else {
+            file_name.to_string()
+        };
 
         let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId);
         let mut label = CodeLabel::plain(symbol.name.clone(), None);
         label.push_str(" ", None);
         label.push_str(&file_name, comment_id);
 
-        let new_text = format!("@symbol {}:{}", file_name, symbol.name);
+        let new_text = MentionLink::for_symbol(&symbol.name, &full_path);
         let new_text_len = new_text.len();
         Some(Completion {
             old_range: source_range.clone(),
@@ -400,7 +407,7 @@ impl CompletionProvider for ContextPickerCompletionProvider {
         };
 
         let snapshot = buffer.read(cx).snapshot();
-        let source_range = snapshot.anchor_after(state.source_range.start)
+        let source_range = snapshot.anchor_before(state.source_range.start)
             ..snapshot.anchor_before(state.source_range.end);
 
         let thread_store = self.thread_store.clone();
@@ -646,6 +653,15 @@ impl MentionCompletion {
         if last_mention_start >= line.len() {
             return Some(Self::default());
         }
+        if last_mention_start > 0
+            && line
+                .chars()
+                .nth(last_mention_start - 1)
+                .map_or(false, |c| !c.is_whitespace())
+        {
+            return None;
+        }
+
         let rest_of_line = &line[last_mention_start + 1..];
 
         let mut mode = None;
@@ -746,6 +762,8 @@ mod tests {
                 argument: Some("main.rs".to_string()),
             })
         );
+
+        assert_eq!(MentionCompletion::try_parse("test@", 0), None);
     }
 
     #[gpui::test]
@@ -914,44 +932,50 @@ mod tests {
         });
 
         editor.update(&mut cx, |editor, cx| {
-            assert_eq!(editor.text(cx), "Lorem @file dir/a/one.txt",);
+            assert_eq!(editor.text(cx), "Lorem [@one.txt](file:dir/a/one.txt)",);
             assert!(!editor.has_visible_completions_menu());
             assert_eq!(
                 crease_ranges(editor, cx),
-                vec![Point::new(0, 6)..Point::new(0, 25)]
+                vec![Point::new(0, 6)..Point::new(0, 36)]
             );
         });
 
         cx.simulate_input(" ");
 
         editor.update(&mut cx, |editor, cx| {
-            assert_eq!(editor.text(cx), "Lorem @file dir/a/one.txt ",);
+            assert_eq!(editor.text(cx), "Lorem [@one.txt](file:dir/a/one.txt) ",);
             assert!(!editor.has_visible_completions_menu());
             assert_eq!(
                 crease_ranges(editor, cx),
-                vec![Point::new(0, 6)..Point::new(0, 25)]
+                vec![Point::new(0, 6)..Point::new(0, 36)]
             );
         });
 
         cx.simulate_input("Ipsum ");
 
         editor.update(&mut cx, |editor, cx| {
-            assert_eq!(editor.text(cx), "Lorem @file dir/a/one.txt Ipsum ",);
+            assert_eq!(
+                editor.text(cx),
+                "Lorem [@one.txt](file:dir/a/one.txt) Ipsum ",
+            );
             assert!(!editor.has_visible_completions_menu());
             assert_eq!(
                 crease_ranges(editor, cx),
-                vec![Point::new(0, 6)..Point::new(0, 25)]
+                vec![Point::new(0, 6)..Point::new(0, 36)]
             );
         });
 
         cx.simulate_input("@file ");
 
         editor.update(&mut cx, |editor, cx| {
-            assert_eq!(editor.text(cx), "Lorem @file dir/a/one.txt Ipsum @file ",);
+            assert_eq!(
+                editor.text(cx),
+                "Lorem [@one.txt](file:dir/a/one.txt) Ipsum @file ",
+            );
             assert!(editor.has_visible_completions_menu());
             assert_eq!(
                 crease_ranges(editor, cx),
-                vec![Point::new(0, 6)..Point::new(0, 25)]
+                vec![Point::new(0, 6)..Point::new(0, 36)]
             );
         });
 
@@ -964,14 +988,14 @@ mod tests {
         editor.update(&mut cx, |editor, cx| {
             assert_eq!(
                 editor.text(cx),
-                "Lorem @file dir/a/one.txt Ipsum @file dir/b/seven.txt"
+                "Lorem [@one.txt](file:dir/a/one.txt) Ipsum [@seven.txt](file:dir/b/seven.txt)"
             );
             assert!(!editor.has_visible_completions_menu());
             assert_eq!(
                 crease_ranges(editor, cx),
                 vec![
-                    Point::new(0, 6)..Point::new(0, 25),
-                    Point::new(0, 32)..Point::new(0, 53)
+                    Point::new(0, 6)..Point::new(0, 36),
+                    Point::new(0, 43)..Point::new(0, 77)
                 ]
             );
         });
@@ -981,14 +1005,14 @@ mod tests {
         editor.update(&mut cx, |editor, cx| {
             assert_eq!(
                 editor.text(cx),
-                "Lorem @file dir/a/one.txt Ipsum @file dir/b/seven.txt\n@"
+                "Lorem [@one.txt](file:dir/a/one.txt) Ipsum [@seven.txt](file:dir/b/seven.txt)\n@"
             );
             assert!(editor.has_visible_completions_menu());
             assert_eq!(
                 crease_ranges(editor, cx),
                 vec![
-                    Point::new(0, 6)..Point::new(0, 25),
-                    Point::new(0, 32)..Point::new(0, 53)
+                    Point::new(0, 6)..Point::new(0, 36),
+                    Point::new(0, 43)..Point::new(0, 77)
                 ]
             );
         });
@@ -1002,15 +1026,15 @@ mod tests {
         editor.update(&mut cx, |editor, cx| {
             assert_eq!(
                 editor.text(cx),
-                "Lorem @file dir/a/one.txt Ipsum @file dir/b/seven.txt\n@file dir/b/six.txt"
+                "Lorem [@one.txt](file:dir/a/one.txt) Ipsum [@seven.txt](file:dir/b/seven.txt)\n[@six.txt](file:dir/b/six.txt)"
             );
             assert!(!editor.has_visible_completions_menu());
             assert_eq!(
                 crease_ranges(editor, cx),
                 vec![
-                    Point::new(0, 6)..Point::new(0, 25),
-                    Point::new(0, 32)..Point::new(0, 53),
-                    Point::new(1, 0)..Point::new(1, 19)
+                    Point::new(0, 6)..Point::new(0, 36),
+                    Point::new(0, 43)..Point::new(0, 77),
+                    Point::new(1, 0)..Point::new(1, 30)
                 ]
             );
         });

crates/assistant2/src/thread.rs 🔗

@@ -58,6 +58,12 @@ impl std::fmt::Display for ThreadId {
     }
 }
 
+impl From<&str> for ThreadId {
+    fn from(value: &str) -> Self {
+        Self(value.into())
+    }
+}
+
 #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Serialize, Deserialize)]
 pub struct MessageId(pub(crate) usize);