agent: Refine the web search tool call UI (#29190)

Danilo Leal created

This PR refines a bit the web search tool UI by introducing a component
(`ToolCallCardHeader`) that aims to standardize the heading element of
tool calls in the thread.

In terms of next steps, I plan to evolve this component further soon
(e.g., building a full-blown "tool call card" component), and even move
it to a place where I can re-use it in the active_thread as well without
making the `assistant_tools` a dependency of it.

Release Notes:

- N/A

Change summary

crates/assistant_tools/src/assistant_tools.rs          |   1 
crates/assistant_tools/src/ui.rs                       |   3 
crates/assistant_tools/src/ui/tool_call_card_header.rs | 102 ++++++++++++
crates/assistant_tools/src/web_search_tool.rs          |  77 ++------
4 files changed, 128 insertions(+), 55 deletions(-)

Detailed changes

crates/assistant_tools/src/ui/tool_call_card_header.rs 🔗

@@ -0,0 +1,102 @@
+use gpui::{Animation, AnimationExt, App, IntoElement, pulsating_between};
+use std::time::Duration;
+use ui::{Tooltip, prelude::*};
+
+/// A reusable header component for tool call cards.
+#[derive(IntoElement)]
+pub struct ToolCallCardHeader {
+    icon: IconName,
+    primary_text: SharedString,
+    secondary_text: Option<SharedString>,
+    is_loading: bool,
+    error: Option<String>,
+}
+
+impl ToolCallCardHeader {
+    pub fn new(icon: IconName, primary_text: impl Into<SharedString>) -> Self {
+        Self {
+            icon,
+            primary_text: primary_text.into(),
+            secondary_text: None,
+            is_loading: false,
+            error: None,
+        }
+    }
+
+    pub fn with_secondary_text(mut self, text: impl Into<SharedString>) -> Self {
+        self.secondary_text = Some(text.into());
+        self
+    }
+
+    pub fn loading(mut self) -> Self {
+        self.is_loading = true;
+        self
+    }
+
+    pub fn with_error(mut self, error: impl Into<String>) -> Self {
+        self.error = Some(error.into());
+        self
+    }
+}
+
+impl RenderOnce for ToolCallCardHeader {
+    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
+        let font_size = rems(0.8125);
+        let secondary_text = self.secondary_text;
+
+        h_flex()
+            .id("tool-label-container")
+            .gap_1p5()
+            .max_w_full()
+            .overflow_x_scroll()
+            .opacity(0.8)
+            .child(
+                h_flex().h(window.line_height()).justify_center().child(
+                    Icon::new(self.icon)
+                        .size(IconSize::XSmall)
+                        .color(Color::Muted),
+                ),
+            )
+            .child(
+                h_flex()
+                    .h(window.line_height())
+                    .gap_1p5()
+                    .text_size(font_size)
+                    .map(|this| {
+                        if let Some(error) = &self.error {
+                            this.child(format!("{} failed", self.primary_text)).child(
+                                IconButton::new("error_info", IconName::Warning)
+                                    .shape(ui::IconButtonShape::Square)
+                                    .icon_size(IconSize::XSmall)
+                                    .icon_color(Color::Warning)
+                                    .tooltip(Tooltip::text(error.clone())),
+                            )
+                        } else {
+                            this.child(self.primary_text.clone())
+                        }
+                    })
+                    .when_some(secondary_text, |this, secondary_text| {
+                        this.child(
+                            div()
+                                .size(px(3.))
+                                .rounded_full()
+                                .bg(cx.theme().colors().text),
+                        )
+                        .child(div().text_size(font_size).child(secondary_text.clone()))
+                    })
+                    .with_animation(
+                        "loading-label",
+                        Animation::new(Duration::from_secs(2))
+                            .repeat()
+                            .with_easing(pulsating_between(0.6, 1.)),
+                        move |this, delta| {
+                            if self.is_loading {
+                                this.opacity(delta)
+                            } else {
+                                this
+                            }
+                        },
+                    ),
+            )
+    }
+}

crates/assistant_tools/src/web_search_tool.rs 🔗

@@ -1,13 +1,11 @@
 use std::{sync::Arc, time::Duration};
 
 use crate::schema::json_schema_for;
+use crate::ui::ToolCallCardHeader;
 use anyhow::{Context as _, Result, anyhow};
 use assistant_tool::{ActionLog, Tool, ToolCard, ToolResult, ToolUseStatus};
-use futures::{FutureExt, TryFutureExt};
-use gpui::{
-    Animation, AnimationExt, App, AppContext, Context, Entity, IntoElement, Task, Window,
-    pulsating_between,
-};
+use futures::{Future, FutureExt, TryFutureExt};
+use gpui::{App, AppContext, Context, Entity, IntoElement, Task, Window};
 use language_model::{LanguageModelRequestMessage, LanguageModelToolSchemaFormat};
 use project::Project;
 use schemars::JsonSchema;
@@ -47,7 +45,7 @@ impl Tool for WebSearchTool {
     }
 
     fn ui_text(&self, _input: &serde_json::Value) -> String {
-        "Web Search".to_string()
+        "Searching the Web".to_string()
     }
 
     fn run(
@@ -115,61 +113,30 @@ impl ToolCard for WebSearchToolCard {
         _window: &mut Window,
         cx: &mut Context<Self>,
     ) -> impl IntoElement {
-        let header = h_flex()
-            .id("tool-label-container")
-            .gap_1p5()
-            .max_w_full()
-            .overflow_x_scroll()
-            .child(
-                Icon::new(IconName::Globe)
-                    .size(IconSize::XSmall)
-                    .color(Color::Muted),
-            )
-            .child(match self.response.as_ref() {
-                Some(Ok(response)) => {
-                    let text: SharedString = if response.citations.len() == 1 {
-                        "1 result".into()
-                    } else {
-                        format!("{} results", response.citations.len()).into()
-                    };
-                    h_flex()
-                        .gap_1p5()
-                        .child(Label::new("Searched the Web").size(LabelSize::Small))
-                        .child(
-                            div()
-                                .size(px(3.))
-                                .rounded_full()
-                                .bg(cx.theme().colors().text),
-                        )
-                        .child(Label::new(text).size(LabelSize::Small))
-                        .into_any_element()
-                }
-                Some(Err(error)) => div()
-                    .id("web-search-error")
-                    .child(Label::new("Web Search failed").size(LabelSize::Small))
-                    .tooltip(Tooltip::text(error.to_string()))
-                    .into_any_element(),
-
-                None => Label::new("Searching the Web…")
-                    .size(LabelSize::Small)
-                    .with_animation(
-                        "web-search-label",
-                        Animation::new(Duration::from_secs(2))
-                            .repeat()
-                            .with_easing(pulsating_between(0.6, 1.)),
-                        |label, delta| label.alpha(delta),
-                    )
-                    .into_any_element(),
-            })
-            .into_any();
+        let header = match self.response.as_ref() {
+            Some(Ok(response)) => {
+                let text: SharedString = if response.citations.len() == 1 {
+                    "1 result".into()
+                } else {
+                    format!("{} results", response.citations.len()).into()
+                };
+                ToolCallCardHeader::new(IconName::Globe, "Searched the Web")
+                    .with_secondary_text(text)
+            }
+            Some(Err(error)) => {
+                ToolCallCardHeader::new(IconName::Globe, "Web Search").with_error(error.to_string())
+            }
+            None => ToolCallCardHeader::new(IconName::Globe, "Searching the Web").loading(),
+        };
 
         let content =
             self.response.as_ref().and_then(|response| match response {
                 Ok(response) => {
                     Some(
                         v_flex()
+                            .overflow_hidden()
                             .ml_1p5()
-                            .pl_1p5()
+                            .pl(px(5.))
                             .border_l_1()
                             .border_color(cx.theme().colors().border_variant)
                             .gap_1()
@@ -209,7 +176,7 @@ impl ToolCard for WebSearchToolCard {
                 Err(_) => None,
             });
 
-        v_flex().my_2().gap_1().child(header).children(content)
+        v_flex().mb_3().gap_1().child(header).children(content)
     }
 }