Skip to main content

ragrig_gpui/
agent_bar.rs

1//! The agent component status bar.
2
3use 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/// What the user asked the agent bar to do.
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub enum AgentBarEvent {
28    /// Open the configuration surface for a component id.
29    Configure(String),
30    /// Flip a switch in a component popover.
31    Toggle {
32        /// Component id that owns the switch.
33        component: String,
34        /// Switch id.
35        toggle: String,
36        /// The state the user asked for.
37        enabled: bool,
38    },
39    /// Open the general agent settings.
40    OpenSettings,
41}
42
43type AgentHandler = Rc<dyn Fn(AgentBarEvent, &mut Window, &mut App)>;
44
45/// A row of pipeline components with status dots and detail popovers.
46///
47/// Each component opens a popover anchored to its icon. The popover shows the
48/// label, status, and detail line, and emits [`AgentBarEvent::Configure`] when
49/// the host should open its configuration.
50#[derive(IntoElement)]
51pub struct AgentBar {
52    id: SharedString,
53    components: Vec<AgentComponent>,
54    on_event: Option<AgentHandler>,
55}
56
57impl AgentBar {
58    /// A bar with a unique element-ID prefix.
59    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    /// Receive configuration and settings requests.
71    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                            // `min_w(0.)` lets the label column shrink instead
186                            // of pushing the switch past the popover's padding.
187                            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            // The popover shell already supplies `p_3()`; adding it here
235            // narrowed the content box and forced the switches outward.
236            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}