Render images in agent threads (#46167)

Richard Feldman , Zed Zippy , and Amp created

Now when the agent reads images (which the tool now explicitly
advertises to agents that it is capable of; previously the tool said it
could only read text even though it can actually read images), we see
them in the thread, and also they are auto-expanded by default so you
can see them when scrolling through:

<img width="725" height="1019" alt="Screenshot 2026-01-06 at 2 57 11โ€ฏPM"
src="https://github.com/user-attachments/assets/5c908bad-48f2-46c2-afaa-7f189a178e05"
/>

This also adds a visual regression test that verifies images render
correctly in the agent thread view.

Unlike our previous visual tests, this one only renders the agent panel,
not the entire Zed window.

The "screenshot" it generates (rendered to a Metal texture) is from
completely mocked/simulated data structures, and looks like this:

<img width="546" height="984" alt="Screenshot 2026-01-06 at 2 54 41โ€ฏPM"
src="https://github.com/user-attachments/assets/89a0921f-59e9-4dfe-94b2-4c3b625a851b"
/>

## Changes

- **New visual test**: `agent_thread_with_image` renders an
`AcpThreadView` containing a tool call with image content (the Zed app
icon)
- **Test infrastructure**: Added `StubAgentServer` helper and required
feature flags for visual testing
- **Test-support API**: Added `expand_tool_call()` method to
`AcpThreadView` to allow expanding tool calls for visual testing
- **Baseline screenshot**: Included baseline image showing the Zed logo
rendered in a tool call output

## How to run

```bash
# Run the visual tests
cargo run -p zed --bin visual_test_runner --features visual-tests

# Update baselines if UI intentionally changed  
UPDATE_BASELINE=1 cargo run -p zed --bin visual_test_runner --features visual-tests
```

Release Notes:
- N/A

---------

Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>
Co-authored-by: Amp <amp@ampcode.com>

Change summary

Cargo.lock                               |   6 
crates/acp_thread/Cargo.toml             |   4 
crates/acp_thread/src/acp_thread.rs      |  77 +
crates/acp_thread/src/connection.rs      |  81 ++
crates/agent/src/tools/read_file_tool.rs |   8 
crates/agent_ui/Cargo.toml               |   3 
crates/agent_ui/src/acp/thread_view.rs   | 106 +++
crates/agent_ui/src/agent_panel.rs       |  34 +
crates/zed/Cargo.toml                    |  12 
crates/zed/src/visual_test_runner.rs     | 867 ++++++++++++++++++-------
docs/src/development/macos.md            |  25 
plans/agent-panel-image-visual-test.md   | 198 +++++
12 files changed, 1,142 insertions(+), 279 deletions(-)

Detailed changes

Cargo.lock ๐Ÿ”—

@@ -10,6 +10,7 @@ dependencies = [
  "agent-client-protocol",
  "agent_settings",
  "anyhow",
+ "base64 0.22.1",
  "buffer_diff",
  "collections",
  "editor",
@@ -17,6 +18,7 @@ dependencies = [
  "file_icons",
  "futures 0.3.31",
  "gpui",
+ "image",
  "indoc",
  "itertools 0.14.0",
  "language",
@@ -338,6 +340,7 @@ dependencies = [
  "assistant_text_thread",
  "async-fs",
  "audio",
+ "base64 0.22.1",
  "buffer_diff",
  "chrono",
  "client",
@@ -20663,10 +20666,13 @@ dependencies = [
 name = "zed"
 version = "0.219.0"
 dependencies = [
+ "acp_thread",
  "acp_tools",
+ "action_log",
  "activity_indicator",
  "agent",
  "agent-client-protocol",
+ "agent_servers",
  "agent_settings",
  "agent_ui",
  "agent_ui_v2",

crates/acp_thread/Cargo.toml ๐Ÿ”—

@@ -13,11 +13,12 @@ path = "src/acp_thread.rs"
 doctest = false
 
 [features]
-test-support = ["gpui/test-support", "project/test-support", "dep:parking_lot"]
+test-support = ["gpui/test-support", "project/test-support", "dep:parking_lot", "dep:image"]
 
 [dependencies]
 action_log.workspace = true
 agent-client-protocol.workspace = true
+base64.workspace = true
 agent_settings.workspace = true
 anyhow.workspace = true
 buffer_diff.workspace = true
@@ -31,6 +32,7 @@ language.workspace = true
 language_model.workspace = true
 markdown.workspace = true
 parking_lot = { workspace = true, optional = true }
+image = { workspace = true, optional = true }
 portable-pty.workspace = true
 project.workspace = true
 prompt_store.workspace = true

crates/acp_thread/src/acp_thread.rs ๐Ÿ”—

@@ -483,6 +483,7 @@ pub enum ContentBlock {
     Empty,
     Markdown { markdown: Entity<Markdown> },
     ResourceLink { resource_link: acp::ResourceLink },
+    Image { image: Arc<gpui::Image> },
 }
 
 impl ContentBlock {
@@ -517,31 +518,52 @@ impl ContentBlock {
         path_style: PathStyle,
         cx: &mut App,
     ) {
-        if matches!(self, ContentBlock::Empty)
-            && let acp::ContentBlock::ResourceLink(resource_link) = block
-        {
-            *self = ContentBlock::ResourceLink { resource_link };
-            return;
-        }
-
-        let new_content = self.block_string_contents(block, path_style);
-
-        match self {
-            ContentBlock::Empty => {
+        match (&mut *self, &block) {
+            (ContentBlock::Empty, acp::ContentBlock::ResourceLink(resource_link)) => {
+                *self = ContentBlock::ResourceLink {
+                    resource_link: resource_link.clone(),
+                };
+            }
+            (ContentBlock::Empty, acp::ContentBlock::Image(image_content)) => {
+                if let Some(image) = Self::decode_image(image_content) {
+                    *self = ContentBlock::Image { image };
+                } else {
+                    let new_content = Self::image_md(image_content);
+                    *self = Self::create_markdown_block(new_content, language_registry, cx);
+                }
+            }
+            (ContentBlock::Empty, _) => {
+                let new_content = Self::block_string_contents(&block, path_style);
                 *self = Self::create_markdown_block(new_content, language_registry, cx);
             }
-            ContentBlock::Markdown { markdown } => {
+            (ContentBlock::Markdown { markdown }, _) => {
+                let new_content = Self::block_string_contents(&block, path_style);
                 markdown.update(cx, |markdown, cx| markdown.append(&new_content, cx));
             }
-            ContentBlock::ResourceLink { resource_link } => {
+            (ContentBlock::ResourceLink { resource_link }, _) => {
                 let existing_content = Self::resource_link_md(&resource_link.uri, path_style);
+                let new_content = Self::block_string_contents(&block, path_style);
                 let combined = format!("{}\n{}", existing_content, new_content);
-
+                *self = Self::create_markdown_block(combined, language_registry, cx);
+            }
+            (ContentBlock::Image { .. }, _) => {
+                let new_content = Self::block_string_contents(&block, path_style);
+                let combined = format!("`Image`\n{}", new_content);
                 *self = Self::create_markdown_block(combined, language_registry, cx);
             }
         }
     }
 
+    fn decode_image(image_content: &acp::ImageContent) -> Option<Arc<gpui::Image>> {
+        use base64::Engine as _;
+
+        let bytes = base64::engine::general_purpose::STANDARD
+            .decode(image_content.data.as_bytes())
+            .ok()?;
+        let format = gpui::ImageFormat::from_mime_type(&image_content.mime_type)?;
+        Some(Arc::new(gpui::Image::from_bytes(format, bytes)))
+    }
+
     fn create_markdown_block(
         content: String,
         language_registry: &Arc<LanguageRegistry>,
@@ -553,9 +575,9 @@ impl ContentBlock {
         }
     }
 
-    fn block_string_contents(&self, block: acp::ContentBlock, path_style: PathStyle) -> String {
+    fn block_string_contents(block: &acp::ContentBlock, path_style: PathStyle) -> String {
         match block {
-            acp::ContentBlock::Text(text_content) => text_content.text,
+            acp::ContentBlock::Text(text_content) => text_content.text.clone(),
             acp::ContentBlock::ResourceLink(resource_link) => {
                 Self::resource_link_md(&resource_link.uri, path_style)
             }
@@ -566,8 +588,8 @@ impl ContentBlock {
                         ..
                     }),
                 ..
-            }) => Self::resource_link_md(&uri, path_style),
-            acp::ContentBlock::Image(image) => Self::image_md(&image),
+            }) => Self::resource_link_md(uri, path_style),
+            acp::ContentBlock::Image(image) => Self::image_md(image),
             _ => String::new(),
         }
     }
@@ -589,6 +611,7 @@ impl ContentBlock {
             ContentBlock::Empty => "",
             ContentBlock::Markdown { markdown } => markdown.read(cx).source(),
             ContentBlock::ResourceLink { resource_link } => &resource_link.uri,
+            ContentBlock::Image { .. } => "`Image`",
         }
     }
 
@@ -597,6 +620,7 @@ impl ContentBlock {
             ContentBlock::Empty => None,
             ContentBlock::Markdown { markdown } => Some(markdown),
             ContentBlock::ResourceLink { .. } => None,
+            ContentBlock::Image { .. } => None,
         }
     }
 
@@ -606,6 +630,13 @@ impl ContentBlock {
             _ => None,
         }
     }
+
+    pub fn image(&self) -> Option<&Arc<gpui::Image>> {
+        match self {
+            ContentBlock::Image { image } => Some(image),
+            _ => None,
+        }
+    }
 }
 
 #[derive(Debug)]
@@ -686,6 +717,13 @@ impl ToolCallContent {
             Self::Terminal(terminal) => terminal.read(cx).to_markdown(cx),
         }
     }
+
+    pub fn image(&self) -> Option<&Arc<gpui::Image>> {
+        match self {
+            Self::ContentBlock(content) => content.image(),
+            _ => None,
+        }
+    }
 }
 
 #[derive(Debug, PartialEq)]
@@ -3774,6 +3812,9 @@ mod tests {
                         ContentBlock::ResourceLink { .. } => {
                             panic!("Expected markdown content, got resource link")
                         }
+                        ContentBlock::Image { .. } => {
+                            panic!("Expected markdown content, got image")
+                        }
                     }
                 } else {
                     panic!("Expected ContentBlock, got: {:?}", tool_call.content[0]);

crates/acp_thread/src/connection.rs ๐Ÿ”—

@@ -284,6 +284,13 @@ impl AgentModelList {
 
 #[cfg(feature = "test-support")]
 mod test_support {
+    //! Test-only stubs and helpers for acp_thread.
+    //!
+    //! This module is gated by the `test-support` feature and is not included
+    //! in production builds. It provides:
+    //! - `StubAgentConnection` for mocking agent connections in tests
+    //! - `create_test_png_base64` for generating test images
+
     use std::sync::Arc;
 
     use action_log::ActionLog;
@@ -294,6 +301,32 @@ mod test_support {
 
     use super::*;
 
+    /// Creates a PNG image encoded as base64 for testing.
+    ///
+    /// Generates a solid-color PNG of the specified dimensions and returns
+    /// it as a base64-encoded string suitable for use in `ImageContent`.
+    pub fn create_test_png_base64(width: u32, height: u32, color: [u8; 4]) -> String {
+        use image::ImageEncoder as _;
+
+        let mut png_data = Vec::new();
+        {
+            let encoder = image::codecs::png::PngEncoder::new(&mut png_data);
+            let mut pixels = Vec::with_capacity((width * height * 4) as usize);
+            for _ in 0..(width * height) {
+                pixels.extend_from_slice(&color);
+            }
+            encoder
+                .write_image(&pixels, width, height, image::ExtendedColorType::Rgba8)
+                .expect("Failed to encode PNG");
+        }
+
+        use image::EncodableLayout as _;
+        base64::Engine::encode(
+            &base64::engine::general_purpose::STANDARD,
+            png_data.as_bytes(),
+        )
+    }
+
     #[derive(Clone, Default)]
     pub struct StubAgentConnection {
         sessions: Arc<Mutex<HashMap<acp::SessionId, Session>>>,
@@ -371,6 +404,13 @@ mod test_support {
             &[]
         }
 
+        fn model_selector(
+            &self,
+            _session_id: &acp::SessionId,
+        ) -> Option<Rc<dyn AgentModelSelector>> {
+            Some(self.model_selector_impl())
+        }
+
         fn new_thread(
             self: Rc<Self>,
             project: Entity<Project>,
@@ -505,6 +545,47 @@ mod test_support {
             Task::ready(Ok(()))
         }
     }
+
+    #[derive(Clone)]
+    struct StubModelSelector {
+        selected_model: Arc<Mutex<AgentModelInfo>>,
+    }
+
+    impl StubModelSelector {
+        fn new() -> Self {
+            Self {
+                selected_model: Arc::new(Mutex::new(AgentModelInfo {
+                    id: acp::ModelId::new("visual-test-model"),
+                    name: "Visual Test Model".into(),
+                    description: Some("A stub model for visual testing".into()),
+                    icon: Some(AgentModelIcon::Named(ui::IconName::ZedAssistant)),
+                })),
+            }
+        }
+    }
+
+    impl AgentModelSelector for StubModelSelector {
+        fn list_models(&self, _cx: &mut App) -> Task<Result<AgentModelList>> {
+            let model = self.selected_model.lock().clone();
+            Task::ready(Ok(AgentModelList::Flat(vec![model])))
+        }
+
+        fn select_model(&self, model_id: acp::ModelId, _cx: &mut App) -> Task<Result<()>> {
+            self.selected_model.lock().id = model_id;
+            Task::ready(Ok(()))
+        }
+
+        fn selected_model(&self, _cx: &mut App) -> Task<Result<AgentModelInfo>> {
+            Task::ready(Ok(self.selected_model.lock().clone()))
+        }
+    }
+
+    impl StubAgentConnection {
+        /// Returns a model selector for this stub connection.
+        pub fn model_selector_impl(&self) -> Rc<dyn AgentModelSelector> {
+            Rc::new(StubModelSelector::new())
+        }
+    }
 }
 
 #[cfg(feature = "test-support")]

crates/agent/src/tools/read_file_tool.rs ๐Ÿ”—

@@ -20,6 +20,8 @@ use crate::{AgentTool, Thread, ToolCallEventStream, outline};
 /// - For large files, this tool returns a file outline with symbol names and line numbers instead of the full content.
 ///   This outline IS a successful response - use the line numbers to read specific sections with start_line/end_line.
 ///   Do NOT retry reading the same file without line numbers if you receive an outline.
+/// - This tool supports reading image files. Supported formats: PNG, JPEG, WebP, GIF, BMP, TIFF.
+///   Image files are returned as visual content that you can analyze directly.
 #[derive(Debug, Serialize, Deserialize, JsonSchema)]
 pub struct ReadFileToolInput {
     /// The relative path of the file to read.
@@ -176,6 +178,12 @@ impl AgentTool for ReadFileTool {
                     .await
                     .context("processing image")?;
 
+                event_stream.update_fields(ToolCallUpdateFields::new().content(vec![
+                    acp::ToolCallContent::Content(acp::Content::new(acp::ContentBlock::Image(
+                        acp::ImageContent::new(language_model_image.source.clone(), "image/png"),
+                    ))),
+                ]));
+
                 Ok(language_model_image.into())
             });
         }

crates/agent_ui/Cargo.toml ๐Ÿ”—

@@ -13,7 +13,7 @@ path = "src/agent_ui.rs"
 doctest = false
 
 [features]
-test-support = ["assistant_text_thread/test-support", "eval_utils", "gpui/test-support", "language/test-support", "reqwest_client", "workspace/test-support", "agent/test-support"]
+test-support = ["assistant_text_thread/test-support", "acp_thread/test-support", "eval_utils", "gpui/test-support", "language/test-support", "reqwest_client", "workspace/test-support", "agent/test-support"]
 unit-eval = []
 
 [dependencies]
@@ -106,6 +106,7 @@ reqwest_client = { workspace = true, optional = true }
 
 [dev-dependencies]
 acp_thread = { workspace = true, features = ["test-support"] }
+base64.workspace = true
 agent = { workspace = true, features = ["test-support"] }
 assistant_text_thread = { workspace = true, features = ["test-support"] }
 buffer_diff = { workspace = true, features = ["test-support"] }

crates/agent_ui/src/acp/thread_view.rs ๐Ÿ”—

@@ -29,9 +29,9 @@ use futures::FutureExt as _;
 use gpui::{
     Action, Animation, AnimationExt, AnyView, App, BorderStyle, ClickEvent, ClipboardItem,
     CursorStyle, EdgesRefinement, ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, Length,
-    ListOffset, ListState, PlatformDisplay, SharedString, StyleRefinement, Subscription, Task,
-    TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, Window, WindowHandle, div,
-    ease_in_out, linear_color_stop, linear_gradient, list, point, pulsating_between,
+    ListOffset, ListState, ObjectFit, PlatformDisplay, SharedString, StyleRefinement, Subscription,
+    Task, TextStyle, TextStyleRefinement, UnderlineStyle, WeakEntity, Window, WindowHandle, div,
+    ease_in_out, img, linear_color_stop, linear_gradient, list, point, pulsating_between,
 };
 use language::Buffer;
 
@@ -2850,11 +2850,11 @@ impl AcpThreadView {
 
         let use_card_layout = needs_confirmation || is_edit || is_terminal_tool;
 
+        let has_image_content = tool_call.content.iter().any(|c| c.image().is_some());
         let is_collapsible = !tool_call.content.is_empty() && !needs_confirmation;
-
         let is_open = needs_confirmation || self.expanded_tool_calls.contains(&tool_call.id);
 
-        let should_show_raw_input = !is_terminal_tool && !is_edit;
+        let should_show_raw_input = !is_terminal_tool && !is_edit && !has_image_content;
 
         let input_output_header = |label: SharedString| {
             Label::new(label)
@@ -2880,6 +2880,7 @@ impl AcpThreadView {
                                         content_ix,
                                         tool_call,
                                         use_card_layout,
+                                        has_image_content,
                                         window,
                                         cx,
                                     ))
@@ -2997,6 +2998,7 @@ impl AcpThreadView {
                                         content_ix,
                                         tool_call,
                                         use_card_layout,
+                                        has_image_content,
                                         window,
                                         cx,
                                     ),
@@ -3236,6 +3238,7 @@ impl AcpThreadView {
         context_ix: usize,
         tool_call: &ToolCall,
         card_layout: bool,
+        is_image_tool_call: bool,
         window: &Window,
         cx: &Context<Self>,
     ) -> AnyElement {
@@ -3252,6 +3255,16 @@ impl AcpThreadView {
                         window,
                         cx,
                     )
+                } else if let Some(image) = content.image() {
+                    let location = tool_call.locations.first().cloned();
+                    self.render_image_output(
+                        entry_ix,
+                        image.clone(),
+                        location,
+                        card_layout,
+                        is_image_tool_call,
+                        cx,
+                    )
                 } else {
                     Empty.into_any_element()
                 }
@@ -3310,6 +3323,79 @@ impl AcpThreadView {
             .into_any_element()
     }
 
+    fn render_image_output(
+        &self,
+        entry_ix: usize,
+        image: Arc<gpui::Image>,
+        location: Option<acp::ToolCallLocation>,
+        card_layout: bool,
+        show_dimensions: bool,
+        cx: &Context<Self>,
+    ) -> AnyElement {
+        let dimensions_label = if show_dimensions {
+            let format_name = match image.format() {
+                gpui::ImageFormat::Png => "PNG",
+                gpui::ImageFormat::Jpeg => "JPEG",
+                gpui::ImageFormat::Webp => "WebP",
+                gpui::ImageFormat::Gif => "GIF",
+                gpui::ImageFormat::Svg => "SVG",
+                gpui::ImageFormat::Bmp => "BMP",
+                gpui::ImageFormat::Tiff => "TIFF",
+                gpui::ImageFormat::Ico => "ICO",
+            };
+            let dimensions = image::ImageReader::new(std::io::Cursor::new(image.bytes()))
+                .with_guessed_format()
+                .ok()
+                .and_then(|reader| reader.into_dimensions().ok());
+            dimensions.map(|(w, h)| format!("{}ร—{} {}", w, h, format_name))
+        } else {
+            None
+        };
+
+        v_flex()
+            .gap_2()
+            .map(|this| {
+                if card_layout {
+                    this
+                } else {
+                    this.ml(rems(0.4))
+                        .px_3p5()
+                        .border_l_1()
+                        .border_color(self.tool_card_border_color(cx))
+                }
+            })
+            .when(dimensions_label.is_some() || location.is_some(), |this| {
+                this.child(
+                    h_flex()
+                        .w_full()
+                        .justify_between()
+                        .items_center()
+                        .children(dimensions_label.map(|label| {
+                            Label::new(label)
+                                .size(LabelSize::XSmall)
+                                .color(Color::Muted)
+                                .buffer_font(cx)
+                        }))
+                        .when_some(location, |this, _loc| {
+                            this.child(
+                                Button::new(("go-to-file", entry_ix), "Go to File")
+                                    .label_size(LabelSize::Small)
+                                    .on_click(cx.listener(move |this, _, window, cx| {
+                                        this.open_tool_call_location(entry_ix, 0, window, cx);
+                                    })),
+                            )
+                        }),
+                )
+            })
+            .child(
+                img(image)
+                    .max_w_96()
+                    .max_h_96()
+                    .object_fit(ObjectFit::ScaleDown),
+            )
+            .into_any_element()
+    }
+
     fn render_resource_link(
         &self,
         resource_link: &acp::ResourceLink,
@@ -6687,6 +6773,16 @@ impl Focusable for AcpThreadView {
     }
 }
 
+#[cfg(any(test, feature = "test-support"))]
+impl AcpThreadView {
+    /// Expands a tool call so its content is visible.
+    /// This is primarily useful for visual testing.
+    pub fn expand_tool_call(&mut self, tool_call_id: acp::ToolCallId, cx: &mut Context<Self>) {
+        self.expanded_tool_calls.insert(tool_call_id);
+        cx.notify();
+    }
+}
+
 impl Render for AcpThreadView {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let has_messages = self.list_state.item_count() > 0;

crates/agent_ui/src/agent_panel.rs ๐Ÿ”—

@@ -2984,3 +2984,37 @@ struct TrialEndUpsell;
 impl Dismissable for TrialEndUpsell {
     const KEY: &'static str = "dismissed-trial-end-upsell";
 }
+
+#[cfg(feature = "test-support")]
+impl AgentPanel {
+    /// Opens an external thread using an arbitrary AgentServer.
+    ///
+    /// This is a test-only helper that allows visual tests and integration tests
+    /// to inject a stub server without modifying production code paths.
+    /// Not compiled into production builds.
+    pub fn open_external_thread_with_server(
+        &mut self,
+        server: Rc<dyn AgentServer>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let workspace = self.workspace.clone();
+        let project = self.project.clone();
+
+        let ext_agent = ExternalAgent::Custom {
+            name: server.name(),
+        };
+
+        self._external_thread(
+            server, None, None, workspace, project, false, ext_agent, window, cx,
+        );
+    }
+
+    /// Returns the currently active thread view, if any.
+    ///
+    /// This is a test-only accessor that exposes the private `active_thread_view()`
+    /// method for test assertions. Not compiled into production builds.
+    pub fn active_thread_view_for_tests(&self) -> Option<&Entity<AcpThreadView>> {
+        self.active_thread_view()
+    }
+}

crates/zed/Cargo.toml ๐Ÿ”—

@@ -30,12 +30,21 @@ visual-tests = [
     "dep:image",
     "dep:semver",
     "dep:tempfile",
+    "dep:acp_thread",
+    "dep:action_log",
+    "dep:agent_servers",
     "workspace/test-support",
     "project/test-support",
     "editor/test-support",
     "terminal_view/test-support",
     "image_viewer/test-support",
     "clock/test-support",
+    "acp_thread/test-support",
+    "agent_ui/test-support",
+    "db/test-support",
+    "agent/test-support",
+    "language_model/test-support",
+    "fs/test-support",
 ]
 
 [[bin]]
@@ -107,6 +116,9 @@ image = { workspace = true, optional = true }
 semver = { workspace = true, optional = true }
 tempfile = { workspace = true, optional = true }
 clock = { workspace = true, optional = true }
+acp_thread = { workspace = true, optional = true }
+action_log = { workspace = true, optional = true }
+agent_servers = { workspace = true, optional = true }
 gpui_tokio.workspace = true
 rayon.workspace = true
 

crates/zed/src/visual_test_runner.rs ๐Ÿ”—

@@ -26,19 +26,29 @@
 
 use anyhow::{Context, Result};
 use gpui::{
-    AppContext as _, Application, Bounds, Window, WindowBounds, WindowHandle, WindowOptions, point,
-    px, size,
+    App, AppContext as _, Application, Bounds, Window, WindowBounds, WindowHandle, WindowOptions,
+    point, px, size,
 };
 use image::RgbaImage;
 use project_panel::ProjectPanel;
 use settings::SettingsStore;
+use std::any::Any;
 use std::path::{Path, PathBuf};
+use std::rc::Rc;
 use std::sync::Arc;
 use workspace::{AppState, Workspace};
 
+use acp_thread::{AgentConnection, StubAgentConnection};
+use agent_client_protocol as acp;
+use agent_servers::{AgentServer, AgentServerDelegate};
+use gpui::SharedString;
+
 /// Baseline images are stored relative to this file
 const BASELINE_DIR: &str = "crates/zed/test_fixtures/visual_tests";
 
+/// Embedded test image (Zed app icon) for visual tests.
+const EMBEDDED_TEST_IMAGE: &[u8] = include_bytes!("../resources/app-icon.png");
+
 /// Threshold for image comparison (0.0 to 1.0)
 /// Images must match at least this percentage to pass
 const MATCH_THRESHOLD: f64 = 0.99;
@@ -67,280 +77,315 @@ fn main() {
 
     let test_result = std::panic::catch_unwind(|| {
         let project_path = project_path;
-        Application::new().run(move |cx| {
-            // Initialize settings store first (required by theme and other subsystems)
-            let settings_store = SettingsStore::test(cx);
-            cx.set_global(settings_store);
-
-            // Create AppState using the production-like initialization
-            let app_state = init_app_state(cx);
-
-            // Initialize all Zed subsystems
-            gpui_tokio::init(cx);
-            theme::init(theme::LoadThemes::JustBase, cx);
-            client::init(&app_state.client, cx);
-            audio::init(cx);
-            workspace::init(app_state.clone(), cx);
-            release_channel::init(semver::Version::new(0, 0, 0), cx);
-            command_palette::init(cx);
-            editor::init(cx);
-            call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
-            title_bar::init(cx);
-            project_panel::init(cx);
-            outline_panel::init(cx);
-            terminal_view::init(cx);
-            image_viewer::init(cx);
-            search::init(cx);
-
-            // Open a real Zed workspace window
-            let window_size = size(px(1280.0), px(800.0));
-            // Window can be hidden since we use direct texture capture (reading pixels from
-            // Metal texture) instead of ScreenCaptureKit which requires visible windows.
-            let bounds = Bounds {
-                origin: point(px(0.0), px(0.0)),
-                size: window_size,
-            };
-
-            // Create a project for the workspace
-            let project = project::Project::local(
-                app_state.client.clone(),
-                app_state.node_runtime.clone(),
-                app_state.user_store.clone(),
-                app_state.languages.clone(),
-                app_state.fs.clone(),
-                None,
-                false,
-                cx,
-            );
-
-            let workspace_window: WindowHandle<Workspace> = cx
-                .open_window(
-                    WindowOptions {
-                        window_bounds: Some(WindowBounds::Windowed(bounds)),
-                        focus: false,
-                        show: false,
-                        ..Default::default()
-                    },
-                    |window, cx| {
-                        cx.new(|cx| {
-                            Workspace::new(None, project.clone(), app_state.clone(), window, cx)
+        Application::new()
+            .with_assets(assets::Assets)
+            .run(move |cx| {
+                // Initialize settings store first (required by theme and other subsystems)
+                let settings_store = SettingsStore::test(cx);
+                cx.set_global(settings_store);
+
+                // Create AppState using the production-like initialization
+                let app_state = init_app_state(cx);
+
+                // Initialize all Zed subsystems
+                gpui_tokio::init(cx);
+                theme::init(theme::LoadThemes::JustBase, cx);
+                client::init(&app_state.client, cx);
+                audio::init(cx);
+                workspace::init(app_state.clone(), cx);
+                release_channel::init(semver::Version::new(0, 0, 0), cx);
+                command_palette::init(cx);
+                editor::init(cx);
+                call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
+                title_bar::init(cx);
+                project_panel::init(cx);
+                outline_panel::init(cx);
+                terminal_view::init(cx);
+                image_viewer::init(cx);
+                search::init(cx);
+                prompt_store::init(cx);
+                language_model::init(app_state.client.clone(), cx);
+                language_models::init(app_state.user_store.clone(), app_state.client.clone(), cx);
+
+                // Open a real Zed workspace window
+                let window_size = size(px(1280.0), px(800.0));
+                // Window can be hidden since we use direct texture capture (reading pixels from
+                // Metal texture) instead of ScreenCaptureKit which requires visible windows.
+                let bounds = Bounds {
+                    origin: point(px(0.0), px(0.0)),
+                    size: window_size,
+                };
+
+                // Create a project for the workspace
+                let project = project::Project::local(
+                    app_state.client.clone(),
+                    app_state.node_runtime.clone(),
+                    app_state.user_store.clone(),
+                    app_state.languages.clone(),
+                    app_state.fs.clone(),
+                    None,
+                    false,
+                    cx,
+                );
+
+                let workspace_window: WindowHandle<Workspace> = cx
+                    .open_window(
+                        WindowOptions {
+                            window_bounds: Some(WindowBounds::Windowed(bounds)),
+                            focus: false,
+                            show: false,
+                            ..Default::default()
+                        },
+                        |window, cx| {
+                            cx.new(|cx| {
+                                Workspace::new(None, project.clone(), app_state.clone(), window, cx)
+                            })
+                        },
+                    )
+                    .expect("Failed to open workspace window");
+
+                // Add the test project as a worktree directly to the project
+                let add_worktree_task = workspace_window
+                    .update(cx, |workspace, _window, cx| {
+                        workspace.project().update(cx, |project, cx| {
+                            project.find_or_create_worktree(&project_path, true, cx)
                         })
-                    },
-                )
-                .expect("Failed to open workspace window");
-
-            // Add the test project as a worktree directly to the project
-            let add_worktree_task = workspace_window
-                .update(cx, |workspace, _window, cx| {
-                    workspace.project().update(cx, |project, cx| {
-                        project.find_or_create_worktree(&project_path, true, cx)
                     })
-                })
-                .expect("Failed to update workspace");
+                    .expect("Failed to update workspace");
 
-            // Spawn async task to set up the UI and capture screenshot
-            cx.spawn(async move |mut cx| {
-                // Wait for the worktree to be added
-                if let Err(e) = add_worktree_task.await {
-                    eprintln!("Failed to add worktree: {:?}", e);
-                }
+                // Clone app_state for the async block
+                let app_state_for_tests = app_state.clone();
 
-                // Wait for UI to settle
-                cx.background_executor()
-                    .timer(std::time::Duration::from_millis(500))
-                    .await;
+                // Spawn async task to set up the UI and capture screenshot
+                cx.spawn(async move |mut cx| {
+                    // Wait for the worktree to be added
+                    if let Err(e) = add_worktree_task.await {
+                        eprintln!("Failed to add worktree: {:?}", e);
+                    }
 
-                // Create and add the project panel to the workspace
-                let panel_task = cx.update(|cx| {
-                    workspace_window
-                        .update(cx, |_workspace, window, cx| {
-                            let weak_workspace = cx.weak_entity();
-                            window.spawn(cx, async move |cx| {
-                                ProjectPanel::load(weak_workspace, cx.clone()).await
-                            })
-                        })
-                        .ok()
-                });
-
-                if let Ok(Some(task)) = panel_task {
-                    if let Ok(panel) = task.await {
-                        cx.update(|cx| {
-                            workspace_window
-                                .update(cx, |workspace, window, cx| {
-                                    workspace.add_panel(panel, window, cx);
+                    // Wait for UI to settle
+                    cx.background_executor()
+                        .timer(std::time::Duration::from_millis(500))
+                        .await;
+
+                    // Create and add the project panel to the workspace
+                    let panel_task = cx.update(|cx| {
+                        workspace_window
+                            .update(cx, |_workspace, window, cx| {
+                                let weak_workspace = cx.weak_entity();
+                                window.spawn(cx, async move |cx| {
+                                    ProjectPanel::load(weak_workspace, cx.clone()).await
                                 })
-                                .ok();
-                        })
-                        .ok();
+                            })
+                            .ok()
+                    });
+
+                    if let Ok(Some(task)) = panel_task {
+                        if let Ok(panel) = task.await {
+                            cx.update(|cx| {
+                                workspace_window
+                                    .update(cx, |workspace, window, cx| {
+                                        workspace.add_panel(panel, window, cx);
+                                    })
+                                    .ok();
+                            })
+                            .ok();
+                        }
                     }
-                }
 
-                // Wait for panel to be added
-                cx.background_executor()
-                    .timer(std::time::Duration::from_millis(500))
-                    .await;
+                    // Wait for panel to be added
+                    cx.background_executor()
+                        .timer(std::time::Duration::from_millis(500))
+                        .await;
 
-                // Open the project panel
-                cx.update(|cx| {
-                    workspace_window
-                        .update(cx, |workspace, window, cx| {
-                            workspace.open_panel::<ProjectPanel>(window, cx);
-                        })
-                        .ok();
-                })
-                .ok();
+                    // Open the project panel
+                    cx.update(|cx| {
+                        workspace_window
+                            .update(cx, |workspace, window, cx| {
+                                workspace.open_panel::<ProjectPanel>(window, cx);
+                            })
+                            .ok();
+                    })
+                    .ok();
+
+                    // Wait for project panel to render
+                    cx.background_executor()
+                        .timer(std::time::Duration::from_millis(500))
+                        .await;
+
+                    // Open main.rs in the editor
+                    let open_file_task = cx.update(|cx| {
+                        workspace_window
+                            .update(cx, |workspace, window, cx| {
+                                let worktree = workspace.project().read(cx).worktrees(cx).next();
+                                if let Some(worktree) = worktree {
+                                    let worktree_id = worktree.read(cx).id();
+                                    let rel_path: std::sync::Arc<util::rel_path::RelPath> =
+                                        util::rel_path::rel_path("src/main.rs").into();
+                                    let project_path: project::ProjectPath =
+                                        (worktree_id, rel_path).into();
+                                    Some(workspace.open_path(project_path, None, true, window, cx))
+                                } else {
+                                    None
+                                }
+                            })
+                            .ok()
+                            .flatten()
+                    });
+
+                    if let Ok(Some(task)) = open_file_task {
+                        if let Ok(item) = task.await {
+                            // Focus the opened item to dismiss the welcome screen
+                            cx.update(|cx| {
+                                workspace_window
+                                    .update(cx, |workspace, window, cx| {
+                                        let pane = workspace.active_pane().clone();
+                                        pane.update(cx, |pane, cx| {
+                                            if let Some(index) = pane.index_for_item(item.as_ref())
+                                            {
+                                                pane.activate_item(index, true, true, window, cx);
+                                            }
+                                        });
+                                    })
+                                    .ok();
+                            })
+                            .ok();
 
-                // Wait for project panel to render
-                cx.background_executor()
-                    .timer(std::time::Duration::from_millis(500))
-                    .await;
+                            // Wait for item activation to render
+                            cx.background_executor()
+                                .timer(std::time::Duration::from_millis(500))
+                                .await;
+                        }
+                    }
 
-                // Open main.rs in the editor
-                let open_file_task = cx.update(|cx| {
-                    workspace_window
-                        .update(cx, |workspace, window, cx| {
-                            let worktree = workspace.project().read(cx).worktrees(cx).next();
-                            if let Some(worktree) = worktree {
-                                let worktree_id = worktree.read(cx).id();
-                                let rel_path: std::sync::Arc<util::rel_path::RelPath> =
-                                    util::rel_path::rel_path("src/main.rs").into();
-                                let project_path: project::ProjectPath =
-                                    (worktree_id, rel_path).into();
-                                Some(workspace.open_path(project_path, None, true, window, cx))
-                            } else {
-                                None
-                            }
-                        })
-                        .ok()
-                        .flatten()
-                });
-
-                if let Ok(Some(task)) = open_file_task {
-                    if let Ok(item) = task.await {
-                        // Focus the opened item to dismiss the welcome screen
-                        cx.update(|cx| {
-                            workspace_window
-                                .update(cx, |workspace, window, cx| {
-                                    let pane = workspace.active_pane().clone();
-                                    pane.update(cx, |pane, cx| {
-                                        if let Some(index) = pane.index_for_item(item.as_ref()) {
-                                            pane.activate_item(index, true, true, window, cx);
-                                        }
-                                    });
-                                })
-                                .ok();
-                        })
-                        .ok();
+                    // Request a window refresh to ensure all pending effects are processed
+                    cx.refresh().ok();
+
+                    // Wait for UI to fully stabilize
+                    cx.background_executor()
+                        .timer(std::time::Duration::from_secs(2))
+                        .await;
+
+                    // Track test results
+                    let mut passed = 0;
+                    let mut failed = 0;
+                    let mut updated = 0;
+
+                    // Run Test 1: Project Panel (with project panel visible)
+                    println!("\n--- Test 1: project_panel ---");
+                    let test_result = run_visual_test(
+                        "project_panel",
+                        workspace_window.into(),
+                        &mut cx,
+                        update_baseline,
+                    )
+                    .await;
 
-                        // Wait for item activation to render
-                        cx.background_executor()
-                            .timer(std::time::Duration::from_millis(500))
-                            .await;
+                    match test_result {
+                        Ok(TestResult::Passed) => {
+                            println!("โœ“ project_panel: PASSED");
+                            passed += 1;
+                        }
+                        Ok(TestResult::BaselineUpdated(path)) => {
+                            println!("โœ“ project_panel: Baseline updated at {}", path.display());
+                            updated += 1;
+                        }
+                        Err(e) => {
+                            eprintln!("โœ— project_panel: FAILED - {}", e);
+                            failed += 1;
+                        }
                     }
-                }
-
-                // Request a window refresh to ensure all pending effects are processed
-                cx.refresh().ok();
 
-                // Wait for UI to fully stabilize
-                cx.background_executor()
-                    .timer(std::time::Duration::from_secs(2))
+                    // Close the project panel for the second test
+                    cx.update(|cx| {
+                        workspace_window
+                            .update(cx, |workspace, window, cx| {
+                                workspace.close_panel::<ProjectPanel>(window, cx);
+                            })
+                            .ok();
+                    })
+                    .ok();
+
+                    // Refresh and wait for panel to close
+                    cx.refresh().ok();
+                    cx.background_executor()
+                        .timer(std::time::Duration::from_millis(500))
+                        .await;
+
+                    // Run Test 2: Workspace with Editor (without project panel)
+                    println!("\n--- Test 2: workspace_with_editor ---");
+                    let test_result = run_visual_test(
+                        "workspace_with_editor",
+                        workspace_window.into(),
+                        &mut cx,
+                        update_baseline,
+                    )
                     .await;
 
-                // Track test results
-                let mut passed = 0;
-                let mut failed = 0;
-                let mut updated = 0;
-
-                // Run Test 1: Project Panel (with project panel visible)
-                println!("\n--- Test 1: project_panel ---");
-                let test_result = run_visual_test(
-                    "project_panel",
-                    workspace_window.into(),
-                    &mut cx,
-                    update_baseline,
-                )
-                .await;
-
-                match test_result {
-                    Ok(TestResult::Passed) => {
-                        println!("โœ“ project_panel: PASSED");
-                        passed += 1;
-                    }
-                    Ok(TestResult::BaselineUpdated(path)) => {
-                        println!("โœ“ project_panel: Baseline updated at {}", path.display());
-                        updated += 1;
+                    match test_result {
+                        Ok(TestResult::Passed) => {
+                            println!("โœ“ workspace_with_editor: PASSED");
+                            passed += 1;
+                        }
+                        Ok(TestResult::BaselineUpdated(path)) => {
+                            println!(
+                                "โœ“ workspace_with_editor: Baseline updated at {}",
+                                path.display()
+                            );
+                            updated += 1;
+                        }
+                        Err(e) => {
+                            eprintln!("โœ— workspace_with_editor: FAILED - {}", e);
+                            failed += 1;
+                        }
                     }
-                    Err(e) => {
-                        eprintln!("โœ— project_panel: FAILED - {}", e);
-                        failed += 1;
-                    }
-                }
-
-                // Close the project panel for the second test
-                cx.update(|cx| {
-                    workspace_window
-                        .update(cx, |workspace, window, cx| {
-                            workspace.close_panel::<ProjectPanel>(window, cx);
-                        })
-                        .ok();
-                })
-                .ok();
 
-                // Refresh and wait for panel to close
-                cx.refresh().ok();
-                cx.background_executor()
-                    .timer(std::time::Duration::from_millis(500))
+                    // Run Test 3: Agent Thread View with Image (collapsed and expanded)
+                    println!("\n--- Test 3: agent_thread_with_image (collapsed + expanded) ---");
+                    let test_result = run_agent_thread_view_test(
+                        app_state_for_tests.clone(),
+                        &mut cx,
+                        update_baseline,
+                    )
                     .await;
 
-                // Run Test 2: Workspace with Editor (without project panel)
-                println!("\n--- Test 2: workspace_with_editor ---");
-                let test_result = run_visual_test(
-                    "workspace_with_editor",
-                    workspace_window.into(),
-                    &mut cx,
-                    update_baseline,
-                )
-                .await;
-
-                match test_result {
-                    Ok(TestResult::Passed) => {
-                        println!("โœ“ workspace_with_editor: PASSED");
-                        passed += 1;
-                    }
-                    Ok(TestResult::BaselineUpdated(path)) => {
-                        println!(
-                            "โœ“ workspace_with_editor: Baseline updated at {}",
-                            path.display()
-                        );
-                        updated += 1;
+                    match test_result {
+                        Ok(TestResult::Passed) => {
+                            println!("โœ“ agent_thread_with_image (collapsed + expanded): PASSED");
+                            passed += 1;
+                        }
+                        Ok(TestResult::BaselineUpdated(_)) => {
+                            println!(
+                                "โœ“ agent_thread_with_image: Baselines updated (collapsed + expanded)"
+                            );
+                            updated += 1;
+                        }
+                        Err(e) => {
+                            eprintln!("โœ— agent_thread_with_image: FAILED - {}", e);
+                            failed += 1;
+                        }
                     }
-                    Err(e) => {
-                        eprintln!("โœ— workspace_with_editor: FAILED - {}", e);
-                        failed += 1;
+
+                    // Print summary
+                    println!("\n=== Test Summary ===");
+                    println!("Passed: {}", passed);
+                    println!("Failed: {}", failed);
+                    if updated > 0 {
+                        println!("Baselines Updated: {}", updated);
                     }
-                }
 
-                // Print summary
-                println!("\n=== Test Summary ===");
-                println!("Passed: {}", passed);
-                println!("Failed: {}", failed);
-                if updated > 0 {
-                    println!("Baselines Updated: {}", updated);
-                }
+                    if failed > 0 {
+                        eprintln!("\n=== Visual Tests FAILED ===");
+                        cx.update(|cx| cx.quit()).ok();
+                        std::process::exit(1);
+                    } else {
+                        println!("\n=== All Visual Tests PASSED ===");
+                    }
 
-                if failed > 0 {
-                    eprintln!("\n=== Visual Tests FAILED ===");
                     cx.update(|cx| cx.quit()).ok();
-                    std::process::exit(1);
-                } else {
-                    println!("\n=== All Visual Tests PASSED ===");
-                }
-
-                cx.update(|cx| cx.quit()).ok();
-            })
-            .detach();
-        });
+                })
+                .detach();
+            });
     });
 
     // Keep temp_dir alive until we're done
@@ -693,3 +738,323 @@ fn init_app_state(cx: &mut gpui::App) -> Arc<AppState> {
         session,
     })
 }
+
+/// A stub AgentServer for visual testing that returns a pre-programmed connection.
+#[derive(Clone)]
+struct StubAgentServer {
+    connection: StubAgentConnection,
+}
+
+impl StubAgentServer {
+    fn new(connection: StubAgentConnection) -> Self {
+        Self { connection }
+    }
+}
+
+impl AgentServer for StubAgentServer {
+    fn logo(&self) -> ui::IconName {
+        ui::IconName::ZedAssistant
+    }
+
+    fn name(&self) -> SharedString {
+        "Visual Test Agent".into()
+    }
+
+    fn connect(
+        &self,
+        _root_dir: Option<&Path>,
+        _delegate: AgentServerDelegate,
+        _cx: &mut App,
+    ) -> gpui::Task<gpui::Result<(Rc<dyn AgentConnection>, Option<task::SpawnInTerminal>)>> {
+        gpui::Task::ready(Ok((Rc::new(self.connection.clone()), None)))
+    }
+
+    fn into_any(self: Rc<Self>) -> Rc<dyn Any> {
+        self
+    }
+}
+
+/// Runs the agent panel visual test with full UI chrome.
+/// This test actually runs the real ReadFileTool to capture image output.
+async fn run_agent_thread_view_test(
+    app_state: Arc<AppState>,
+    cx: &mut gpui::AsyncApp,
+    update_baseline: bool,
+) -> Result<TestResult> {
+    use agent::AgentTool;
+    use agent_ui::AgentPanel;
+
+    // Create a temporary directory with the test image using real filesystem
+    let temp_dir = tempfile::tempdir()?;
+    let project_path = temp_dir.path().join("project");
+    std::fs::create_dir_all(&project_path)?;
+    let image_path = project_path.join("test-image.png");
+    std::fs::write(&image_path, EMBEDDED_TEST_IMAGE)?;
+
+    // Create a project with the real filesystem containing the test image
+    let project = cx.update(|cx| {
+        project::Project::local(
+            app_state.client.clone(),
+            app_state.node_runtime.clone(),
+            app_state.user_store.clone(),
+            app_state.languages.clone(),
+            app_state.fs.clone(),
+            None,
+            false,
+            cx,
+        )
+    })?;
+
+    // Add the test directory as a worktree
+    let add_worktree_task = project.update(cx, |project, cx| {
+        project.find_or_create_worktree(&project_path, true, cx)
+    })?;
+    let (worktree, _) = add_worktree_task.await?;
+
+    // Wait for worktree to scan and find the image file
+    let worktree_name = worktree.read_with(cx, |wt, _| wt.root_name_str().to_string())?;
+
+    // Wait for worktree to be fully scanned
+    cx.background_executor()
+        .timer(std::time::Duration::from_millis(100))
+        .await;
+
+    // Create the necessary entities for the ReadFileTool
+    let action_log = cx.new(|_| action_log::ActionLog::new(project.clone()))?;
+    let context_server_registry = cx
+        .new(|cx| agent::ContextServerRegistry::new(project.read(cx).context_server_store(), cx))?;
+    let fake_model = Arc::new(language_model::fake_provider::FakeLanguageModel::default());
+    let project_context = cx.new(|_| prompt_store::ProjectContext::default())?;
+
+    // Create the agent Thread
+    let thread = cx.new(|cx| {
+        agent::Thread::new(
+            project.clone(),
+            project_context,
+            context_server_registry,
+            agent::Templates::new(),
+            Some(fake_model),
+            cx,
+        )
+    })?;
+
+    // Create the ReadFileTool
+    let tool = Arc::new(agent::ReadFileTool::new(
+        thread.downgrade(),
+        project.clone(),
+        action_log,
+    ));
+
+    // Create a test event stream to capture tool output
+    let (event_stream, mut event_receiver) = agent::ToolCallEventStream::test();
+
+    // Run the real ReadFileTool to get the actual image content
+    // The path is relative to the worktree root name
+    let input = agent::ReadFileToolInput {
+        path: format!("{}/test-image.png", worktree_name),
+        start_line: None,
+        end_line: None,
+    };
+    let run_task = cx.update(|cx| tool.clone().run(input, event_stream, cx))?;
+
+    // The tool runs async - wait for it
+    run_task.await?;
+
+    // Collect the events from the tool execution
+    let mut tool_content: Vec<acp::ToolCallContent> = Vec::new();
+    let mut tool_locations: Vec<acp::ToolCallLocation> = Vec::new();
+
+    while let Ok(Some(event)) = event_receiver.try_next() {
+        if let Ok(agent::ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields(
+            update,
+        ))) = event
+        {
+            if let Some(content) = update.fields.content {
+                tool_content.extend(content);
+            }
+            if let Some(locations) = update.fields.locations {
+                tool_locations.extend(locations);
+            }
+        }
+    }
+
+    // Verify we got image content from the real tool
+    if tool_content.is_empty() {
+        return Err(anyhow::anyhow!(
+            "ReadFileTool did not produce any content - the tool is broken!"
+        ));
+    }
+
+    // Create stub connection with the REAL tool output
+    let connection = StubAgentConnection::new();
+    connection.set_next_prompt_updates(vec![acp::SessionUpdate::ToolCall(
+        acp::ToolCall::new(
+            "read_file",
+            format!("Read file `{}/test-image.png`", worktree_name),
+        )
+        .kind(acp::ToolKind::Read)
+        .status(acp::ToolCallStatus::Completed)
+        .locations(tool_locations)
+        .content(tool_content),
+    )]);
+
+    let stub_agent: Rc<dyn AgentServer> = Rc::new(StubAgentServer::new(connection.clone()));
+
+    // Create a window sized for the agent panel (500x900)
+    let window_size = size(px(500.0), px(900.0));
+    let bounds = Bounds {
+        origin: point(px(0.0), px(0.0)),
+        size: window_size,
+    };
+
+    // Create a workspace window
+    let workspace_window: WindowHandle<Workspace> = cx.update(|cx| {
+        cx.open_window(
+            WindowOptions {
+                window_bounds: Some(WindowBounds::Windowed(bounds)),
+                focus: false,
+                show: false,
+                ..Default::default()
+            },
+            |window, cx| {
+                cx.new(|cx| Workspace::new(None, project.clone(), app_state.clone(), window, cx))
+            },
+        )
+    })??;
+
+    // Wait for workspace to initialize
+    cx.background_executor()
+        .timer(std::time::Duration::from_millis(100))
+        .await;
+
+    // Load the AgentPanel
+    let panel_task = workspace_window.update(cx, |_workspace, window, cx| {
+        let weak_workspace = cx.weak_entity();
+        let prompt_builder = prompt_store::PromptBuilder::load(app_state.fs.clone(), false, cx);
+        let async_window_cx = window.to_async(cx);
+        AgentPanel::load(weak_workspace, prompt_builder, async_window_cx)
+    })?;
+
+    let panel = panel_task.await?;
+
+    // Add the panel to the workspace
+    workspace_window.update(cx, |workspace, window, cx| {
+        workspace.add_panel(panel.clone(), window, cx);
+        workspace.open_panel::<AgentPanel>(window, cx);
+    })?;
+
+    // Wait for panel to be ready
+    cx.background_executor()
+        .timer(std::time::Duration::from_millis(200))
+        .await;
+
+    // Inject the stub server and open the stub thread
+    workspace_window.update(cx, |_workspace, window, cx| {
+        panel.update(cx, |panel, cx| {
+            panel.open_external_thread_with_server(stub_agent.clone(), window, cx);
+        });
+    })?;
+
+    // Wait for thread view to initialize
+    cx.background_executor()
+        .timer(std::time::Duration::from_millis(200))
+        .await;
+
+    // Get the thread view and send a message
+    let thread_view = panel
+        .read_with(cx, |panel, _| panel.active_thread_view_for_tests().cloned())?
+        .ok_or_else(|| anyhow::anyhow!("No active thread view"))?;
+
+    let thread = thread_view
+        .update(cx, |view, _cx| view.thread().cloned())?
+        .ok_or_else(|| anyhow::anyhow!("Thread not available"))?;
+
+    // Send the message to trigger the image response
+    thread
+        .update(cx, |thread, cx| thread.send_raw("Show me the Zed logo", cx))?
+        .await?;
+
+    // Wait for response to be processed
+    cx.background_executor()
+        .timer(std::time::Duration::from_millis(500))
+        .await;
+
+    // Get the tool call ID for expanding later
+    let tool_call_id = thread
+        .update(cx, |thread, _cx| {
+            thread.entries().iter().find_map(|entry| {
+                if let acp_thread::AgentThreadEntry::ToolCall(tool_call) = entry {
+                    Some(tool_call.id.clone())
+                } else {
+                    None
+                }
+            })
+        })?
+        .ok_or_else(|| anyhow::anyhow!("Expected a ToolCall entry in thread for visual test"))?;
+
+    // Refresh window for collapsed state
+    cx.update_window(
+        workspace_window.into(),
+        |_view, window: &mut Window, _cx| {
+            window.refresh();
+        },
+    )?;
+
+    cx.background_executor()
+        .timer(std::time::Duration::from_millis(300))
+        .await;
+
+    // First, capture the COLLAPSED state (image tool call not expanded)
+    let collapsed_result = run_visual_test(
+        "agent_thread_with_image_collapsed",
+        workspace_window.into(),
+        cx,
+        update_baseline,
+    )
+    .await?;
+
+    // Now expand the tool call so its content (the image) is visible
+    thread_view.update(cx, |view, cx| {
+        view.expand_tool_call(tool_call_id, cx);
+    })?;
+
+    // Wait for UI to update
+    cx.background_executor()
+        .timer(std::time::Duration::from_millis(300))
+        .await;
+
+    // Refresh window for expanded state
+    cx.update_window(
+        workspace_window.into(),
+        |_view, window: &mut Window, _cx| {
+            window.refresh();
+        },
+    )?;
+
+    cx.background_executor()
+        .timer(std::time::Duration::from_millis(300))
+        .await;
+
+    // Capture the EXPANDED state (image visible)
+    let expanded_result = run_visual_test(
+        "agent_thread_with_image_expanded",
+        workspace_window.into(),
+        cx,
+        update_baseline,
+    )
+    .await?;
+
+    // Return pass only if both tests passed
+    match (&collapsed_result, &expanded_result) {
+        (TestResult::Passed, TestResult::Passed) => Ok(TestResult::Passed),
+        (TestResult::BaselineUpdated(p1), TestResult::BaselineUpdated(_)) => {
+            Ok(TestResult::BaselineUpdated(p1.clone()))
+        }
+        (TestResult::Passed, TestResult::BaselineUpdated(p)) => {
+            Ok(TestResult::BaselineUpdated(p.clone()))
+        }
+        (TestResult::BaselineUpdated(p), TestResult::Passed) => {
+            Ok(TestResult::BaselineUpdated(p.clone()))
+        }
+    }
+}

docs/src/development/macos.md ๐Ÿ”—

@@ -72,15 +72,34 @@ You must grant Screen Recording permission to your terminal:
 cargo run -p zed --bin zed_visual_test_runner --features visual-tests
 ```
 
-### Updating Baselines
+### Baseline Images
 
-When UI changes are intentional, update the baseline images:
+Baseline images are stored in `crates/zed/test_fixtures/visual_tests/` but are
+**gitignored** to avoid bloating the repository. You must generate them locally
+before running tests.
+
+#### Initial Setup
+
+Before making any UI changes, generate baseline images from a known-good state:
+
+```sh
+git checkout origin/main
+UPDATE_BASELINE=1 cargo run -p zed --bin visual_test_runner --features visual-tests
+git checkout -
+```
+
+This creates baselines that reflect the current expected UI.
+
+#### Updating Baselines
+
+When UI changes are intentional, update the baseline images after your changes:
 
 ```sh
 UPDATE_BASELINE=1 cargo run -p zed --bin zed_visual_test_runner --features visual-tests
 ```
 
-Baseline images are stored in `crates/zed/test_fixtures/visual_tests/` and should be committed to the repository.
+> **Note:** In the future, baselines may be stored externally. For now, they
+> remain local-only to keep the git repository lightweight.
 
 ## Troubleshooting
 

plans/agent-panel-image-visual-test.md ๐Ÿ”—

@@ -0,0 +1,198 @@
+# Visual Test Plan: Agent Panel Image Rendering
+
+## ๐ŸŽฏ The Goal
+
+We want a visual regression test that **catches bugs in how `read_file` displays images**. 
+
+If someone changes the code in `ReadFileTool` or the UI rendering in `thread_view.rs`, this test should fail and show us visually what changed.
+
+## โš ๏ธ Current Problem: The Test is Useless
+
+**The current test in `crates/zed/src/visual_test_runner.rs` does NOT test the real code!**
+
+Here's what it does now (WRONG):
+1. Creates a `StubAgentConnection` 
+2. Hard-codes a fake tool call response with pre-baked image data
+3. Injects that directly into `AcpThread`
+4. Takes a screenshot
+
+**Why this is useless:** If you change how `ReadFileTool` produces its output (in `crates/agent/src/tools/read_file_tool.rs`), the test will still pass because it never runs that code! The test bypasses the entire tool execution pipeline.
+
+## โœ… What We Actually Need
+
+The test should:
+1. Create a real project with a real image file
+2. Actually run the real `ReadFileTool::run()` method
+3. Let the tool produce its real output via `event_stream.update_fields()`
+4. Have that real output flow through to `AcpThread` and render in the UI
+5. Take a screenshot of the real rendered result
+
+This way, if someone changes `ReadFileTool` or the UI rendering, the test will catch it.
+
+## ๐Ÿ“š Architecture Background (For Newcomers)
+
+Here's how the agent system works:
+
+### The Two "Thread" Types
+- **`Thread`** (in `crates/agent/src/thread.rs`) - Runs tools, talks to LLMs, produces events
+- **`AcpThread`** (in `crates/acp_thread/src/acp_thread.rs`) - Receives events and stores data for UI rendering
+
+### How Tools Work
+1. `Thread` has registered tools (like `ReadFileTool`)
+2. When a tool runs, it gets a `ToolCallEventStream`
+3. The tool calls `event_stream.update_fields(...)` to send updates
+4. Those updates become `ThreadEvent::ToolCallUpdate` events
+5. Events flow to `AcpThread` via `handle_thread_events()` in `NativeAgentConnection`
+6. `AcpThread` stores the data and the UI renders it
+
+### The Key File Locations
+- **Tool implementation:** `crates/agent/src/tools/read_file_tool.rs`
+  - Lines 163-188: Image file handling (calls `event_stream.update_fields()`)
+- **Event stream:** `crates/agent/src/thread.rs` 
+  - `ToolCallEventStream::update_fields()` - sends updates
+  - `ToolCallEventStream::test()` - creates a test event stream
+- **UI rendering:** `crates/agent_ui/src/acp/thread_view.rs`
+  - `render_image_output()` - renders images in tool call output
+- **Current (broken) test:** `crates/zed/src/visual_test_runner.rs`
+  - `run_agent_thread_view_test()` - the function that needs fixing
+
+## ๐Ÿ”ง Implementation Plan
+
+### Option A: Direct Tool Invocation (Recommended)
+
+Run the real tool and capture its output:
+
+```rust
+// 1. Create a project with a real image file
+let fs = FakeFs::new(cx.executor());
+fs.insert_file("/project/test-image.png", EMBEDDED_TEST_IMAGE.to_vec()).await;
+let project = Project::test(fs.clone(), ["/project"], cx).await;
+
+// 2. Create the ReadFileTool (needs Thread, ActionLog)
+let action_log = cx.new(|_| ActionLog::new(project.clone()));
+// ... create Thread with project ...
+let tool = Arc::new(ReadFileTool::new(thread.downgrade(), project.clone(), action_log));
+
+// 3. Run the tool and capture events
+let (event_stream, mut event_receiver) = ToolCallEventStream::test();
+let input = ReadFileToolInput {
+    path: "project/test-image.png".to_string(),
+    start_line: None,
+    end_line: None,
+};
+tool.run(input, event_stream, cx).await?;
+
+// 4. Collect the ToolCallUpdateFields that the tool produced
+let updates = event_receiver.collect_updates();
+
+// 5. Create an AcpThread and inject the real tool output
+// ... create AcpThread ...
+acp_thread.update(cx, |thread, cx| {
+    // First create the tool call entry
+    thread.upsert_tool_call(initial_tool_call, cx)?;
+    // Then update it with the real output from the tool
+    for update in updates {
+        thread.update_tool_call(update, cx)?;
+    }
+})?;
+
+// 6. Render and screenshot
+```
+
+### Required Exports
+
+The `agent` crate needs to export these for the visual test:
+- `ReadFileTool` and `ReadFileToolInput`
+- `ToolCallEventStream::test()` (already has `#[cfg(feature = "test-support")]`)
+- `Thread` (to create the tool)
+
+Check `crates/agent/src/lib.rs` and add exports if needed.
+
+### Required Dependencies in `crates/zed/Cargo.toml`
+
+The `visual-tests` feature needs:
+```toml
+"agent/test-support"  # For ToolCallEventStream::test() and tool exports
+```
+
+### Option B: Use NativeAgentConnection with Fake Model
+
+Alternatively, use the full agent flow with a fake LLM:
+
+1. Create `NativeAgentServer` with a `FakeLanguageModel`
+2. Program the fake model to return a tool call for `read_file`
+3. Let the real agent flow execute the tool
+4. The tool runs, produces output, flows through to UI
+
+This is more complex but tests more of the real code path.
+
+## ๐Ÿ“‹ Step-by-Step Implementation Checklist
+
+### Phase 1: Enable Tool Access
+- [x] Add `agent/test-support` to `visual-tests` feature in `crates/zed/Cargo.toml`
+- [x] Verify `ReadFileTool`, `ReadFileToolInput`, `ToolCallEventStream::test()` are exported
+- [x] Added additional required features: `language_model/test-support`, `fs/test-support`, `action_log`
+
+### Phase 2: Rewrite the Test
+- [x] In `run_agent_thread_view_test()`, remove the fake stub response
+- [x] Create a real temp directory with a real image file (FakeFs doesn't work in visual test runner)
+- [x] Create the real `ReadFileTool` with Thread, ActionLog, etc.
+- [x] Run the tool with `ToolCallEventStream::test()`
+- [x] Capture the `ToolCallUpdateFields` it produces
+- [x] Use the real tool output to populate the stub connection's response
+
+### Phase 3: Verify It Works
+- [x] Run `UPDATE_BASELINE=1 cargo run -p zed --bin visual_test_runner --features visual-tests`
+- [x] Check the screenshot shows the real tool output
+- [x] Intentionally break `read_file_tool.rs` (comment out `event_stream.update_fields`)
+- [x] Verified the test fails with: "ReadFileTool did not produce any content - the tool is broken!"
+- [x] Restored the code and verified test passes again
+
+## ๐Ÿงช How to Verify the Test is Actually Testing Real Code
+
+After implementing, do this sanity check:
+
+1. In `crates/agent/src/tools/read_file_tool.rs`, comment out lines 181-185:
+   ```rust
+   // event_stream.update_fields(ToolCallUpdateFields::new().content(vec![
+   //     acp::ToolCallContent::Content(acp::Content::new(acp::ContentBlock::Image(
+   //         acp::ImageContent::new(language_model_image.source.clone(), "image/png"),
+   //     ))),
+   // ]));
+   ```
+
+2. Run the visual test - it should FAIL or produce a visibly different screenshot
+
+3. Restore the code - test should pass again
+
+If commenting out the real tool code doesn't affect the test, the test is still broken!
+
+## ๐Ÿ“ Files Modified
+
+| File | Change |
+|------|--------|
+| `crates/zed/Cargo.toml` | Added `agent/test-support`, `language_model/test-support`, `fs/test-support`, `action_log` to `visual-tests` feature |
+| `crates/zed/src/visual_test_runner.rs` | Rewrote `run_agent_thread_view_test()` to run the real `ReadFileTool` and capture its output |
+
+Note: No changes needed to `crates/agent/src/lib.rs` - all necessary exports were already public.
+
+## โœ… Already Completed (Don't Redo These)
+
+These changes have already been made and are working:
+
+1. **`read_file` tool sends image content** - `crates/agent/src/tools/read_file_tool.rs` now calls `event_stream.update_fields()` with image content blocks (lines 181-185)
+
+2. **UI renders images** - `crates/agent_ui/src/acp/thread_view.rs` has `render_image_output()` that shows dimensions ("512ร—512 PNG") and a "Go to File" button
+
+3. **Image tool calls auto-expand** - The UI automatically expands tool calls that return images
+
+4. **Visual test infrastructure exists** - The test runner, baseline comparison, etc. all work
+
+The only thing broken is that the test doesn't actually run the real tool code!
+
+## ๐Ÿ”— Related Code References
+
+- Tool implementation: [read_file_tool.rs](file:///Users/rtfeldman/code/zed5/crates/agent/src/tools/read_file_tool.rs)
+- Event stream: [thread.rs lines 2501-2596](file:///Users/rtfeldman/code/zed5/crates/agent/src/thread.rs#L2501-L2596)
+- UI rendering: [thread_view.rs render_image_output](file:///Users/rtfeldman/code/zed5/crates/agent_ui/src/acp/thread_view.rs#L3146-L3217)
+- Current test: [visual_test_runner.rs run_agent_thread_view_test](file:///Users/rtfeldman/code/zed5/crates/zed/src/visual_test_runner.rs#L778-L943)