thread_item.rs

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