thread_item.rs

  1use crate::{
  2    DecoratedIcon, DiffStat, HighlightedLabel, IconDecoration, IconDecorationKind, SpinnerLabel,
  3    prelude::*,
  4};
  5
  6use gpui::{AnyView, ClickEvent, Hsla, SharedString};
  7
  8#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
  9pub enum AgentThreadStatus {
 10    #[default]
 11    Completed,
 12    Running,
 13    WaitingForConfirmation,
 14    Error,
 15}
 16
 17#[derive(IntoElement, RegisterComponent)]
 18pub struct ThreadItem {
 19    id: ElementId,
 20    icon: IconName,
 21    custom_icon_from_external_svg: Option<SharedString>,
 22    title: SharedString,
 23    timestamp: SharedString,
 24    notified: bool,
 25    status: AgentThreadStatus,
 26    selected: bool,
 27    hovered: bool,
 28    added: Option<usize>,
 29    removed: Option<usize>,
 30    worktree: Option<SharedString>,
 31    highlight_positions: Vec<usize>,
 32    worktree_highlight_positions: Vec<usize>,
 33    on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
 34    on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
 35    action_slot: Option<AnyElement>,
 36    tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
 37}
 38
 39impl ThreadItem {
 40    pub fn new(id: impl Into<ElementId>, title: impl Into<SharedString>) -> Self {
 41        Self {
 42            id: id.into(),
 43            icon: IconName::ZedAgent,
 44            custom_icon_from_external_svg: None,
 45            title: title.into(),
 46            timestamp: "".into(),
 47            notified: false,
 48            status: AgentThreadStatus::default(),
 49            selected: false,
 50            hovered: false,
 51            added: None,
 52            removed: None,
 53            worktree: None,
 54            highlight_positions: Vec::new(),
 55            worktree_highlight_positions: Vec::new(),
 56            on_click: None,
 57            on_hover: Box::new(|_, _, _| {}),
 58            action_slot: None,
 59            tooltip: None,
 60        }
 61    }
 62
 63    pub fn timestamp(mut self, timestamp: impl Into<SharedString>) -> Self {
 64        self.timestamp = timestamp.into();
 65        self
 66    }
 67
 68    pub fn icon(mut self, icon: IconName) -> Self {
 69        self.icon = icon;
 70        self
 71    }
 72
 73    pub fn custom_icon_from_external_svg(mut self, svg: impl Into<SharedString>) -> Self {
 74        self.custom_icon_from_external_svg = Some(svg.into());
 75        self
 76    }
 77
 78    pub fn notified(mut self, notified: bool) -> Self {
 79        self.notified = notified;
 80        self
 81    }
 82
 83    pub fn status(mut self, status: AgentThreadStatus) -> Self {
 84        self.status = status;
 85        self
 86    }
 87
 88    pub fn selected(mut self, selected: bool) -> Self {
 89        self.selected = selected;
 90        self
 91    }
 92
 93    pub fn added(mut self, added: usize) -> Self {
 94        self.added = Some(added);
 95        self
 96    }
 97
 98    pub fn removed(mut self, removed: usize) -> Self {
 99        self.removed = Some(removed);
100        self
101    }
102
103    pub fn worktree(mut self, worktree: impl Into<SharedString>) -> Self {
104        self.worktree = Some(worktree.into());
105        self
106    }
107
108    pub fn highlight_positions(mut self, positions: Vec<usize>) -> Self {
109        self.highlight_positions = positions;
110        self
111    }
112
113    pub fn worktree_highlight_positions(mut self, positions: Vec<usize>) -> Self {
114        self.worktree_highlight_positions = positions;
115        self
116    }
117
118    pub fn hovered(mut self, hovered: bool) -> Self {
119        self.hovered = hovered;
120        self
121    }
122
123    pub fn on_click(
124        mut self,
125        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
126    ) -> Self {
127        self.on_click = Some(Box::new(handler));
128        self
129    }
130
131    pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
132        self.on_hover = Box::new(on_hover);
133        self
134    }
135
136    pub fn action_slot(mut self, element: impl IntoElement) -> Self {
137        self.action_slot = Some(element.into_any_element());
138        self
139    }
140
141    pub fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self {
142        self.tooltip = Some(Box::new(tooltip));
143        self
144    }
145}
146
147impl RenderOnce for ThreadItem {
148    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
149        let clr = cx.theme().colors();
150        // let dot_separator = || {
151        //     Label::new("•")
152        //         .size(LabelSize::Small)
153        //         .color(Color::Muted)
154        //         .alpha(0.5)
155        // };
156
157        let icon_container = || h_flex().size_4().justify_center();
158        let agent_icon = if let Some(custom_svg) = self.custom_icon_from_external_svg {
159            Icon::from_external_svg(custom_svg)
160                .color(Color::Muted)
161                .size(IconSize::Small)
162        } else {
163            Icon::new(self.icon)
164                .color(Color::Muted)
165                .size(IconSize::Small)
166        };
167
168        let decoration = |icon: IconDecorationKind, color: Hsla| {
169            IconDecoration::new(icon, cx.theme().colors().surface_background, cx)
170                .color(color)
171                .position(gpui::Point {
172                    x: px(-2.),
173                    y: px(-2.),
174                })
175        };
176
177        let decoration = if self.status == AgentThreadStatus::WaitingForConfirmation {
178            Some(decoration(
179                IconDecorationKind::Triangle,
180                cx.theme().status().warning,
181            ))
182        } else if self.status == AgentThreadStatus::Error {
183            Some(decoration(IconDecorationKind::X, cx.theme().status().error))
184        } else if self.notified {
185            Some(decoration(IconDecorationKind::Dot, clr.text_accent))
186        } else {
187            None
188        };
189
190        let icon = if let Some(decoration) = decoration {
191            icon_container().child(DecoratedIcon::new(agent_icon, Some(decoration)))
192        } else {
193            icon_container().child(agent_icon)
194        };
195
196        let is_running = matches!(
197            self.status,
198            AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
199        );
200        let running_or_action = is_running || (self.hovered && self.action_slot.is_some());
201
202        let title = self.title;
203        let highlight_positions = self.highlight_positions;
204        let title_label = if highlight_positions.is_empty() {
205            Label::new(title).truncate().into_any_element()
206        } else {
207            HighlightedLabel::new(title, highlight_positions)
208                .truncate()
209                .into_any_element()
210        };
211
212        v_flex()
213            .id(self.id.clone())
214            .cursor_pointer()
215            .w_full()
216            .map(|this| {
217                if self.worktree.is_some() {
218                    this.p_2()
219                } else {
220                    this.px_2().py_1()
221                }
222            })
223            .when(self.selected, |s| s.bg(clr.element_active))
224            .hover(|s| s.bg(clr.element_hover))
225            .on_hover(self.on_hover)
226            .child(
227                h_flex()
228                    .min_w_0()
229                    .w_full()
230                    .gap_2()
231                    .justify_between()
232                    .child(
233                        h_flex()
234                            .id("content")
235                            .min_w_0()
236                            .flex_1()
237                            .gap_1p5()
238                            .child(icon)
239                            .child(title_label)
240                            .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)),
241                    )
242                    .when(running_or_action, |this| {
243                        this.child(
244                            h_flex()
245                                .gap_1()
246                                .when(is_running, |this| {
247                                    this.child(
248                                        icon_container()
249                                            .child(SpinnerLabel::new().color(Color::Accent)),
250                                    )
251                                })
252                                .when(self.hovered, |this| {
253                                    this.when_some(self.action_slot, |this, slot| this.child(slot))
254                                }),
255                        )
256                    }),
257            )
258            .when_some(self.worktree, |this, worktree| {
259                let worktree_highlight_positions = self.worktree_highlight_positions;
260                let worktree_label = if worktree_highlight_positions.is_empty() {
261                    Label::new(worktree)
262                        .size(LabelSize::Small)
263                        .color(Color::Muted)
264                        .truncate_start()
265                        .into_any_element()
266                } else {
267                    HighlightedLabel::new(worktree, worktree_highlight_positions)
268                        .size(LabelSize::Small)
269                        .color(Color::Muted)
270                        .into_any_element()
271                };
272
273                this.child(
274                    h_flex()
275                        .min_w_0()
276                        .gap_1p5()
277                        .child(icon_container()) // Icon Spacing
278                        .child(worktree_label)
279                        // TODO: Uncomment the elements below when we're ready to expose this data
280                        // .child(dot_separator())
281                        // .child(
282                        //     Label::new(self.timestamp)
283                        //         .size(LabelSize::Small)
284                        //         .color(Color::Muted),
285                        // )
286                        // .child(
287                        //     Label::new("•")
288                        //         .size(LabelSize::Small)
289                        //         .color(Color::Muted)
290                        //         .alpha(0.5),
291                        // )
292                        // .when(has_no_changes, |this| {
293                        //     this.child(
294                        //         Label::new("No Changes")
295                        //             .size(LabelSize::Small)
296                        //             .color(Color::Muted),
297                        //     )
298                        // })
299                        .when(self.added.is_some() || self.removed.is_some(), |this| {
300                            this.child(DiffStat::new(
301                                self.id,
302                                self.added.unwrap_or(0),
303                                self.removed.unwrap_or(0),
304                            ))
305                        }),
306                )
307            })
308            .when_some(self.on_click, |this, on_click| this.on_click(on_click))
309    }
310}
311
312impl Component for ThreadItem {
313    fn scope() -> ComponentScope {
314        ComponentScope::Agent
315    }
316
317    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
318        let container = || {
319            v_flex()
320                .w_72()
321                .border_1()
322                .border_color(cx.theme().colors().border_variant)
323                .bg(cx.theme().colors().panel_background)
324        };
325
326        let thread_item_examples = vec![
327            single_example(
328                "Default",
329                container()
330                    .child(
331                        ThreadItem::new("ti-1", "Linking to the Agent Panel Depending on Settings")
332                            .icon(IconName::AiOpenAi)
333                            .timestamp("1:33 AM"),
334                    )
335                    .into_any_element(),
336            ),
337            single_example(
338                "Notified",
339                container()
340                    .child(
341                        ThreadItem::new("ti-2", "Refine thread view scrolling behavior")
342                            .timestamp("12:12 AM")
343                            .notified(true),
344                    )
345                    .into_any_element(),
346            ),
347            single_example(
348                "Waiting for Confirmation",
349                container()
350                    .child(
351                        ThreadItem::new("ti-2b", "Execute shell command in terminal")
352                            .timestamp("12:15 AM")
353                            .status(AgentThreadStatus::WaitingForConfirmation),
354                    )
355                    .into_any_element(),
356            ),
357            single_example(
358                "Error",
359                container()
360                    .child(
361                        ThreadItem::new("ti-2c", "Failed to connect to language server")
362                            .timestamp("12:20 AM")
363                            .status(AgentThreadStatus::Error),
364                    )
365                    .into_any_element(),
366            ),
367            single_example(
368                "Running Agent",
369                container()
370                    .child(
371                        ThreadItem::new("ti-3", "Add line numbers option to FileEditBlock")
372                            .icon(IconName::AiClaude)
373                            .timestamp("7:30 PM")
374                            .status(AgentThreadStatus::Running),
375                    )
376                    .into_any_element(),
377            ),
378            single_example(
379                "In Worktree",
380                container()
381                    .child(
382                        ThreadItem::new("ti-4", "Add line numbers option to FileEditBlock")
383                            .icon(IconName::AiClaude)
384                            .timestamp("7:37 PM")
385                            .worktree("link-agent-panel"),
386                    )
387                    .into_any_element(),
388            ),
389            single_example(
390                "With Changes",
391                container()
392                    .child(
393                        ThreadItem::new("ti-5", "Managing user and project settings interactions")
394                            .icon(IconName::AiClaude)
395                            .timestamp("7:37 PM")
396                            .added(10)
397                            .removed(3),
398                    )
399                    .into_any_element(),
400            ),
401            single_example(
402                "Selected Item",
403                container()
404                    .child(
405                        ThreadItem::new("ti-6", "Refine textarea interaction behavior")
406                            .icon(IconName::AiGemini)
407                            .timestamp("3:00 PM")
408                            .selected(true),
409                    )
410                    .into_any_element(),
411            ),
412        ];
413
414        Some(
415            example_group(thread_item_examples)
416                .vertical()
417                .into_any_element(),
418        )
419    }
420}