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

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
This commit is contained in:
Danilo Leal 2025-04-22 09:51:57 -03:00 committed by GitHub
parent 109f1d43fc
commit 19b547565d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 128 additions and 55 deletions

View file

@ -22,6 +22,7 @@ mod schema;
mod symbol_info_tool; mod symbol_info_tool;
mod terminal_tool; mod terminal_tool;
mod thinking_tool; mod thinking_tool;
mod ui;
mod web_search_tool; mod web_search_tool;
use std::sync::Arc; use std::sync::Arc;

View file

@ -0,0 +1,3 @@
mod tool_call_card_header;
pub use tool_call_card_header::*;

View file

@ -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
}
},
),
)
}
}

View file

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