thread_item.rs

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