agent_ui: Add support for pasting external files and directories (#52300)

Smit Barmase created

This PR adds support to paste external files and directories in Agent
Panel, along with the existing image paste path.

  Release Notes:

  - Added support for pasting files and folders into the Agent Panel.

Change summary

Cargo.lock                                |   1 
crates/agent_ui/Cargo.toml                |   1 
crates/agent_ui/src/mention_set.rs        |  66 +-
crates/agent_ui/src/message_editor.rs     | 730 ++++++++++++++++++++----
crates/agent_ui/src/text_thread_editor.rs |  21 
5 files changed, 643 insertions(+), 176 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -337,7 +337,6 @@ dependencies = [
  "assistant_slash_command",
  "assistant_slash_commands",
  "assistant_text_thread",
- "async-fs",
  "audio",
  "base64 0.22.1",
  "buffer_diff",

crates/agent_ui/Cargo.toml 🔗

@@ -113,7 +113,6 @@ watch.workspace = true
 workspace.workspace = true
 zed_actions.workspace = true
 image.workspace = true
-async-fs.workspace = true
 reqwest_client = { workspace = true, optional = true }
 
 [dev-dependencies]

crates/agent_ui/src/mention_set.rs 🔗

@@ -835,6 +835,36 @@ pub(crate) async fn insert_images_as_context(
     }
 }
 
+fn image_format_from_external_content(format: image::ImageFormat) -> Option<ImageFormat> {
+    match format {
+        image::ImageFormat::Png => Some(ImageFormat::Png),
+        image::ImageFormat::Jpeg => Some(ImageFormat::Jpeg),
+        image::ImageFormat::WebP => Some(ImageFormat::Webp),
+        image::ImageFormat::Gif => Some(ImageFormat::Gif),
+        image::ImageFormat::Bmp => Some(ImageFormat::Bmp),
+        image::ImageFormat::Tiff => Some(ImageFormat::Tiff),
+        image::ImageFormat::Ico => Some(ImageFormat::Ico),
+        _ => None,
+    }
+}
+
+pub(crate) fn load_external_image_from_path(
+    path: &Path,
+    default_name: &SharedString,
+) -> Option<(Image, SharedString)> {
+    let content = std::fs::read(path).ok()?;
+    let format = image::guess_format(&content)
+        .ok()
+        .and_then(image_format_from_external_content)?;
+    let name = path
+        .file_name()
+        .and_then(|name| name.to_str())
+        .map(|name| SharedString::from(name.to_owned()))
+        .unwrap_or_else(|| default_name.clone());
+
+    Some((Image::from_bytes(format, content), name))
+}
+
 pub(crate) fn paste_images_as_context(
     editor: Entity<Editor>,
     mention_set: Entity<MentionSet>,
@@ -869,37 +899,11 @@ pub(crate) fn paste_images_as_context(
         if !paths.is_empty() {
             images.extend(
                 cx.background_spawn(async move {
-                    let mut images = vec![];
-                    for path in paths.into_iter().flat_map(|paths| paths.paths().to_owned()) {
-                        let Ok(content) = async_fs::read(&path).await else {
-                            continue;
-                        };
-                        let Ok(format) = image::guess_format(&content) else {
-                            continue;
-                        };
-                        let name: SharedString = path
-                            .file_name()
-                            .and_then(|n| n.to_str())
-                            .map(|s| SharedString::from(s.to_owned()))
-                            .unwrap_or_else(|| default_name.clone());
-                        images.push((
-                            gpui::Image::from_bytes(
-                                match format {
-                                    image::ImageFormat::Png => gpui::ImageFormat::Png,
-                                    image::ImageFormat::Jpeg => gpui::ImageFormat::Jpeg,
-                                    image::ImageFormat::WebP => gpui::ImageFormat::Webp,
-                                    image::ImageFormat::Gif => gpui::ImageFormat::Gif,
-                                    image::ImageFormat::Bmp => gpui::ImageFormat::Bmp,
-                                    image::ImageFormat::Tiff => gpui::ImageFormat::Tiff,
-                                    image::ImageFormat::Ico => gpui::ImageFormat::Ico,
-                                    _ => continue,
-                                },
-                                content,
-                            ),
-                            name,
-                        ));
-                    }
-                    images
+                    paths
+                        .into_iter()
+                        .flat_map(|paths| paths.paths().to_owned())
+                        .filter_map(|path| load_external_image_from_path(&path, &default_name))
+                        .collect::<Vec<_>>()
                 })
                 .await,
             );

crates/agent_ui/src/message_editor.rs 🔗

@@ -7,9 +7,7 @@ use crate::{
         PromptCompletionProvider, PromptCompletionProviderDelegate, PromptContextAction,
         PromptContextType, SlashCommandCompletion,
     },
-    mention_set::{
-        Mention, MentionImage, MentionSet, insert_crease_for_mention, paste_images_as_context,
-    },
+    mention_set::{Mention, MentionImage, MentionSet, insert_crease_for_mention},
 };
 use acp_thread::MentionUri;
 use agent::ThreadStore;
@@ -28,7 +26,9 @@ use gpui::{
 use language::{Buffer, language_settings::InlayHintKind};
 use parking_lot::RwLock;
 use project::AgentId;
-use project::{CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, Worktree};
+use project::{
+    CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, ProjectPath, Worktree,
+};
 use prompt_store::PromptStore;
 use rope::Point;
 use settings::Settings;
@@ -161,6 +161,236 @@ impl EventEmitter<MessageEditorEvent> for MessageEditor {}
 
 const COMMAND_HINT_INLAY_ID: InlayId = InlayId::Hint(0);
 
+enum MentionInsertPosition {
+    AtCursor,
+    EndOfBuffer,
+}
+
+fn insert_mention_for_project_path(
+    project_path: &ProjectPath,
+    position: MentionInsertPosition,
+    editor: &Entity<Editor>,
+    mention_set: &Entity<MentionSet>,
+    project: &Entity<Project>,
+    workspace: &Entity<Workspace>,
+    supports_images: bool,
+    window: &mut Window,
+    cx: &mut App,
+) -> Option<Task<()>> {
+    let (file_name, mention_uri) = {
+        let project = project.read(cx);
+        let path_style = project.path_style(cx);
+        let entry = project.entry_for_path(project_path, cx)?;
+        let worktree = project.worktree_for_id(project_path.worktree_id, cx)?;
+        let abs_path = worktree.read(cx).absolutize(&project_path.path);
+        let (file_name, _) = crate::completion_provider::extract_file_name_and_directory(
+            &project_path.path,
+            worktree.read(cx).root_name(),
+            path_style,
+        );
+        let mention_uri = if entry.is_dir() {
+            MentionUri::Directory { abs_path }
+        } else {
+            MentionUri::File { abs_path }
+        };
+        (file_name, mention_uri)
+    };
+
+    let mention_text = mention_uri.as_link().to_string();
+    let content_len = mention_text.len();
+
+    let text_anchor = match position {
+        MentionInsertPosition::AtCursor => editor.update(cx, |editor, cx| {
+            let buffer = editor.buffer().read(cx);
+            let snapshot = buffer.snapshot(cx);
+            let (_, _, buffer_snapshot) = snapshot.as_singleton()?;
+            let text_anchor = editor
+                .selections
+                .newest_anchor()
+                .start
+                .text_anchor
+                .bias_left(&buffer_snapshot);
+
+            editor.insert(&mention_text, window, cx);
+            editor.insert(" ", window, cx);
+
+            Some(text_anchor)
+        }),
+        MentionInsertPosition::EndOfBuffer => {
+            let multi_buffer = editor.read(cx).buffer().clone();
+            let buffer = multi_buffer.read(cx).as_singleton()?;
+            let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len()));
+            let new_text = format!("{mention_text} ");
+            editor.update(cx, |editor, cx| {
+                editor.edit(
+                    [(
+                        multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
+                        new_text,
+                    )],
+                    cx,
+                );
+            });
+            Some(anchor)
+        }
+    }?;
+
+    Some(mention_set.update(cx, |mention_set, cx| {
+        mention_set.confirm_mention_completion(
+            file_name,
+            text_anchor,
+            content_len,
+            mention_uri,
+            supports_images,
+            editor.clone(),
+            workspace,
+            window,
+            cx,
+        )
+    }))
+}
+
+enum ResolvedPastedContextItem {
+    Image(gpui::Image, gpui::SharedString),
+    ProjectPath(ProjectPath),
+}
+
+async fn resolve_pasted_context_items(
+    project: Entity<Project>,
+    project_is_local: bool,
+    supports_images: bool,
+    entries: Vec<ClipboardEntry>,
+    cx: &mut gpui::AsyncWindowContext,
+) -> (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();
+
+    for entry in entries {
+        match entry {
+            ClipboardEntry::String(_) => {}
+            ClipboardEntry::Image(image) => {
+                if supports_images {
+                    items.push(ResolvedPastedContextItem::Image(
+                        image,
+                        default_image_name.clone(),
+                    ));
+                }
+            }
+            ClipboardEntry::ExternalPaths(paths) => {
+                for path in paths.paths().iter() {
+                    if let Some((image, name)) = cx
+                        .background_spawn({
+                            let path = path.clone();
+                            let default_image_name = default_image_name.clone();
+                            async move {
+                                crate::mention_set::load_external_image_from_path(
+                                    &path,
+                                    &default_image_name,
+                                )
+                            }
+                        })
+                        .await
+                    {
+                        if supports_images {
+                            items.push(ResolvedPastedContextItem::Image(image, name));
+                        }
+                        continue;
+                    }
+
+                    if !project_is_local {
+                        continue;
+                    }
+
+                    let path = path.clone();
+                    let Ok(resolve_task) = cx.update({
+                        let project = project.clone();
+                        move |_, cx| Workspace::project_path_for_path(project, &path, false, cx)
+                    }) else {
+                        continue;
+                    };
+
+                    if let Some((worktree, project_path)) = resolve_task.await.log_err() {
+                        added_worktrees.push(worktree);
+                        items.push(ResolvedPastedContextItem::ProjectPath(project_path));
+                    }
+                }
+            }
+        }
+    }
+
+    (items, added_worktrees)
+}
+
+fn insert_project_path_as_context(
+    project_path: ProjectPath,
+    editor: Entity<Editor>,
+    mention_set: Entity<MentionSet>,
+    workspace: WeakEntity<Workspace>,
+    supports_images: bool,
+    cx: &mut gpui::AsyncWindowContext,
+) -> Option<Task<()>> {
+    let workspace = workspace.upgrade()?;
+
+    cx.update(move |window, cx| {
+        let project = workspace.read(cx).project().clone();
+        insert_mention_for_project_path(
+            &project_path,
+            MentionInsertPosition::AtCursor,
+            &editor,
+            &mention_set,
+            &project,
+            &workspace,
+            supports_images,
+            window,
+            cx,
+        )
+    })
+    .ok()
+    .flatten()
+}
+
+async fn insert_resolved_pasted_context_items(
+    items: Vec<ResolvedPastedContextItem>,
+    added_worktrees: Vec<Entity<Worktree>>,
+    editor: Entity<Editor>,
+    mention_set: Entity<MentionSet>,
+    workspace: WeakEntity<Workspace>,
+    supports_images: bool,
+    cx: &mut gpui::AsyncWindowContext,
+) {
+    let mut path_mention_tasks = Vec::new();
+
+    for item in items {
+        match item {
+            ResolvedPastedContextItem::Image(image, name) => {
+                crate::mention_set::insert_images_as_context(
+                    vec![(image, name)],
+                    editor.clone(),
+                    mention_set.clone(),
+                    workspace.clone(),
+                    cx,
+                )
+                .await;
+            }
+            ResolvedPastedContextItem::ProjectPath(project_path) => {
+                if let Some(task) = insert_project_path_as_context(
+                    project_path,
+                    editor.clone(),
+                    mention_set.clone(),
+                    workspace.clone(),
+                    supports_images,
+                    cx,
+                ) {
+                    path_mention_tasks.push(task);
+                }
+            }
+        }
+    }
+
+    join_all(path_mention_tasks).await;
+    drop(added_worktrees);
+}
+
 impl MessageEditor {
     pub fn new(
         workspace: WeakEntity<Workspace>,
@@ -859,9 +1089,8 @@ impl MessageEditor {
             }
             return;
         }
-        // Handle text paste with potential markdown mention links.
-        // This must be checked BEFORE paste_images_as_context because that function
-        // returns a task even when there are no images in the clipboard.
+        // Handle text paste with potential markdown mention links before
+        // clipboard context entries so markdown text still pastes as text.
         if let Some(clipboard_text) = cx.read_from_clipboard().and_then(|item| {
             item.entries().iter().find_map(|entry| match entry {
                 ClipboardEntry::String(text) => Some(text.text().to_string()),
@@ -958,30 +1187,7 @@ impl MessageEditor {
             }
         }
 
-        let has_non_text_content = cx
-            .read_from_clipboard()
-            .map(|item| {
-                item.entries().iter().any(|entry| {
-                    matches!(
-                        entry,
-                        ClipboardEntry::Image(_) | ClipboardEntry::ExternalPaths(_)
-                    )
-                })
-            })
-            .unwrap_or(false);
-
-        if self.session_capabilities.read().supports_images()
-            && has_non_text_content
-            && let Some(task) = paste_images_as_context(
-                self.editor.clone(),
-                self.mention_set.clone(),
-                self.workspace.clone(),
-                window,
-                cx,
-            )
-        {
-            cx.stop_propagation();
-            task.detach();
+        if self.handle_pasted_context(window, cx) {
             return;
         }
 
@@ -996,6 +1202,61 @@ impl MessageEditor {
         });
     }
 
+    fn handle_pasted_context(&mut self, window: &mut Window, cx: &mut Context<Self>) -> bool {
+        let Some(clipboard) = cx.read_from_clipboard() else {
+            return false;
+        };
+
+        if matches!(
+            clipboard.entries().first(),
+            Some(ClipboardEntry::String(_)) | None
+        ) {
+            return false;
+        }
+
+        let Some(workspace) = self.workspace.upgrade() else {
+            return false;
+        };
+        let project = workspace.read(cx).project().clone();
+        let project_is_local = project.read(cx).is_local();
+        let supports_images = self.session_capabilities.read().supports_images();
+        if !project_is_local && !supports_images {
+            return false;
+        }
+        let editor = self.editor.clone();
+        let mention_set = self.mention_set.clone();
+        let workspace = self.workspace.clone();
+        let entries = clipboard.into_entries().collect::<Vec<_>>();
+
+        cx.stop_propagation();
+
+        window
+            .spawn(cx, async move |mut cx| {
+                let (items, added_worktrees) = resolve_pasted_context_items(
+                    project,
+                    project_is_local,
+                    supports_images,
+                    entries,
+                    &mut cx,
+                )
+                .await;
+                insert_resolved_pasted_context_items(
+                    items,
+                    added_worktrees,
+                    editor,
+                    mention_set,
+                    workspace,
+                    supports_images,
+                    &mut cx,
+                )
+                .await;
+                Ok::<(), anyhow::Error>(())
+            })
+            .detach_and_log_err(cx);
+
+        true
+    }
+
     pub fn insert_dragged_files(
         &mut self,
         paths: Vec<project::ProjectPath>,
@@ -1007,60 +1268,22 @@ impl MessageEditor {
             return;
         };
         let project = workspace.read(cx).project().clone();
-        let path_style = project.read(cx).path_style(cx);
-        let buffer = self.editor.read(cx).buffer().clone();
-        let Some(buffer) = buffer.read(cx).as_singleton() else {
-            return;
-        };
+        let supports_images = self.session_capabilities.read().supports_images();
         let mut tasks = Vec::new();
         for path in paths {
-            let Some(entry) = project.read(cx).entry_for_path(&path, cx) else {
-                continue;
-            };
-            let Some(worktree) = project.read(cx).worktree_for_id(path.worktree_id, cx) else {
-                continue;
-            };
-            let abs_path = worktree.read(cx).absolutize(&path.path);
-            let (file_name, _) = crate::completion_provider::extract_file_name_and_directory(
-                &path.path,
-                worktree.read(cx).root_name(),
-                path_style,
-            );
-
-            let uri = if entry.is_dir() {
-                MentionUri::Directory { abs_path }
-            } else {
-                MentionUri::File { abs_path }
-            };
-
-            let new_text = format!("{} ", uri.as_link());
-            let content_len = new_text.len() - 1;
-
-            let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len()));
-
-            self.editor.update(cx, |message_editor, cx| {
-                message_editor.edit(
-                    [(
-                        multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
-                        new_text,
-                    )],
-                    cx,
-                );
-            });
-            let supports_images = self.session_capabilities.read().supports_images();
-            tasks.push(self.mention_set.update(cx, |mention_set, cx| {
-                mention_set.confirm_mention_completion(
-                    file_name,
-                    anchor,
-                    content_len,
-                    uri,
-                    supports_images,
-                    self.editor.clone(),
-                    &workspace,
-                    window,
-                    cx,
-                )
-            }));
+            if let Some(task) = insert_mention_for_project_path(
+                &path,
+                MentionInsertPosition::EndOfBuffer,
+                &self.editor,
+                &self.mention_set,
+                &project,
+                &workspace,
+                supports_images,
+                window,
+                cx,
+            ) {
+                tasks.push(task);
+            }
         }
         cx.spawn(async move |_, _| {
             join_all(tasks).await;
@@ -1346,45 +1569,20 @@ impl MessageEditor {
                     _ => return Ok::<(), anyhow::Error>(()),
                 };
 
-                let supported_formats = [
-                    ("png", gpui::ImageFormat::Png),
-                    ("jpg", gpui::ImageFormat::Jpeg),
-                    ("jpeg", gpui::ImageFormat::Jpeg),
-                    ("webp", gpui::ImageFormat::Webp),
-                    ("gif", gpui::ImageFormat::Gif),
-                    ("bmp", gpui::ImageFormat::Bmp),
-                    ("tiff", gpui::ImageFormat::Tiff),
-                    ("tif", gpui::ImageFormat::Tiff),
-                    ("ico", gpui::ImageFormat::Ico),
-                ];
-
-                let mut images = Vec::new();
-                for path in paths {
-                    let extension = path
-                        .extension()
-                        .and_then(|ext| ext.to_str())
-                        .map(|s| s.to_lowercase());
-
-                    let Some(format) = extension.and_then(|ext| {
-                        supported_formats
-                            .iter()
-                            .find(|(e, _)| *e == ext)
-                            .map(|(_, f)| *f)
-                    }) else {
-                        continue;
-                    };
-
-                    let Ok(content) = async_fs::read(&path).await else {
-                        continue;
-                    };
-
-                    let name: gpui::SharedString = path
-                        .file_name()
-                        .and_then(|n| n.to_str())
-                        .map(|s| gpui::SharedString::from(s.to_owned()))
-                        .unwrap_or_else(|| "Image".into());
-                    images.push((gpui::Image::from_bytes(format, content), name));
-                }
+                let default_image_name: SharedString = "Image".into();
+                let images = cx
+                    .background_spawn(async move {
+                        paths
+                            .into_iter()
+                            .filter_map(|path| {
+                                crate::mention_set::load_external_image_from_path(
+                                    &path,
+                                    &default_image_name,
+                                )
+                            })
+                            .collect::<Vec<_>>()
+                    })
+                    .await;
 
                 crate::mention_set::insert_images_as_context(
                     images,
@@ -1771,11 +1969,12 @@ fn find_matching_bracket(text: &str, open: char, close: char) -> Option<usize> {
 
 #[cfg(test)]
 mod tests {
-    use std::{ops::Range, path::Path, sync::Arc};
+    use std::{ops::Range, path::Path, path::PathBuf, sync::Arc};
 
     use acp_thread::MentionUri;
     use agent::{ThreadStore, outline};
     use agent_client_protocol as acp;
+    use base64::Engine as _;
     use editor::{
         AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset, SelectionEffects,
         actions::Paste,
@@ -1784,14 +1983,14 @@ mod tests {
     use fs::FakeFs;
     use futures::StreamExt as _;
     use gpui::{
-        AppContext, ClipboardItem, Entity, EventEmitter, FocusHandle, Focusable, TestAppContext,
-        VisualTestContext,
+        AppContext, ClipboardEntry, ClipboardItem, Entity, EventEmitter, ExternalPaths,
+        FocusHandle, Focusable, TestAppContext, VisualTestContext,
     };
     use language_model::LanguageModelRegistry;
     use lsp::{CompletionContext, CompletionTriggerKind};
     use parking_lot::RwLock;
     use project::{CompletionIntent, Project, ProjectPath};
-    use serde_json::json;
+    use serde_json::{Value, json};
 
     use text::Point;
     use ui::{App, Context, IntoElement, Render, SharedString, Window};
@@ -3819,6 +4018,285 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_paste_external_file_path_inserts_file_mention(cx: &mut TestAppContext) {
+        init_test(cx);
+        let (message_editor, editor, mut cx) =
+            setup_paste_test_message_editor(json!({"file.txt": "content"}), cx).await;
+        paste_external_paths(
+            &message_editor,
+            vec![PathBuf::from(path!("/project/file.txt"))],
+            &mut cx,
+        );
+
+        let expected_uri = MentionUri::File {
+            abs_path: path!("/project/file.txt").into(),
+        }
+        .to_uri()
+        .to_string();
+
+        editor.update(&mut cx, |editor, cx| {
+            assert_eq!(editor.text(cx), format!("[@file.txt]({expected_uri}) "));
+        });
+
+        let contents = mention_contents(&message_editor, &mut cx).await;
+
+        let [(uri, Mention::Text { content, .. })] = contents.as_slice() else {
+            panic!("Unexpected mentions");
+        };
+        assert_eq!(content, "content");
+        assert_eq!(
+            uri,
+            &MentionUri::File {
+                abs_path: path!("/project/file.txt").into(),
+            }
+        );
+    }
+
+    #[gpui::test]
+    async fn test_paste_external_directory_path_inserts_directory_mention(cx: &mut TestAppContext) {
+        init_test(cx);
+        let (message_editor, editor, mut cx) = setup_paste_test_message_editor(
+            json!({
+                "src": {
+                    "main.rs": "fn main() {}\n",
+                }
+            }),
+            cx,
+        )
+        .await;
+        paste_external_paths(
+            &message_editor,
+            vec![PathBuf::from(path!("/project/src"))],
+            &mut cx,
+        );
+
+        let expected_uri = MentionUri::Directory {
+            abs_path: path!("/project/src").into(),
+        }
+        .to_uri()
+        .to_string();
+
+        editor.update(&mut cx, |editor, cx| {
+            assert_eq!(editor.text(cx), format!("[@src]({expected_uri}) "));
+        });
+
+        let contents = mention_contents(&message_editor, &mut cx).await;
+
+        let [(uri, Mention::Link)] = contents.as_slice() else {
+            panic!("Unexpected mentions");
+        };
+        assert_eq!(
+            uri,
+            &MentionUri::Directory {
+                abs_path: path!("/project/src").into(),
+            }
+        );
+    }
+
+    #[gpui::test]
+    async fn test_paste_external_file_path_inserts_at_cursor(cx: &mut TestAppContext) {
+        init_test(cx);
+        let (message_editor, editor, mut cx) =
+            setup_paste_test_message_editor(json!({"file.txt": "content"}), cx).await;
+
+        editor.update_in(&mut cx, |editor, window, cx| {
+            editor.set_text("Hello world", window, cx);
+            editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| {
+                selections.select_ranges([MultiBufferOffset(6)..MultiBufferOffset(6)]);
+            });
+        });
+
+        paste_external_paths(
+            &message_editor,
+            vec![PathBuf::from(path!("/project/file.txt"))],
+            &mut cx,
+        );
+
+        let expected_uri = MentionUri::File {
+            abs_path: path!("/project/file.txt").into(),
+        }
+        .to_uri()
+        .to_string();
+
+        editor.update(&mut cx, |editor, cx| {
+            assert_eq!(
+                editor.text(cx),
+                format!("Hello [@file.txt]({expected_uri}) world")
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_paste_mixed_external_image_without_extension_and_file_path(
+        cx: &mut TestAppContext,
+    ) {
+        init_test(cx);
+        let (message_editor, editor, mut cx) =
+            setup_paste_test_message_editor(json!({"file.txt": "content"}), cx).await;
+
+        message_editor.update(&mut cx, |message_editor, _cx| {
+            message_editor
+                .session_capabilities
+                .write()
+                .set_prompt_capabilities(acp::PromptCapabilities::new().image(true));
+        });
+
+        let temporary_image_path = write_test_png_file(None);
+        paste_external_paths(
+            &message_editor,
+            vec![
+                temporary_image_path.clone(),
+                PathBuf::from(path!("/project/file.txt")),
+            ],
+            &mut cx,
+        );
+
+        std::fs::remove_file(&temporary_image_path).expect("remove temp png");
+
+        let expected_file_uri = MentionUri::File {
+            abs_path: path!("/project/file.txt").into(),
+        }
+        .to_uri()
+        .to_string();
+        let expected_image_uri = MentionUri::PastedImage.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}) ")
+            );
+        });
+
+        let contents = mention_contents(&message_editor, &mut cx).await;
+
+        assert_eq!(contents.len(), 2);
+        assert!(contents.iter().any(|(uri, mention)| {
+            *uri == MentionUri::PastedImage && matches!(mention, Mention::Image(_))
+        }));
+        assert!(contents.iter().any(|(uri, mention)| {
+            *uri == MentionUri::File {
+                abs_path: path!("/project/file.txt").into(),
+            } && matches!(
+                mention,
+                Mention::Text {
+                    content,
+                    tracked_buffers: _,
+                } if content == "content"
+            )
+        }));
+    }
+
+    async fn setup_paste_test_message_editor(
+        project_tree: Value,
+        cx: &mut TestAppContext,
+    ) -> (Entity<MessageEditor>, Entity<Editor>, VisualTestContext) {
+        let app_state = cx.update(AppState::test);
+
+        cx.update(|cx| {
+            editor::init(cx);
+            workspace::init(app_state.clone(), cx);
+        });
+
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(path!("/project"), project_tree)
+            .await;
+
+        let project = Project::test(app_state.fs.clone(), [path!("/project").as_ref()], cx).await;
+        let window =
+            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+        let workspace = window
+            .read_with(cx, |mw, _| mw.workspace().clone())
+            .unwrap();
+
+        let mut cx = VisualTestContext::from_window(window.into(), cx);
+
+        let thread_store = cx.new(|cx| ThreadStore::new(cx));
+
+        let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
+            let workspace_handle = cx.weak_entity();
+            let message_editor = cx.new(|cx| {
+                MessageEditor::new(
+                    workspace_handle,
+                    project.downgrade(),
+                    Some(thread_store),
+                    None,
+                    None,
+                    Default::default(),
+                    "Test Agent".into(),
+                    "Test",
+                    EditorMode::AutoHeight {
+                        max_lines: None,
+                        min_lines: 1,
+                    },
+                    window,
+                    cx,
+                )
+            });
+            workspace.active_pane().update(cx, |pane, cx| {
+                pane.add_item(
+                    Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
+                    true,
+                    true,
+                    None,
+                    window,
+                    cx,
+                );
+            });
+            message_editor.read(cx).focus_handle(cx).focus(window, cx);
+            let editor = message_editor.read(cx).editor().clone();
+            (message_editor, editor)
+        });
+
+        (message_editor, editor, cx)
+    }
+
+    fn paste_external_paths(
+        message_editor: &Entity<MessageEditor>,
+        paths: Vec<PathBuf>,
+        cx: &mut VisualTestContext,
+    ) {
+        cx.write_to_clipboard(ClipboardItem {
+            entries: vec![ClipboardEntry::ExternalPaths(ExternalPaths(paths.into()))],
+        });
+
+        message_editor.update_in(cx, |message_editor, window, cx| {
+            message_editor.paste(&Paste, window, cx);
+        });
+        cx.run_until_parked();
+    }
+
+    async fn mention_contents(
+        message_editor: &Entity<MessageEditor>,
+        cx: &mut VisualTestContext,
+    ) -> Vec<(MentionUri, Mention)> {
+        message_editor
+            .update(cx, |message_editor, cx| {
+                message_editor
+                    .mention_set()
+                    .update(cx, |mention_set, cx| mention_set.contents(false, cx))
+            })
+            .await
+            .unwrap()
+            .into_values()
+            .collect::<Vec<_>>()
+    }
+
+    fn write_test_png_file(extension: Option<&str>) -> PathBuf {
+        let bytes = base64::prelude::BASE64_STANDARD
+            .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==")
+            .expect("decode png");
+        let file_name = match extension {
+            Some(extension) => format!("zed-agent-ui-test-{}.{}", uuid::Uuid::new_v4(), extension),
+            None => format!("zed-agent-ui-test-{}", uuid::Uuid::new_v4()),
+        };
+        let path = std::env::temp_dir().join(file_name);
+        std::fs::write(&path, bytes).expect("write temp png");
+        path
+    }
+
     // Helper that creates a minimal MessageEditor inside a window, returning both
     // the entity and the underlying VisualTestContext so callers can drive updates.
     async fn setup_message_editor(

crates/agent_ui/src/text_thread_editor.rs 🔗

@@ -1,5 +1,6 @@
 use crate::{
     language_model_selector::{LanguageModelSelector, language_model_selector},
+    mention_set::load_external_image_from_path,
     ui::ModelSelectorTooltip,
 };
 use anyhow::Result;
@@ -1900,26 +1901,12 @@ impl TextThreadEditor {
             }
         }
 
+        let default_image_name: SharedString = "Image".into();
         for path in paths {
-            let Ok(content) = std::fs::read(path) else {
+            let Some((image, _)) = load_external_image_from_path(&path, &default_image_name) else {
                 continue;
             };
-            let Ok(format) = image::guess_format(&content) else {
-                continue;
-            };
-            images.push(gpui::Image::from_bytes(
-                match format {
-                    image::ImageFormat::Png => gpui::ImageFormat::Png,
-                    image::ImageFormat::Jpeg => gpui::ImageFormat::Jpeg,
-                    image::ImageFormat::WebP => gpui::ImageFormat::Webp,
-                    image::ImageFormat::Gif => gpui::ImageFormat::Gif,
-                    image::ImageFormat::Bmp => gpui::ImageFormat::Bmp,
-                    image::ImageFormat::Tiff => gpui::ImageFormat::Tiff,
-                    image::ImageFormat::Ico => gpui::ImageFormat::Ico,
-                    _ => continue,
-                },
-                content,
-            ));
+            images.push(image);
         }
 
         // Respect entry priority order — if the first entry is text, the source