agent_ui: Fix label for image mentions (#52995)

Danilo Leal and Bennet Bo Fenner created

This PR fixes an issue where an image mention would have its label reset
to just "Image", instead of persisting the original label, when the
prompt got submitted.

Closes #48564

Release Notes:

- agent: Fixed image mention labels by persisting the file name after
submitting the prompt
- agent: Fixed directory mentions being incorrectly parsed as files when
pasting into prompt editor

---------

Co-authored-by: Bennet Bo Fenner <bennetbo@gmx.de>

Change summary

crates/acp_thread/src/mention.rs                     | 39 +++++++++++--
crates/agent/src/thread.rs                           |  2 
crates/agent_ui/src/conversation_view/thread_view.rs |  2 
crates/agent_ui/src/mention_set.rs                   | 20 +++++--
crates/agent_ui/src/message_editor.rs                | 25 ++++++--
crates/agent_ui/src/ui/mention_crease.rs             |  2 
6 files changed, 69 insertions(+), 21 deletions(-)

Detailed changes

crates/acp_thread/src/mention.rs 🔗

@@ -19,7 +19,9 @@ pub enum MentionUri {
     File {
         abs_path: PathBuf,
     },
-    PastedImage,
+    PastedImage {
+        name: String,
+    },
     Directory {
         abs_path: PathBuf,
     },
@@ -155,7 +157,9 @@ impl MentionUri {
                         include_warnings,
                     })
                 } else if path.starts_with("/agent/pasted-image") {
-                    Ok(Self::PastedImage)
+                    let name =
+                        single_query_param(&url, "name")?.unwrap_or_else(|| "Image".to_string());
+                    Ok(Self::PastedImage { name })
                 } else if path.starts_with("/agent/untitled-buffer") {
                     let fragment = url
                         .fragment()
@@ -227,7 +231,7 @@ impl MentionUri {
                 .unwrap_or_default()
                 .to_string_lossy()
                 .into_owned(),
-            MentionUri::PastedImage => "Image".to_string(),
+            MentionUri::PastedImage { name } => name.clone(),
             MentionUri::Symbol { name, .. } => name.clone(),
             MentionUri::Thread { name, .. } => name.clone(),
             MentionUri::Rule { name, .. } => name.clone(),
@@ -296,7 +300,7 @@ impl MentionUri {
             MentionUri::File { abs_path } => {
                 FileIcons::get_icon(abs_path, cx).unwrap_or_else(|| IconName::File.path().into())
             }
-            MentionUri::PastedImage => IconName::Image.path().into(),
+            MentionUri::PastedImage { .. } => IconName::Image.path().into(),
             MentionUri::Directory { abs_path } => FileIcons::get_folder_icon(false, abs_path, cx)
                 .unwrap_or_else(|| IconName::Folder.path().into()),
             MentionUri::Symbol { .. } => IconName::Code.path().into(),
@@ -322,10 +326,18 @@ impl MentionUri {
                 url.set_path(&abs_path.to_string_lossy());
                 url
             }
-            MentionUri::PastedImage => Url::parse("zed:///agent/pasted-image").unwrap(),
+            MentionUri::PastedImage { name } => {
+                let mut url = Url::parse("zed:///agent/pasted-image").unwrap();
+                url.query_pairs_mut().append_pair("name", name);
+                url
+            }
             MentionUri::Directory { abs_path } => {
                 let mut url = Url::parse("file:///").unwrap();
-                url.set_path(&abs_path.to_string_lossy());
+                let mut path = abs_path.to_string_lossy().into_owned();
+                if !path.ends_with('/') && !path.ends_with('\\') {
+                    path.push('/');
+                }
+                url.set_path(&path);
                 url
             }
             MentionUri::Symbol {
@@ -490,6 +502,21 @@ mod tests {
         assert_eq!(uri.to_uri().to_string(), expected);
     }
 
+    #[test]
+    fn test_directory_uri_round_trip_without_trailing_slash() {
+        let uri = MentionUri::Directory {
+            abs_path: PathBuf::from(path!("/path/to/dir")),
+        };
+        let serialized = uri.to_uri().to_string();
+        assert!(serialized.ends_with('/'), "directory URI must end with /");
+        let parsed = MentionUri::parse(&serialized, PathStyle::local()).unwrap();
+        assert!(
+            matches!(parsed, MentionUri::Directory { .. }),
+            "expected Directory variant, got {:?}",
+            parsed
+        );
+    }
+
     #[test]
     fn test_parse_symbol_uri() {
         let symbol_uri = uri!("file:///path/to/file.rs?symbol=MySymbol#L10:20");

crates/agent/src/thread.rs 🔗

@@ -253,7 +253,7 @@ impl UserMessage {
                             )
                             .ok();
                         }
-                        MentionUri::PastedImage => {
+                        MentionUri::PastedImage { .. } => {
                             debug_panic!("pasted image URI should not be used in mention content")
                         }
                         MentionUri::Directory { .. } => {

crates/agent_ui/src/conversation_view/thread_view.rs 🔗

@@ -8819,7 +8819,7 @@ pub(crate) fn open_link(
                     .open_path(path, None, true, window, cx)
                     .detach_and_log_err(cx);
             }
-            MentionUri::PastedImage => {}
+            MentionUri::PastedImage { .. } => {}
             MentionUri::Directory { abs_path } => {
                 let project = workspace.project();
                 let Some(entry_id) = project.update(cx, |project, cx| {

crates/agent_ui/src/mention_set.rs 🔗

@@ -154,7 +154,7 @@ impl MentionSet {
             MentionUri::Selection { abs_path: None, .. } => Task::ready(Err(anyhow!(
                 "Untitled buffer selection mentions are not supported for paste"
             ))),
-            MentionUri::PastedImage
+            MentionUri::PastedImage { .. }
             | MentionUri::TerminalSelection { .. }
             | MentionUri::MergeConflict { .. } => {
                 Task::ready(Err(anyhow!("Unsupported mention URI type for paste")))
@@ -283,7 +283,7 @@ impl MentionSet {
                 include_errors,
                 include_warnings,
             } => self.confirm_mention_for_diagnostics(include_errors, include_warnings, cx),
-            MentionUri::PastedImage => {
+            MentionUri::PastedImage { .. } => {
                 debug_panic!("pasted image URI should not be included in completions");
                 Task::ready(Err(anyhow!(
                     "pasted imaged URI should not be included in completions"
@@ -739,9 +739,11 @@ pub(crate) async fn insert_images_as_context(
         return;
     }
 
-    let replacement_text = MentionUri::PastedImage.as_link().to_string();
-
     for (image, name) in images {
+        let mention_uri = MentionUri::PastedImage {
+            name: name.to_string(),
+        };
+        let replacement_text = mention_uri.as_link().to_string();
         let Some((text_anchor, multibuffer_anchor)) = editor
             .update_in(cx, |editor, window, cx| {
                 let snapshot = editor.snapshot(window, cx);
@@ -804,7 +806,13 @@ pub(crate) async fn insert_images_as_context(
             .shared();
 
         mention_set.update(cx, |mention_set, _cx| {
-            mention_set.insert_mention(crease_id, MentionUri::PastedImage, task.clone())
+            mention_set.insert_mention(
+                crease_id,
+                MentionUri::PastedImage {
+                    name: name.to_string(),
+                },
+                task.clone(),
+            )
         });
 
         if task
@@ -873,7 +881,7 @@ pub(crate) fn paste_images_as_context(
 
     Some(window.spawn(cx, async move |mut cx| {
         use itertools::Itertools;
-        let default_name: SharedString = MentionUri::PastedImage.name().into();
+        let default_name: SharedString = "Image".into();
         let (mut images, paths): (Vec<(gpui::Image, SharedString)>, Vec<_>) = clipboard
             .into_entries()
             .filter_map(|entry| match entry {

crates/agent_ui/src/message_editor.rs 🔗

@@ -261,7 +261,7 @@ async fn resolve_pasted_context_items(
 ) -> (Vec<ResolvedPastedContextItem>, Vec<Entity<Worktree>>) {
     let mut items = Vec::new();
     let mut added_worktrees = Vec::new();
-    let default_image_name: SharedString = MentionUri::PastedImage.name().into();
+    let default_image_name: SharedString = "Image".into();
 
     for entry in entries {
         match entry {
@@ -812,7 +812,9 @@ impl MessageEditor {
                                 )
                                 .uri(match uri {
                                     MentionUri::File { .. } => Some(uri.to_uri().to_string()),
-                                    MentionUri::PastedImage => None,
+                                    MentionUri::PastedImage { .. } => {
+                                        Some(uri.to_uri().to_string())
+                                    }
                                     other => {
                                         debug_panic!(
                                             "unexpected mention uri for image: {:?}",
@@ -1638,7 +1640,9 @@ impl MessageEditor {
                     let mention_uri = if let Some(uri) = uri {
                         MentionUri::parse(&uri, path_style)
                     } else {
-                        Ok(MentionUri::PastedImage)
+                        Ok(MentionUri::PastedImage {
+                            name: "Image".to_string(),
+                        })
                     };
                     let Some(mention_uri) = mention_uri.log_err() else {
                         continue;
@@ -4074,6 +4078,11 @@ mod tests {
             &mut cx,
         );
 
+        let image_name = temporary_image_path
+            .file_name()
+            .and_then(|n| n.to_str())
+            .unwrap_or("Image")
+            .to_string();
         std::fs::remove_file(&temporary_image_path).expect("remove temp png");
 
         let expected_file_uri = MentionUri::File {
@@ -4081,12 +4090,16 @@ mod tests {
         }
         .to_uri()
         .to_string();
-        let expected_image_uri = MentionUri::PastedImage.to_uri().to_string();
+        let expected_image_uri = MentionUri::PastedImage {
+            name: image_name.clone(),
+        }
+        .to_uri()
+        .to_string();
 
         editor.update(&mut cx, |editor, cx| {
             assert_eq!(
                 editor.text(cx),
-                format!("[@Image]({expected_image_uri}) [@file.txt]({expected_file_uri}) ")
+                format!("[@{image_name}]({expected_image_uri}) [@file.txt]({expected_file_uri}) ")
             );
         });
 
@@ -4094,7 +4107,7 @@ mod tests {
 
         assert_eq!(contents.len(), 2);
         assert!(contents.iter().any(|(uri, mention)| {
-            *uri == MentionUri::PastedImage && matches!(mention, Mention::Image(_))
+            matches!(uri, MentionUri::PastedImage { .. }) && matches!(mention, Mention::Image(_))
         }));
         assert!(contents.iter().any(|(uri, mention)| {
             *uri == MentionUri::File {

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

@@ -184,7 +184,7 @@ fn open_mention_uri(
         MentionUri::Fetch { url } => {
             cx.open_url(url.as_str());
         }
-        MentionUri::PastedImage
+        MentionUri::PastedImage { .. }
         | MentionUri::Selection { abs_path: None, .. }
         | MentionUri::Diagnostics { .. }
         | MentionUri::TerminalSelection { .. }