1use std::{rc::Rc, time::Duration};
4
5use gpui_kit::{
6 Anchor, Animation, AnimationExt as _, AnyElement, App, Div, ElementId, FontWeight, Hsla,
7 IntoElement, ParentElement as _, RenderOnce, SharedString, Styled as _, Window,
8 assets::IconName,
9 component::{
10 ActiveTheme as _, Sizable as _,
11 button::{Button, ButtonVariants as _},
12 h_flex,
13 popover::Popover,
14 v_flex,
15 },
16 div,
17 prelude::FluentBuilder as _,
18 px,
19};
20
21use crate::model::{AgentComponent, ComponentStatus};
22
23#[derive(Clone, Debug, PartialEq, Eq)]
25pub enum AgentBarEvent {
26 Configure(String),
28 OpenSettings,
30}
31
32type AgentHandler = Rc<dyn Fn(AgentBarEvent, &mut Window, &mut App)>;
33
34#[derive(IntoElement)]
40pub struct AgentBar {
41 id: SharedString,
42 components: Vec<AgentComponent>,
43 on_event: Option<AgentHandler>,
44}
45
46impl AgentBar {
47 pub fn new(
49 id: impl Into<SharedString>,
50 components: impl IntoIterator<Item = AgentComponent>,
51 ) -> Self {
52 Self {
53 id: id.into(),
54 components: components.into_iter().collect(),
55 on_event: None,
56 }
57 }
58
59 pub fn on_event(
61 mut self,
62 handler: impl Fn(AgentBarEvent, &mut Window, &mut App) + 'static,
63 ) -> Self {
64 self.on_event = Some(Rc::new(handler));
65 self
66 }
67}
68
69impl RenderOnce for AgentBar {
70 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
71 let handler = self.on_event;
72 let prefix = self.id;
73
74 let components = self
75 .components
76 .into_iter()
77 .enumerate()
78 .map(|(index, component)| {
79 component_button(&prefix, index, component, handler.clone(), cx)
80 })
81 .collect::<Vec<_>>();
82
83 let settings_handler = handler;
84 let settings_id = ElementId::Name(format!("{prefix}-settings").into());
85 h_flex()
86 .items_center()
87 .gap_1()
88 .px_2()
89 .py_1()
90 .children(components)
91 .child(div().flex_1())
92 .child(
93 Button::new(settings_id)
94 .icon(IconName::Settings)
95 .ghost()
96 .small()
97 .tooltip("Agent settings")
98 .on_click(move |_, window, cx| {
99 if let Some(handler) = &settings_handler {
100 handler(AgentBarEvent::OpenSettings, window, cx);
101 }
102 }),
103 )
104 }
105}
106
107fn component_button(
108 prefix: &SharedString,
109 index: usize,
110 component: AgentComponent,
111 handler: Option<AgentHandler>,
112 cx: &mut App,
113) -> Div {
114 let theme = cx.theme();
115 let color = status_color(component.status, cx);
116 let popover_id = ElementId::Name(format!("{prefix}-{index}-popover").into());
117 let trigger_id = ElementId::Name(format!("{prefix}-{index}-trigger").into());
118 let pulse_id = ElementId::Name(format!("{prefix}-{index}-pulse").into());
119
120 let tooltip = if let Some(detail) = &component.detail {
121 format!(
122 "{} · {}\n{}",
123 component.label,
124 component.status.label(),
125 detail
126 )
127 } else {
128 format!("{} · {}", component.label, component.status.label())
129 };
130
131 let content_component = component.clone();
132 let content_handler = handler.clone();
133 let content_prefix = prefix.clone();
134 let popover = Popover::new(popover_id)
135 .anchor(Anchor::TopLeft)
136 .trigger(
137 Button::new(trigger_id)
138 .icon(component.kind.icon())
139 .ghost()
140 .small()
141 .tooltip(tooltip),
142 )
143 .content(move |_, _, cx| {
144 let popover_entity = cx.entity();
145 let configure_handler = content_handler.clone();
146 let configure_id =
147 ElementId::Name(format!("{content_prefix}-{index}-configure").into());
148 let component = content_component.clone();
149 let id = component.id.clone();
150 let configure_popover = popover_entity.clone();
151
152 v_flex()
153 .w(px(280.))
154 .p_3()
155 .gap_2()
156 .child(
157 h_flex()
158 .gap_2()
159 .child(div().child(component.kind.icon()))
160 .child(
161 div()
162 .text_sm()
163 .font_weight(FontWeight::SEMIBOLD)
164 .child(component.label.clone()),
165 ),
166 )
167 .child(
168 div()
169 .text_sm()
170 .text_color(status_color(component.status, cx))
171 .child(component.status.label()),
172 )
173 .when_some(component.detail.clone(), |card, detail| {
174 card.child(
175 div()
176 .text_sm()
177 .text_color(cx.theme().muted_foreground)
178 .child(detail),
179 )
180 })
181 .child(
182 Button::new(configure_id)
183 .label("Configure")
184 .outline()
185 .small()
186 .on_click(move |_, window, cx| {
187 if let Some(handler) = &configure_handler {
188 handler(AgentBarEvent::Configure(id.clone()), window, cx);
189 }
190 configure_popover.update(cx, |state, cx| state.dismiss(window, cx));
191 }),
192 )
193 });
194
195 let dot = div()
196 .absolute()
197 .bottom(px(1.))
198 .right(px(1.))
199 .size(px(7.))
200 .rounded_full()
201 .border_1()
202 .border_color(theme.background)
203 .bg(color);
204
205 let dot: AnyElement = if component.status.is_busy() {
206 dot.with_animation(
207 pulse_id,
208 Animation::new(Duration::from_millis(900)).repeat(),
209 |dot, delta| dot.opacity(0.35 + 0.65 * (1.0 - delta)),
210 )
211 .into_any_element()
212 } else {
213 dot.into_any_element()
214 };
215
216 div().relative().child(popover).child(dot)
217}
218
219fn status_color(status: ComponentStatus, cx: &App) -> Hsla {
220 let theme = cx.theme();
221 match status {
222 ComponentStatus::Inactive => theme.muted_foreground,
223 ComponentStatus::Ready => theme.success,
224 ComponentStatus::Busy => theme.info,
225 ComponentStatus::Error => theme.danger,
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use crate::model::ComponentKind;
233
234 #[test]
235 fn a_bar_collects_components() {
236 let bar = AgentBar::new(
237 "agent",
238 [
239 AgentComponent::new("chat", ComponentKind::Chat, "DeepSeek"),
240 AgentComponent::new("embed", ComponentKind::Embedder, "nomic"),
241 ],
242 );
243 assert_eq!(bar.components.len(), 2);
244 }
245}