1use std::sync::Arc;
2
3use gpui::{ClickEvent, SharedString};
4
5use crate::prelude::*;
6use crate::{AgentMarkdown, BadgeColor, BadgeVariant, Card, CardVariant, Disclosure};
7
8#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
11pub enum AgentMessageRole {
12 User,
13 #[default]
14 Assistant,
15 ToolCall,
16 Status,
17 Thinking,
18}
19
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
22pub enum ToolCallState {
23 #[default]
24 Running,
25 Success,
26 Failed,
27}
28
29#[derive(IntoElement, RegisterComponent)]
31pub struct AgentMessageBubble {
32 id: ElementId,
33 role: AgentMessageRole,
34 body: SharedString,
35 tool_name: Option<SharedString>,
36 tool_state: ToolCallState,
37 tool_input: Option<SharedString>,
38 tool_output: Option<SharedString>,
39 expanded: bool,
40 on_toggle_expanded: Option<Arc<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
41}
42
43impl AgentMessageBubble {
44 pub fn new(
45 id: impl Into<ElementId>,
46 role: AgentMessageRole,
47 body: impl Into<SharedString>,
48 ) -> Self {
49 Self {
50 id: id.into(),
51 role,
52 body: body.into(),
53 tool_name: None,
54 tool_state: ToolCallState::default(),
55 tool_input: None,
56 tool_output: None,
57 expanded: false,
58 on_toggle_expanded: None,
59 }
60 }
61
62 pub fn tool_name(mut self, name: impl Into<SharedString>) -> Self {
64 self.tool_name = Some(name.into());
65 self
66 }
67
68 pub fn tool_state(mut self, state: ToolCallState) -> Self {
69 self.tool_state = state;
70 self
71 }
72 pub fn tool_input(mut self, input: impl Into<SharedString>) -> Self {
73 self.tool_input = Some(input.into());
74 self
75 }
76
77 pub fn tool_output(mut self, output: impl Into<SharedString>) -> Self {
78 self.tool_output = Some(output.into());
79 self
80 }
81
82 pub fn expanded(mut self, expanded: bool) -> Self {
84 self.expanded = expanded;
85 self
86 }
87
88 pub fn on_toggle_expanded(
89 mut self,
90 handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
91 ) -> Self {
92 self.on_toggle_expanded = Some(Arc::new(handler));
93 self
94 }
95}
96
97fn raw_block(label: &'static str, content: SharedString, cx: &App) -> AnyElement {
98 v_flex()
99 .gap_1()
100 .child(Label::new(label).size(LabelSize::Small).color(Color::Muted))
101 .child(
102 div()
103 .p_2()
104 .rounded_md()
105 .bg(cx.theme().colors().element_background)
106 .child(content),
107 )
108 .into_any_element()
109}
110
111fn tool_call_card(bubble: AgentMessageBubble, cx: &App) -> AnyElement {
112 let (badge_label, badge_color) = match bubble.tool_state {
113 ToolCallState::Running => ("Running", BadgeColor::Secondary),
114 ToolCallState::Success => ("Success", BadgeColor::Success),
115 ToolCallState::Failed => ("Failed", BadgeColor::Danger),
116 };
117 let disclosure_id = format!("{}-disclosure", bubble.id);
118 let title = bubble.tool_name.unwrap_or_else(|| bubble.body.clone());
119 let has_raw = bubble.tool_input.is_some() || bubble.tool_output.is_some();
120 let expanded = bubble.expanded;
121
122 let tool_icon = Icon::new(IconName::ToolHammer)
123 .size(IconSize::Small)
124 .color(Color::Muted);
125 let badge = Badge::new(badge_label)
126 .variant(BadgeVariant::Soft)
127 .color(badge_color);
128 let disclosure =
129 Disclosure::new(disclosure_id, expanded).on_toggle_expanded(bubble.on_toggle_expanded);
130 let header = h_flex()
131 .id(bubble.id)
132 .w_full()
133 .justify_between()
134 .child(h_flex().gap_2().child(tool_icon).child(Label::new(title)))
135 .child(
136 h_flex()
137 .gap_2()
138 .child(badge)
139 .when(has_raw, |this| this.child(disclosure)),
140 );
141
142 Card::new()
143 .variant(CardVariant::Bordered)
144 .header(header)
145 .when(has_raw && expanded, |this| {
146 this.child(
147 v_flex()
148 .gap_2()
149 .when_some(bubble.tool_input, |this, input| {
150 this.child(raw_block("Input", input, cx))
151 })
152 .when_some(bubble.tool_output, |this, output| {
153 this.child(raw_block("Output", output, cx))
154 }),
155 )
156 })
157 .into_any_element()
158}
159
160impl RenderOnce for AgentMessageBubble {
161 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
162 match self.role {
163 AgentMessageRole::User => {
164 let bubble = div()
165 .max_w(relative(0.8))
166 .px_3()
167 .py_2()
168 .rounded_lg()
169 .bg(cx.theme().colors().element_active)
170 .child(Label::new(self.body));
171 h_flex()
172 .id(self.id)
173 .w_full()
174 .justify_end()
175 .child(bubble)
176 .into_any_element()
177 }
178 AgentMessageRole::Assistant => {
179 let md = AgentMarkdown::new(format!("{}-md", self.id), self.body);
180 div()
181 .id(self.id.clone())
182 .w_full()
183 .child(md)
184 .into_any_element()
185 }
186 AgentMessageRole::Thinking => {
187 let md = AgentMarkdown::new(format!("{}-md", self.id), self.body);
188 let header = h_flex()
189 .gap_1()
190 .child(
191 Icon::new(IconName::ToolThink)
192 .size(IconSize::XSmall)
193 .color(Color::Muted),
194 )
195 .child(
196 Label::new("Thinking")
197 .size(LabelSize::Small)
198 .color(Color::Muted),
199 );
200 v_flex()
201 .id(self.id.clone())
202 .w_full()
203 .gap_1()
204 .child(header)
205 .child(div().opacity(0.7).child(md))
206 .into_any_element()
207 }
208 AgentMessageRole::Status => h_flex()
209 .id(self.id)
210 .w_full()
211 .gap_1()
212 .child(
213 Label::new(self.body)
214 .size(LabelSize::Small)
215 .color(Color::Muted),
216 )
217 .into_any_element(),
218 AgentMessageRole::ToolCall => tool_call_card(self, cx),
219 }
220 }
221}
222
223impl Component for AgentMessageBubble {
224 fn scope() -> ComponentScope {
225 ComponentScope::Agent
226 }
227
228 fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
229 let tool_call =
230 AgentMessageBubble::new("m-tool-1", AgentMessageRole::ToolCall, "run_tests")
231 .tool_name("run_tests")
232 .tool_state(ToolCallState::Success)
233 .tool_input("cargo test -p boltz-ui")
234 .tool_output("test result: ok. 42 passed; 0 failed")
235 .expanded(true);
236
237 let assistant = AgentMessageBubble::new(
238 "m-a",
239 AgentMessageRole::Assistant,
240 "Run `cargo test -p boltz-ui`.",
241 );
242 let messages = v_flex()
243 .gap_2()
244 .child(AgentMessageBubble::new(
245 "m-user",
246 AgentMessageRole::User,
247 "How do I run tests?",
248 ))
249 .child(assistant)
250 .child(AgentMessageBubble::new(
251 "m-thinking",
252 AgentMessageRole::Thinking,
253 "Checking...",
254 ))
255 .child(AgentMessageBubble::new(
256 "m-status",
257 AgentMessageRole::Status,
258 "Connecting…",
259 ));
260
261 Some(
262 v_flex()
263 .w_96()
264 .gap_4()
265 .child(single_example("Roles", messages.into_any_element()))
266 .child(single_example(
267 "Tool Call (expanded)",
268 tool_call.into_any_element(),
269 ))
270 .into_any_element(),
271 )
272 }
273}