1use gpui::{Animation, AnimationExt, AnyElement, App, IntoElement, pulsating_between};
2use std::time::Duration;
3use ui::{Tooltip, prelude::*};
4
5/// A reusable header component for tool call cards.
6#[derive(IntoElement)]
7pub struct ToolCallCardHeader {
8 icon: IconName,
9 primary_text: SharedString,
10 secondary_text: Option<SharedString>,
11 code_path: Option<SharedString>,
12 disclosure_slot: Option<AnyElement>,
13 is_loading: bool,
14 error: Option<String>,
15}
16
17impl ToolCallCardHeader {
18 pub fn new(icon: IconName, primary_text: impl Into<SharedString>) -> Self {
19 Self {
20 icon,
21 primary_text: primary_text.into(),
22 secondary_text: None,
23 code_path: None,
24 disclosure_slot: None,
25 is_loading: false,
26 error: None,
27 }
28 }
29
30 pub fn with_secondary_text(mut self, text: impl Into<SharedString>) -> Self {
31 self.secondary_text = Some(text.into());
32 self
33 }
34
35 pub fn with_code_path(mut self, text: impl Into<SharedString>) -> Self {
36 self.code_path = Some(text.into());
37 self
38 }
39
40 pub fn disclosure_slot(mut self, element: impl IntoElement) -> Self {
41 self.disclosure_slot = Some(element.into_any_element());
42 self
43 }
44
45 pub fn loading(mut self) -> Self {
46 self.is_loading = true;
47 self
48 }
49
50 pub fn with_error(mut self, error: impl Into<String>) -> Self {
51 self.error = Some(error.into());
52 self
53 }
54}
55
56impl RenderOnce for ToolCallCardHeader {
57 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
58 let font_size = rems(0.8125);
59 let line_height = window.line_height();
60
61 let secondary_text = self.secondary_text;
62 let code_path = self.code_path;
63
64 let bullet_divider = || {
65 div()
66 .size(px(3.))
67 .rounded_full()
68 .bg(cx.theme().colors().text)
69 };
70
71 h_flex()
72 .id("tool-label-container")
73 .gap_2()
74 .max_w_full()
75 .overflow_x_scroll()
76 .opacity(0.8)
77 .child(
78 h_flex()
79 .h(line_height)
80 .gap_1p5()
81 .text_size(font_size)
82 .child(
83 h_flex().h(line_height).justify_center().child(
84 Icon::new(self.icon)
85 .size(IconSize::Small)
86 .color(Color::Muted),
87 ),
88 )
89 .map(|this| {
90 if let Some(error) = &self.error {
91 this.child(format!("{} failed", self.primary_text)).child(
92 IconButton::new("error_info", IconName::Warning)
93 .shape(ui::IconButtonShape::Square)
94 .icon_size(IconSize::XSmall)
95 .icon_color(Color::Warning)
96 .tooltip(Tooltip::text(error.clone())),
97 )
98 } else {
99 this.child(self.primary_text.clone())
100 }
101 })
102 .when_some(secondary_text, |this, secondary_text| {
103 this.child(bullet_divider())
104 .child(div().text_size(font_size).child(secondary_text))
105 })
106 .when_some(code_path, |this, code_path| {
107 this.child(bullet_divider())
108 .child(Label::new(code_path).size(LabelSize::Small).inline_code(cx))
109 })
110 .with_animation(
111 "loading-label",
112 Animation::new(Duration::from_secs(2))
113 .repeat()
114 .with_easing(pulsating_between(0.6, 1.)),
115 move |this, delta| {
116 if self.is_loading {
117 this.opacity(delta)
118 } else {
119 this
120 }
121 },
122 ),
123 )
124 .when_some(self.disclosure_slot, |container, disclosure_slot| {
125 container
126 .group("disclosure")
127 .justify_between()
128 .child(div().visible_on_hover("disclosure").child(disclosure_slot))
129 })
130 }
131}