agent_ui: Make file mention chips clickable to open files (#46751)

Daniel Llamas and Claude Opus 4.6 created

### Summary

Makes file mention chips in the AI chat input clickable to open the
referenced files. Previously, chips like `@README.md` were purely visual
indicators with no interaction.

### Changes
- **Clickable mention chips**: Users can now click on file mentions in
the chat input to open those files in the editor
- **Support for all mention types**:
  - Files → Opens in editor
  - Files with line numbers → Opens and scrolls to line
  - Directories → Reveals in project panel
  - Threads → Navigates to thread
  - Rules → Opens rules library
  - URLs → Opens in browser
- **Handles files outside workspace**: Falls back to `open_abs_path()`
for files not in the current workspace

### Implementation

Threads `MentionUri` and `WeakEntity<Workspace>` through the crease
rendering pipeline:

1. Updated `insert_crease_for_mention()` to accept mention URI and
workspace references
2. Added click handler to `MentionCrease` component using `.when()` for
conditional attachment
3. Implemented file opening helpers that mirror the existing
`thread_view.rs::open_link()` logic

### Demo


https://github.com/user-attachments/assets/21b2afb7-7a86-4a0a-aba1-e24bb1b650c2

### Testing

Manually tested:

- [x] Clicking `@README.md` opens file
- [x] Clicking file with line numbers navigates correctly
- [x] Clicking directory reveals in project panel
- [x] Files outside workspace open via absolute path

### Files Changed

- `crates/agent_ui/src/mention_set.rs` - Thread URI/workspace through
pipeline
- `crates/agent_ui/src/ui/mention_crease.rs` - Add click handler and
file opening logic
- `crates/agent_ui/src/acp/message_editor.rs` - Update call sites

### Review feedback addressed

- Replaced `.when()` + `unwrap()` with `.when_some()` + `Option::zip()`
(`0e36efb4eb`)
- De-duplicated `open_file` and `open_file_at_line` into a single
function with `Option<RangeInclusive<u32>>` (`dbcbb69a4b`)
  - Rebased onto latest `main` and resolved conflicts

  Also update item 2 under Implementation from:
_Added click handler to MentionCrease component using `.when()` for
conditional attachment_

  to:
_Added click handler to MentionCrease component using `.when_some()`
with `Option::zip()` for conditional attachment_

### Release Notes:

- agent: File mention chips in the chat input are now clickable and will
open the referenced files in the editor.

Closes  #46746

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

Change summary

crates/agent_ui/src/mention_set.rs       |  18 ++
crates/agent_ui/src/message_editor.rs    |   8 +
crates/agent_ui/src/ui/mention_crease.rs | 199 +++++++++++++++++++++++++
3 files changed, 223 insertions(+), 2 deletions(-)

Detailed changes

crates/agent_ui/src/mention_set.rs 🔗

@@ -234,6 +234,8 @@ impl MentionSet {
                 mention_uri.name().into(),
                 IconName::Image.path().into(),
                 mention_uri.tooltip_text(),
+                Some(mention_uri.clone()),
+                Some(workspace.downgrade()),
                 Some(image),
                 editor.clone(),
                 window,
@@ -247,6 +249,8 @@ impl MentionSet {
                 crease_text,
                 mention_uri.icon_path(cx),
                 mention_uri.tooltip_text(),
+                Some(mention_uri.clone()),
+                Some(workspace.downgrade()),
                 None,
                 editor.clone(),
                 window,
@@ -699,6 +703,8 @@ pub(crate) async fn insert_images_as_context(
                 MentionUri::PastedImage.name().into(),
                 IconName::Image.path().into(),
                 None,
+                None,
+                None,
                 Some(Task::ready(Ok(image.clone())).shared()),
                 editor.clone(),
                 window,
@@ -810,6 +816,8 @@ pub(crate) fn insert_crease_for_mention(
     crease_label: SharedString,
     crease_icon: SharedString,
     crease_tooltip: Option<SharedString>,
+    mention_uri: Option<MentionUri>,
+    workspace: Option<WeakEntity<Workspace>>,
     image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
     editor: Entity<Editor>,
     window: &mut Window,
@@ -830,6 +838,8 @@ pub(crate) fn insert_crease_for_mention(
                 crease_label.clone(),
                 crease_icon.clone(),
                 crease_tooltip,
+                mention_uri.clone(),
+                workspace.clone(),
                 start..end,
                 rx,
                 image,
@@ -1029,6 +1039,8 @@ fn render_mention_fold_button(
     label: SharedString,
     icon: SharedString,
     tooltip: Option<SharedString>,
+    mention_uri: Option<MentionUri>,
+    workspace: Option<WeakEntity<Workspace>>,
     range: Range<Anchor>,
     mut loading_finished: postage::barrier::Receiver,
     image_task: Option<Shared<Task<Result<Arc<Image>, String>>>>,
@@ -1049,6 +1061,8 @@ fn render_mention_fold_button(
             label,
             icon,
             tooltip,
+            mention_uri: mention_uri.clone(),
+            workspace: workspace.clone(),
             range,
             editor,
             loading: Some(loading),
@@ -1063,6 +1077,8 @@ struct LoadingContext {
     label: SharedString,
     icon: SharedString,
     tooltip: Option<SharedString>,
+    mention_uri: Option<MentionUri>,
+    workspace: Option<WeakEntity<Workspace>>,
     range: Range<Anchor>,
     editor: WeakEntity<Editor>,
     loading: Option<Task<()>>,
@@ -1079,6 +1095,8 @@ impl Render for LoadingContext {
         let id = ElementId::from(("loading_context", self.id));
 
         MentionCrease::new(id, self.icon.clone(), self.label.clone())
+            .mention_uri(self.mention_uri.clone())
+            .workspace(self.workspace.clone())
             .is_toggled(is_in_text_selection)
             .is_loading(self.loading.is_some())
             .when_some(self.tooltip.clone(), |this, tooltip_text| {

crates/agent_ui/src/message_editor.rs 🔗

@@ -722,6 +722,8 @@ impl MessageEditor {
                         crease_text.into(),
                         mention_uri.icon_path(cx),
                         mention_uri.tooltip_text(),
+                        Some(mention_uri.clone()),
+                        Some(self.workspace.clone()),
                         None,
                         self.editor.clone(),
                         window,
@@ -833,6 +835,8 @@ impl MessageEditor {
                             mention_uri.name().into(),
                             mention_uri.icon_path(cx),
                             mention_uri.tooltip_text(),
+                            Some(mention_uri.clone()),
+                            Some(self.workspace.clone()),
                             None,
                             self.editor.clone(),
                             window,
@@ -1014,6 +1018,8 @@ impl MessageEditor {
             mention_uri.name().into(),
             mention_uri.icon_path(cx),
             mention_uri.tooltip_text(),
+            Some(mention_uri.clone()),
+            Some(self.workspace.clone()),
             None,
             self.editor.clone(),
             window,
@@ -1370,6 +1376,8 @@ impl MessageEditor {
                 mention_uri.name().into(),
                 mention_uri.icon_path(cx),
                 mention_uri.tooltip_text(),
+                Some(mention_uri.clone()),
+                Some(self.workspace.clone()),
                 None,
                 self.editor.clone(),
                 window,

crates/agent_ui/src/ui/mention_crease.rs 🔗

@@ -1,15 +1,25 @@
-use std::time::Duration;
+use std::{ops::RangeInclusive, path::PathBuf, time::Duration};
 
-use gpui::{Animation, AnimationExt, AnyView, IntoElement, Window, pulsating_between};
+use acp_thread::MentionUri;
+use agent_client_protocol as acp;
+use editor::{Editor, SelectionEffects, scroll::Autoscroll};
+use gpui::{
+    Animation, AnimationExt, AnyView, Context, IntoElement, WeakEntity, Window, pulsating_between,
+};
+use prompt_store::PromptId;
+use rope::Point;
 use settings::Settings;
 use theme::ThemeSettings;
 use ui::{ButtonLike, TintColor, Tooltip, prelude::*};
+use workspace::{OpenOptions, Workspace};
 
 #[derive(IntoElement)]
 pub struct MentionCrease {
     id: ElementId,
     icon: SharedString,
     label: SharedString,
+    mention_uri: Option<MentionUri>,
+    workspace: Option<WeakEntity<Workspace>>,
     is_toggled: bool,
     is_loading: bool,
     tooltip: Option<SharedString>,
@@ -26,6 +36,8 @@ impl MentionCrease {
             id: id.into(),
             icon: icon.into(),
             label: label.into(),
+            mention_uri: None,
+            workspace: None,
             is_toggled: false,
             is_loading: false,
             tooltip: None,
@@ -33,6 +45,16 @@ impl MentionCrease {
         }
     }
 
+    pub fn mention_uri(mut self, mention_uri: Option<MentionUri>) -> Self {
+        self.mention_uri = mention_uri;
+        self
+    }
+
+    pub fn workspace(mut self, workspace: Option<WeakEntity<Workspace>>) -> Self {
+        self.workspace = workspace;
+        self
+    }
+
     pub fn is_toggled(mut self, is_toggled: bool) -> Self {
         self.is_toggled = is_toggled;
         self
@@ -76,6 +98,14 @@ impl RenderOnce for MentionCrease {
             .height(button_height)
             .selected_style(ButtonStyle::Tinted(TintColor::Accent))
             .toggle_state(self.is_toggled)
+            .when_some(
+                self.mention_uri.clone().zip(self.workspace.clone()),
+                |this, (mention_uri, workspace)| {
+                    this.on_click(move |_event, window, cx| {
+                        open_mention_uri(mention_uri.clone(), &workspace, window, cx);
+                    })
+                },
+            )
             .child(
                 h_flex()
                     .pb_px()
@@ -114,3 +144,168 @@ impl RenderOnce for MentionCrease {
             })
     }
 }
+
+fn open_mention_uri(
+    mention_uri: MentionUri,
+    workspace: &WeakEntity<Workspace>,
+    window: &mut Window,
+    cx: &mut App,
+) {
+    let Some(workspace) = workspace.upgrade() else {
+        return;
+    };
+
+    workspace.update(cx, |workspace, cx| match mention_uri {
+        MentionUri::File { abs_path } => {
+            open_file(workspace, abs_path, None, window, cx);
+        }
+        MentionUri::Symbol {
+            abs_path,
+            line_range,
+            ..
+        }
+        | MentionUri::Selection {
+            abs_path: Some(abs_path),
+            line_range,
+        } => {
+            open_file(workspace, abs_path, Some(line_range), window, cx);
+        }
+        MentionUri::Directory { abs_path } => {
+            reveal_in_project_panel(workspace, abs_path, cx);
+        }
+        MentionUri::Thread { id, name } => {
+            open_thread(workspace, id, name, window, cx);
+        }
+        MentionUri::TextThread { .. } => {}
+        MentionUri::Rule { id, .. } => {
+            open_rule(workspace, id, window, cx);
+        }
+        MentionUri::Fetch { url } => {
+            cx.open_url(url.as_str());
+        }
+        MentionUri::PastedImage
+        | MentionUri::Selection { abs_path: None, .. }
+        | MentionUri::Diagnostics { .. }
+        | MentionUri::TerminalSelection { .. }
+        | MentionUri::GitDiff { .. } => {}
+    });
+}
+
+fn open_file(
+    workspace: &mut Workspace,
+    abs_path: PathBuf,
+    line_range: Option<RangeInclusive<u32>>,
+    window: &mut Window,
+    cx: &mut Context<Workspace>,
+) {
+    let project = workspace.project();
+
+    if let Some(project_path) =
+        project.update(cx, |project, cx| project.find_project_path(&abs_path, cx))
+    {
+        let item = workspace.open_path(project_path, None, true, window, cx);
+        if let Some(line_range) = line_range {
+            window
+                .spawn(cx, async move |cx| {
+                    let Some(editor) = item.await?.downcast::<Editor>() else {
+                        return Ok(());
+                    };
+                    editor
+                        .update_in(cx, |editor, window, cx| {
+                            let range = Point::new(*line_range.start(), 0)
+                                ..Point::new(*line_range.start(), 0);
+                            editor.change_selections(
+                                SelectionEffects::scroll(Autoscroll::center()),
+                                window,
+                                cx,
+                                |selections| selections.select_ranges(vec![range]),
+                            );
+                        })
+                        .ok();
+                    anyhow::Ok(())
+                })
+                .detach_and_log_err(cx);
+        } else {
+            item.detach_and_log_err(cx);
+        }
+    } else if abs_path.exists() {
+        workspace
+            .open_abs_path(
+                abs_path,
+                OpenOptions {
+                    focus: Some(true),
+                    ..Default::default()
+                },
+                window,
+                cx,
+            )
+            .detach_and_log_err(cx);
+    }
+}
+
+fn reveal_in_project_panel(
+    workspace: &mut Workspace,
+    abs_path: PathBuf,
+    cx: &mut Context<Workspace>,
+) {
+    let project = workspace.project();
+    let Some(entry_id) = project.update(cx, |project, cx| {
+        let path = project.find_project_path(&abs_path, cx)?;
+        project.entry_for_path(&path, cx).map(|entry| entry.id)
+    }) else {
+        return;
+    };
+
+    project.update(cx, |_, cx| {
+        cx.emit(project::Event::RevealInProjectPanel(entry_id));
+    });
+}
+
+fn open_thread(
+    workspace: &mut Workspace,
+    id: acp::SessionId,
+    name: String,
+    window: &mut Window,
+    cx: &mut Context<Workspace>,
+) {
+    use crate::AgentPanel;
+    use acp_thread::AgentSessionInfo;
+
+    let Some(panel) = workspace.panel::<AgentPanel>(cx) else {
+        return;
+    };
+
+    panel.update(cx, |panel, cx| {
+        panel.load_agent_thread(
+            AgentSessionInfo {
+                session_id: id,
+                cwd: None,
+                title: Some(name.into()),
+                updated_at: None,
+                meta: None,
+            },
+            window,
+            cx,
+        )
+    });
+}
+
+fn open_rule(
+    _workspace: &mut Workspace,
+    id: PromptId,
+    window: &mut Window,
+    cx: &mut Context<Workspace>,
+) {
+    use zed_actions::assistant::OpenRulesLibrary;
+
+    let PromptId::User { uuid } = id else {
+        return;
+    };
+
+    window.dispatch_action(
+        Box::new(OpenRulesLibrary {
+            prompt_to_select: Some(uuid.0),
+        }),
+        cx,
+    );
+}