Add example agent tool preview (#28984)

Nate Butler and Marshall Bowers created

This PR adds an example of rendering previews for tools using the new
Agent ToolCard style.

![CleanShot 2025-04-17 at 13 03
12@2x](https://github.com/user-attachments/assets/d4c7d266-cc32-4038-9170-f3e070fce60e)

Release Notes:

- N/A

---------

Co-authored-by: Marshall Bowers <git@maxdeviant.com>

Change summary

Cargo.lock                                    |   2 
crates/assistant_tools/Cargo.toml             |   6 
crates/assistant_tools/src/web_search_tool.rs | 111 ++++++++++++++++++++
crates/component/src/component.rs             |   2 
typos.toml                                    |   3 
5 files changed, 121 insertions(+), 3 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -703,6 +703,7 @@ dependencies = [
  "assistant_tool",
  "chrono",
  "collections",
+ "component",
  "feature_flags",
  "futures 0.3.31",
  "gpui",
@@ -711,6 +712,7 @@ dependencies = [
  "itertools 0.14.0",
  "language",
  "language_model",
+ "linkme",
  "open",
  "project",
  "rand 0.8.5",

crates/assistant_tools/Cargo.toml 🔗

@@ -16,6 +16,7 @@ anyhow.workspace = true
 assistant_tool.workspace = true
 chrono.workspace = true
 collections.workspace = true
+component.workspace = true
 feature_flags.workspace = true
 futures.workspace = true
 gpui.workspace = true
@@ -24,6 +25,8 @@ http_client.workspace = true
 itertools.workspace = true
 language.workspace = true
 language_model.workspace = true
+linkme.workspace = true
+open.workspace = true
 project.workspace = true
 regex.workspace = true
 schemars.workspace = true
@@ -31,10 +34,9 @@ serde.workspace = true
 serde_json.workspace = true
 ui.workspace = true
 util.workspace = true
-worktree.workspace = true
-open = { workspace = true }
 web_search.workspace = true
 workspace-hack.workspace = true
+worktree.workspace = true
 zed_llm_client.workspace = true
 
 [dev-dependencies]

crates/assistant_tools/src/web_search_tool.rs 🔗

@@ -14,7 +14,7 @@ use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use ui::{IconName, Tooltip, prelude::*};
 use web_search::WebSearchRegistry;
-use zed_llm_client::WebSearchResponse;
+use zed_llm_client::{WebSearchCitation, WebSearchResponse};
 
 #[derive(Debug, Serialize, Deserialize, JsonSchema)]
 pub struct WebSearchToolInput {
@@ -22,6 +22,7 @@ pub struct WebSearchToolInput {
     query: String,
 }
 
+#[derive(RegisterComponent)]
 pub struct WebSearchTool;
 
 impl Tool for WebSearchTool {
@@ -211,3 +212,111 @@ impl ToolCard for WebSearchToolCard {
         v_flex().my_2().gap_1().child(header).children(content)
     }
 }
+
+impl Component for WebSearchTool {
+    fn scope() -> ComponentScope {
+        ComponentScope::Agent
+    }
+
+    fn sort_name() -> &'static str {
+        "ToolWebSearch"
+    }
+
+    fn preview(window: &mut Window, cx: &mut App) -> Option<AnyElement> {
+        let in_progress_search = cx.new(|cx| WebSearchToolCard {
+            response: None,
+            _task: cx.spawn(async move |_this, cx| {
+                loop {
+                    cx.background_executor()
+                        .timer(Duration::from_secs(60))
+                        .await
+                }
+            }),
+        });
+
+        let successful_search = cx.new(|_cx| WebSearchToolCard {
+            response: Some(Ok(example_search_response())),
+            _task: Task::ready(()),
+        });
+
+        let error_search = cx.new(|_cx| WebSearchToolCard {
+            response: Some(Err(anyhow!("Failed to resolve https://google.com"))),
+            _task: Task::ready(()),
+        });
+
+        Some(
+            v_flex()
+                .gap_6()
+                .children(vec![example_group(vec![
+                    single_example(
+                        "In Progress",
+                        div()
+                            .size_full()
+                            .child(in_progress_search.update(cx, |tool, cx| {
+                                tool.render(&ToolUseStatus::Pending, window, cx)
+                                    .into_any_element()
+                            }))
+                            .into_any_element(),
+                    ),
+                    single_example(
+                        "Successful",
+                        div()
+                            .size_full()
+                            .child(successful_search.update(cx, |tool, cx| {
+                                tool.render(&ToolUseStatus::Finished("".into()), window, cx)
+                                    .into_any_element()
+                            }))
+                            .into_any_element(),
+                    ),
+                    single_example(
+                        "Error",
+                        div()
+                            .size_full()
+                            .child(error_search.update(cx, |tool, cx| {
+                                tool.render(&ToolUseStatus::Error("".into()), window, cx)
+                                    .into_any_element()
+                            }))
+                            .into_any_element(),
+                    ),
+                ])])
+                .into_any_element(),
+        )
+    }
+}
+
+fn example_search_response() -> WebSearchResponse {
+    WebSearchResponse {
+        summary: r#"Toronto boasts a vibrant culinary scene with a diverse array of..."#
+            .to_string(),
+        citations: vec![
+            WebSearchCitation {
+                title: "Alo".to_string(),
+                url: "https://www.google.com/maps/search/Alo%2C+Toronto%2C+Canada".to_string(),
+                range: Some(147..213),
+            },
+            WebSearchCitation {
+                title: "Edulis".to_string(),
+                url: "https://www.google.com/maps/search/Edulis%2C+Toronto%2C+Canada".to_string(),
+                range: Some(447..519),
+            },
+            WebSearchCitation {
+                title: "Sushi Masaki Saito".to_string(),
+                url: "https://www.google.com/maps/search/Sushi+Masaki+Saito%2C+Toronto%2C+Canada"
+                    .to_string(),
+                range: Some(776..872),
+            },
+            WebSearchCitation {
+                title: "Shoushin".to_string(),
+                url: "https://www.google.com/maps/search/Shoushin%2C+Toronto%2C+Canada".to_string(),
+                range: Some(1072..1148),
+            },
+            WebSearchCitation {
+                title: "Restaurant 20 Victoria".to_string(),
+                url:
+                    "https://www.google.com/maps/search/Restaurant+20+Victoria%2C+Toronto%2C+Canada"
+                        .to_string(),
+                range: Some(1291..1395),
+            },
+        ],
+    }
+}

crates/component/src/component.rs 🔗

@@ -201,6 +201,7 @@ pub fn components() -> AllComponents {
 
 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
 pub enum ComponentScope {
+    Agent,
     Collaboration,
     DataDisplay,
     Editor,
@@ -220,6 +221,7 @@ pub enum ComponentScope {
 impl Display for ComponentScope {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         match self {
+            ComponentScope::Agent => write!(f, "Agent"),
             ComponentScope::Collaboration => write!(f, "Collaboration"),
             ComponentScope::DataDisplay => write!(f, "Data Display"),
             ComponentScope::Editor => write!(f, "Editor"),

typos.toml 🔗

@@ -19,6 +19,9 @@ extend-exclude = [
     # Some crate names are flagged as typos.
     "crates/indexed_docs/src/providers/rustdoc/popular_crates.txt",
 
+    # Some mock data is flagged as typos.
+    "crates/assistant_tools/src/web_search_tool.rs",
+
     # Stripe IDs are flagged as typos.
     "crates/collab/src/db/tests/processed_stripe_event_tests.rs",
     # Not our typos.