1use std::{rc::Rc, time::Duration};
4
5use gpui_kit::{
6 Anchor, Animation, AnimationExt as _, AnyElement, App, Div, ElementId, FontWeight, Hsla,
7 InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, SharedString,
8 Styled as _, Window,
9 assets::IconName,
10 component::{
11 ActiveTheme as _, Sizable as _,
12 button::{Button, ButtonVariants as _},
13 h_flex,
14 popover::Popover,
15 switch::Switch,
16 v_flex,
17 },
18 div,
19 prelude::FluentBuilder as _,
20 px,
21};
22
23use crate::model::{AgentComponent, ComponentStatus};
24
25#[derive(Clone, Debug, PartialEq, Eq)]
27pub enum AgentBarEvent {
28 Configure(String),
30 Toggle {
32 component: String,
34 toggle: String,
36 enabled: bool,
38 },
39 OpenSettings,
41}
42
43type AgentHandler = Rc<dyn Fn(AgentBarEvent, &mut Window, &mut App)>;
44
45#[derive(IntoElement)]
51pub struct AgentBar {
52 id: SharedString,
53 components: Vec<AgentComponent>,
54 on_event: Option<AgentHandler>,
55}
56
57impl AgentBar {
58 pub fn new(
60 id: impl Into<SharedString>,
61 components: impl IntoIterator<Item = AgentComponent>,
62 ) -> Self {
63 Self {
64 id: id.into(),
65 components: components.into_iter().collect(),
66 on_event: None,
67 }
68 }
69
70 pub fn on_event(
72 mut self,
73 handler: impl Fn(AgentBarEvent, &mut Window, &mut App) + 'static,
74 ) -> Self {
75 self.on_event = Some(Rc::new(handler));
76 self
77 }
78}
79
80impl RenderOnce for AgentBar {
81 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
82 let handler = self.on_event;
83 let prefix = self.id;
84
85 let components = self
86 .components
87 .into_iter()
88 .enumerate()
89 .map(|(index, component)| {
90 component_button(&prefix, index, component, handler.clone(), cx)
91 })
92 .collect::<Vec<_>>();
93
94 let settings_handler = handler;
95 let settings_id = ElementId::Name(format!("{prefix}-settings").into());
96 h_flex()
97 .items_center()
98 .gap_1()
99 .px_2()
100 .py_1()
101 .children(components)
102 .child(div().flex_1())
103 .child(
104 Button::new(settings_id)
105 .icon(IconName::Settings)
106 .ghost()
107 .small()
108 .tooltip("Agent settings")
109 .on_click(move |_, window, cx| {
110 if let Some(handler) = &settings_handler {
111 handler(AgentBarEvent::OpenSettings, window, cx);
112 }
113 }),
114 )
115 }
116}
117
118fn component_button(
119 prefix: &SharedString,
120 index: usize,
121 component: AgentComponent,
122 handler: Option<AgentHandler>,
123 cx: &mut App,
124) -> Div {
125 let theme = cx.theme();
126 let color = status_color(component.status, cx);
127 let popover_id = ElementId::Name(format!("{prefix}-{index}-popover").into());
128 let trigger_id = ElementId::Name(format!("{prefix}-{index}-trigger").into());
129 let pulse_id = ElementId::Name(format!("{prefix}-{index}-pulse").into());
130
131 let tooltip = if let Some(detail) = &component.detail {
132 format!(
133 "{} · {}\n{}",
134 component.label,
135 component.status.label(),
136 detail
137 )
138 } else {
139 format!("{} · {}", component.label, component.status.label())
140 };
141
142 let icon = component
143 .icon
144 .as_ref()
145 .and_then(|icon| icon.as_icon())
146 .unwrap_or_else(|| gpui_kit::component::Icon::new(component.kind.icon()));
147
148 let content_component = component.clone();
149 let content_handler = handler.clone();
150 let content_prefix = prefix.clone();
151 let popover = Popover::new(popover_id)
152 .anchor(Anchor::TopLeft)
153 .trigger(
154 Button::new(trigger_id)
155 .debug_selector(|| format!("{prefix}-{index}-trigger"))
156 .icon(icon)
157 .ghost()
158 .small()
159 .rounded(px(4.))
160 .bg(theme.muted.opacity(0.18))
161 .tooltip(tooltip),
162 )
163 .content(move |_, _, cx| {
164 let popover_entity = cx.entity();
165 let configure_handler = content_handler.clone();
166 let configure_id =
167 ElementId::Name(format!("{content_prefix}-{index}-configure").into());
168 let component = content_component.clone();
169 let id = component.id.clone();
170 let configure_popover = popover_entity.clone();
171
172 let toggle_rows = component
173 .toggles
174 .iter()
175 .map(|toggle| {
176 let handler = content_handler.clone();
177 let component_id = component.id.clone();
178 let toggle_id = toggle.id.clone();
179 h_flex()
180 .w_full()
181 .justify_between()
182 .items_center()
183 .gap_3()
184 .child(
185 v_flex()
188 .flex_1()
189 .min_w(px(0.))
190 .child(div().min_w(px(0.)).text_sm().child(toggle.label.clone()))
191 .when_some(toggle.detail.clone(), |column, detail| {
192 column.child(
193 div()
194 .min_w(px(0.))
195 .text_xs()
196 .text_color(cx.theme().muted_foreground)
197 .child(detail),
198 )
199 }),
200 )
201 .child(
202 div()
203 .flex_none()
204 .debug_selector(|| {
205 format!("{content_prefix}-{index}-toggle-{toggle_id}")
206 })
207 .child(
208 Switch::new(ElementId::Name(
209 format!("{content_prefix}-{index}-toggle-{toggle_id}")
210 .into(),
211 ))
212 .checked(toggle.enabled)
213 .on_click(
214 move |enabled, window, cx| {
215 if let Some(handler) = &handler {
216 handler(
217 AgentBarEvent::Toggle {
218 component: component_id.clone(),
219 toggle: toggle_id.clone(),
220 enabled: *enabled,
221 },
222 window,
223 cx,
224 );
225 }
226 },
227 ),
228 ),
229 )
230 .into_any_element()
231 })
232 .collect::<Vec<_>>();
233
234 v_flex()
237 .id(ElementId::Name(
238 format!("{content_prefix}-{index}-popover-content").into(),
239 ))
240 .debug_selector(|| format!("{content_prefix}-{index}-popover-content"))
241 .w(px(280.))
242 .gap_2()
243 .child(
244 h_flex()
245 .w_full()
246 .gap_2()
247 .child(div().child(component.kind.icon()))
248 .child(
249 div()
250 .text_sm()
251 .font_weight(FontWeight::SEMIBOLD)
252 .child(component.label.clone()),
253 ),
254 )
255 .child(
256 div()
257 .text_sm()
258 .text_color(status_color(component.status, cx))
259 .child(component.status.label()),
260 )
261 .when_some(component.detail.clone(), |card, detail| {
262 card.child(
263 div()
264 .text_sm()
265 .text_color(cx.theme().muted_foreground)
266 .child(detail),
267 )
268 })
269 .when(!toggle_rows.is_empty(), |card| {
270 card.child(div().h(px(1.)).bg(cx.theme().border))
271 .child(v_flex().w_full().gap_2().children(toggle_rows))
272 })
273 .child(
274 Button::new(configure_id)
275 .label("Configure")
276 .outline()
277 .small()
278 .on_click(move |_, window, cx| {
279 if let Some(handler) = &configure_handler {
280 handler(AgentBarEvent::Configure(id.clone()), window, cx);
281 }
282 configure_popover.update(cx, |state, cx| state.dismiss(window, cx));
283 }),
284 )
285 });
286
287 let dot = div()
288 .absolute()
289 .bottom(px(1.))
290 .right(px(1.))
291 .size(px(7.))
292 .rounded_full()
293 .border_1()
294 .border_color(theme.background)
295 .bg(color);
296
297 let dot: AnyElement = if component.status.is_busy() {
298 dot.with_animation(
299 pulse_id,
300 Animation::new(Duration::from_millis(900)).repeat(),
301 |dot, delta| dot.opacity(0.35 + 0.65 * (1.0 - delta)),
302 )
303 .into_any_element()
304 } else {
305 dot.into_any_element()
306 };
307
308 div().relative().child(popover).child(dot)
309}
310
311fn status_color(status: ComponentStatus, cx: &App) -> Hsla {
312 let theme = cx.theme();
313 match status {
314 ComponentStatus::Inactive => theme.muted_foreground,
315 ComponentStatus::Ready => theme.success,
316 ComponentStatus::Busy => theme.info,
317 ComponentStatus::Error => theme.danger,
318 }
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use crate::model::{ComponentKind, ComponentToggle};
325 use gpui_kit::{Context, Modifiers, Render, TestAppContext, Window};
326
327 struct BarHost {
328 component: AgentComponent,
329 }
330
331 impl Render for BarHost {
332 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
333 div()
334 .w(px(640.))
335 .h(px(320.))
336 .child(AgentBar::new("agent", [self.component.clone()]))
337 }
338 }
339
340 #[test]
341 fn a_bar_collects_components() {
342 let bar = AgentBar::new(
343 "agent",
344 [
345 AgentComponent::new("chat", ComponentKind::Chat, "DeepSeek"),
346 AgentComponent::new("embed", ComponentKind::Embedder, "nomic"),
347 ],
348 );
349 assert_eq!(bar.components.len(), 2);
350 }
351
352 #[gpui_kit::test]
353 fn toggle_switches_stay_inside_the_popover(cx: &mut TestAppContext) {
354 cx.update(crate::init);
355 let (_, cx) = cx.add_window_view(|_, _| BarHost {
356 component: AgentComponent::new("memory", ComponentKind::Memory, "Chat memory").toggle(
357 ComponentToggle::new("memory", "Enabled", true)
358 .detail("Conversation transcript and query rewriting."),
359 ),
360 });
361 cx.update(|window, cx| window.draw(cx).clear(cx));
362
363 let trigger = cx.debug_bounds("agent-0-trigger").expect("trigger bounds");
364 cx.simulate_click(trigger.center(), Modifiers::default());
365 cx.run_until_parked();
366 for _ in 0..2 {
367 cx.update(|window, cx| window.draw(cx).clear(cx));
368 }
369
370 let content = cx
371 .debug_bounds("agent-0-popover-content")
372 .expect("popover content bounds");
373 let switch = cx
374 .debug_bounds("agent-0-toggle-memory")
375 .expect("switch bounds");
376 assert!(
377 switch.right() <= content.right() + px(0.5),
378 "switch {switch:?} escapes the popover content {content:?}"
379 );
380 assert!(switch.left() >= content.left(), "switch starts outside");
381 }
382}