thread_item.rs

  1use crate::{
  2    CommonAnimationExt, DecoratedIcon, DiffStat, GradientFade, HighlightedLabel, IconDecoration,
  3    IconDecorationKind, Tooltip, prelude::*,
  4};
  5
  6use gpui::{
  7    Animation, AnimationExt, AnyView, ClickEvent, Hsla, MouseButton, SharedString,
  8    pulsating_between,
  9};
 10use std::time::Duration;
 11
 12#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
 13pub enum AgentThreadStatus {
 14    #[default]
 15    Completed,
 16    Running,
 17    WaitingForConfirmation,
 18    Error,
 19}
 20
 21#[derive(IntoElement, RegisterComponent)]
 22pub struct ThreadItem {
 23    id: ElementId,
 24    icon: IconName,
 25    icon_color: Option<Color>,
 26    icon_visible: bool,
 27    custom_icon_from_external_svg: Option<SharedString>,
 28    title: SharedString,
 29    title_label_color: Option<Color>,
 30    title_generating: bool,
 31    highlight_positions: Vec<usize>,
 32    timestamp: SharedString,
 33    notified: bool,
 34    status: AgentThreadStatus,
 35    selected: bool,
 36    focused: bool,
 37    hovered: bool,
 38    added: Option<usize>,
 39    removed: Option<usize>,
 40    worktree: Option<SharedString>,
 41    worktree_full_path: Option<SharedString>,
 42    worktree_highlight_positions: Vec<usize>,
 43    on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
 44    on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
 45    action_slot: Option<AnyElement>,
 46    tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
 47}
 48
 49impl ThreadItem {
 50    pub fn new(id: impl Into<ElementId>, title: impl Into<SharedString>) -> Self {
 51        Self {
 52            id: id.into(),
 53            icon: IconName::ZedAgent,
 54            icon_color: None,
 55            icon_visible: true,
 56            custom_icon_from_external_svg: None,
 57            title: title.into(),
 58            title_label_color: None,
 59            title_generating: false,
 60            highlight_positions: Vec::new(),
 61            timestamp: "".into(),
 62            notified: false,
 63            status: AgentThreadStatus::default(),
 64            selected: false,
 65            focused: false,
 66            hovered: false,
 67            added: None,
 68            removed: None,
 69            worktree: None,
 70            worktree_full_path: None,
 71            worktree_highlight_positions: Vec::new(),
 72            on_click: None,
 73            on_hover: Box::new(|_, _, _| {}),
 74            action_slot: None,
 75            tooltip: None,
 76        }
 77    }
 78
 79    pub fn timestamp(mut self, timestamp: impl Into<SharedString>) -> Self {
 80        self.timestamp = timestamp.into();
 81        self
 82    }
 83
 84    pub fn icon(mut self, icon: IconName) -> Self {
 85        self.icon = icon;
 86        self
 87    }
 88
 89    pub fn icon_color(mut self, color: Color) -> Self {
 90        self.icon_color = Some(color);
 91        self
 92    }
 93
 94    pub fn icon_visible(mut self, visible: bool) -> Self {
 95        self.icon_visible = visible;
 96        self
 97    }
 98
 99    pub fn custom_icon_from_external_svg(mut self, svg: impl Into<SharedString>) -> Self {
100        self.custom_icon_from_external_svg = Some(svg.into());
101        self
102    }
103
104    pub fn notified(mut self, notified: bool) -> Self {
105        self.notified = notified;
106        self
107    }
108
109    pub fn status(mut self, status: AgentThreadStatus) -> Self {
110        self.status = status;
111        self
112    }
113
114    pub fn title_generating(mut self, generating: bool) -> Self {
115        self.title_generating = generating;
116        self
117    }
118
119    pub fn title_label_color(mut self, color: Color) -> Self {
120        self.title_label_color = Some(color);
121        self
122    }
123
124    pub fn highlight_positions(mut self, positions: Vec<usize>) -> Self {
125        self.highlight_positions = positions;
126        self
127    }
128
129    pub fn selected(mut self, selected: bool) -> Self {
130        self.selected = selected;
131        self
132    }
133
134    pub fn focused(mut self, focused: bool) -> Self {
135        self.focused = focused;
136        self
137    }
138
139    pub fn added(mut self, added: usize) -> Self {
140        self.added = Some(added);
141        self
142    }
143
144    pub fn removed(mut self, removed: usize) -> Self {
145        self.removed = Some(removed);
146        self
147    }
148
149    pub fn worktree(mut self, worktree: impl Into<SharedString>) -> Self {
150        self.worktree = Some(worktree.into());
151        self
152    }
153
154    pub fn worktree_full_path(mut self, worktree_full_path: impl Into<SharedString>) -> Self {
155        self.worktree_full_path = Some(worktree_full_path.into());
156        self
157    }
158
159    pub fn worktree_highlight_positions(mut self, positions: Vec<usize>) -> Self {
160        self.worktree_highlight_positions = positions;
161        self
162    }
163
164    pub fn hovered(mut self, hovered: bool) -> Self {
165        self.hovered = hovered;
166        self
167    }
168
169    pub fn on_click(
170        mut self,
171        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
172    ) -> Self {
173        self.on_click = Some(Box::new(handler));
174        self
175    }
176
177    pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
178        self.on_hover = Box::new(on_hover);
179        self
180    }
181
182    pub fn action_slot(mut self, element: impl IntoElement) -> Self {
183        self.action_slot = Some(element.into_any_element());
184        self
185    }
186
187    pub fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self {
188        self.tooltip = Some(Box::new(tooltip));
189        self
190    }
191}
192
193impl RenderOnce for ThreadItem {
194    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
195        let color = cx.theme().colors();
196        let base_bg = color
197            .title_bar_background
198            .blend(color.panel_background.opacity(0.2));
199
200        let base_bg = if self.selected {
201            color.element_active
202        } else {
203            base_bg
204        };
205
206        let hover_color = color
207            .element_active
208            .blend(color.element_background.opacity(0.2));
209
210        let gradient_overlay = GradientFade::new(base_bg, hover_color, hover_color)
211            .width(px(64.0))
212            .right(px(-10.0))
213            .gradient_stop(0.75)
214            .group_name("thread-item");
215
216        let dot_separator = || {
217            Label::new("")
218                .size(LabelSize::Small)
219                .color(Color::Muted)
220                .alpha(0.5)
221        };
222
223        let icon_id = format!("icon-{}", self.id);
224        let icon_visible = self.icon_visible;
225        let icon_container = || {
226            h_flex()
227                .id(icon_id.clone())
228                .size_4()
229                .flex_none()
230                .justify_center()
231                .when(!icon_visible, |this| this.invisible())
232        };
233        let icon_color = self.icon_color.unwrap_or(Color::Muted);
234        let agent_icon = if let Some(custom_svg) = self.custom_icon_from_external_svg {
235            Icon::from_external_svg(custom_svg)
236                .color(icon_color)
237                .size(IconSize::Small)
238        } else {
239            Icon::new(self.icon).color(icon_color).size(IconSize::Small)
240        };
241
242        let decoration = |icon: IconDecorationKind, color: Hsla| {
243            IconDecoration::new(icon, base_bg, cx)
244                .color(color)
245                .position(gpui::Point {
246                    x: px(-2.),
247                    y: px(-2.),
248                })
249        };
250
251        let (decoration, icon_tooltip) = if self.status == AgentThreadStatus::Error {
252            (
253                Some(decoration(IconDecorationKind::X, cx.theme().status().error)),
254                Some("Thread has an Error"),
255            )
256        } else if self.status == AgentThreadStatus::WaitingForConfirmation {
257            (
258                Some(decoration(
259                    IconDecorationKind::Triangle,
260                    cx.theme().status().warning,
261                )),
262                Some("Thread is Waiting for Confirmation"),
263            )
264        } else if self.notified {
265            (
266                Some(decoration(IconDecorationKind::Dot, color.text_accent)),
267                Some("Thread's Generation is Complete"),
268            )
269        } else {
270            (None, None)
271        };
272
273        let icon = if self.status == AgentThreadStatus::Running {
274            icon_container()
275                .child(
276                    Icon::new(IconName::LoadCircle)
277                        .size(IconSize::Small)
278                        .color(Color::Muted)
279                        .with_rotate_animation(2),
280                )
281                .into_any_element()
282        } else if let Some(decoration) = decoration {
283            icon_container()
284                .child(DecoratedIcon::new(agent_icon, Some(decoration)))
285                .when_some(icon_tooltip, |icon, tooltip| {
286                    icon.tooltip(Tooltip::text(tooltip))
287                })
288                .into_any_element()
289        } else {
290            icon_container().child(agent_icon).into_any_element()
291        };
292
293        let title = self.title;
294        let highlight_positions = self.highlight_positions;
295
296        let title_label = if self.title_generating {
297            Label::new(title)
298                .color(Color::Muted)
299                .with_animation(
300                    "generating-title",
301                    Animation::new(Duration::from_secs(2))
302                        .repeat()
303                        .with_easing(pulsating_between(0.4, 0.8)),
304                    |label, delta| label.alpha(delta),
305                )
306                .into_any_element()
307        } else if highlight_positions.is_empty() {
308            Label::new(title)
309                .when_some(self.title_label_color, |label, color| label.color(color))
310                .into_any_element()
311        } else {
312            HighlightedLabel::new(title, highlight_positions)
313                .when_some(self.title_label_color, |label, color| label.color(color))
314                .into_any_element()
315        };
316
317        let has_diff_stats = self.added.is_some() || self.removed.is_some();
318        let diff_stat_id = self.id.clone();
319        let added_count = self.added.unwrap_or(0);
320        let removed_count = self.removed.unwrap_or(0);
321
322        let has_worktree = self.worktree.is_some();
323        let has_timestamp = !self.timestamp.is_empty();
324        let timestamp = self.timestamp;
325
326        v_flex()
327            .id(self.id.clone())
328            .cursor_pointer()
329            .group("thread-item")
330            .relative()
331            .overflow_hidden()
332            .w_full()
333            .py_1()
334            .px_1p5()
335            .when(self.selected, |s| s.bg(color.element_active))
336            .border_1()
337            .border_color(gpui::transparent_black())
338            .when(self.focused, |s| s.border_color(color.border_focused))
339            .hover(|s| s.bg(hover_color))
340            .on_hover(self.on_hover)
341            .child(
342                h_flex()
343                    .min_w_0()
344                    .w_full()
345                    .gap_2()
346                    .justify_between()
347                    .child(
348                        h_flex()
349                            .id("content")
350                            .min_w_0()
351                            .flex_1()
352                            .gap_1p5()
353                            .child(icon)
354                            .child(title_label)
355                            .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)),
356                    )
357                    .child(gradient_overlay)
358                    .when(self.hovered, |this| {
359                        this.when_some(self.action_slot, |this, slot| {
360                            let overlay = GradientFade::new(base_bg, hover_color, hover_color)
361                                .width(px(64.0))
362                                .right(px(6.))
363                                .gradient_stop(0.75)
364                                .group_name("thread-item");
365
366                            this.child(
367                                h_flex()
368                                    .relative()
369                                    .on_mouse_down(MouseButton::Left, |_, _, cx| {
370                                        cx.stop_propagation()
371                                    })
372                                    .child(overlay)
373                                    .child(slot),
374                            )
375                        })
376                    }),
377            )
378            .when(has_worktree || has_diff_stats || has_timestamp, |this| {
379                let worktree_full_path = self.worktree_full_path.clone().unwrap_or_default();
380                let worktree_label = self.worktree.map(|worktree| {
381                    let positions = self.worktree_highlight_positions;
382                    if positions.is_empty() {
383                        Label::new(worktree)
384                            .size(LabelSize::Small)
385                            .color(Color::Muted)
386                            .into_any_element()
387                    } else {
388                        HighlightedLabel::new(worktree, positions)
389                            .size(LabelSize::Small)
390                            .color(Color::Muted)
391                            .into_any_element()
392                    }
393                });
394
395                this.child(
396                    h_flex()
397                        .min_w_0()
398                        .gap_1p5()
399                        .child(icon_container()) // Icon Spacing
400                        .when_some(worktree_label, |this, label| {
401                            this.child(
402                                h_flex()
403                                    .id(format!("{}-worktree", self.id.clone()))
404                                    .gap_0p5()
405                                    .child(
406                                        Icon::new(IconName::GitWorktree)
407                                            .size(IconSize::XSmall)
408                                            .color(Color::Muted),
409                                    )
410                                    .child(label)
411                                    .tooltip(move |_, cx| {
412                                        Tooltip::with_meta(
413                                            "Thread Running in a Local Git Worktree",
414                                            None,
415                                            worktree_full_path.clone(),
416                                            cx,
417                                        )
418                                    }),
419                            )
420                        })
421                        .when(has_worktree && (has_diff_stats || has_timestamp), |this| {
422                            this.child(dot_separator())
423                        })
424                        .when(has_diff_stats, |this| {
425                            this.child(
426                                DiffStat::new(diff_stat_id, added_count, removed_count)
427                                    .tooltip("Unreviewed changes"),
428                            )
429                        })
430                        .when(has_diff_stats && has_timestamp, |this| {
431                            this.child(dot_separator())
432                        })
433                        .when(has_timestamp, |this| {
434                            this.child(
435                                Label::new(timestamp.clone())
436                                    .size(LabelSize::Small)
437                                    .color(Color::Muted),
438                            )
439                        }),
440                )
441            })
442            .when_some(self.on_click, |this, on_click| this.on_click(on_click))
443    }
444}
445
446impl Component for ThreadItem {
447    fn scope() -> ComponentScope {
448        ComponentScope::Agent
449    }
450
451    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
452        let container = || {
453            v_flex()
454                .w_72()
455                .border_1()
456                .border_color(cx.theme().colors().border_variant)
457                .bg(cx.theme().colors().panel_background)
458        };
459
460        let thread_item_examples = vec![
461            single_example(
462                "Default (minutes)",
463                container()
464                    .child(
465                        ThreadItem::new("ti-1", "Linking to the Agent Panel Depending on Settings")
466                            .icon(IconName::AiOpenAi)
467                            .timestamp("15m"),
468                    )
469                    .into_any_element(),
470            ),
471            single_example(
472                "Timestamp Only (hours)",
473                container()
474                    .child(
475                        ThreadItem::new("ti-1b", "Thread with just a timestamp")
476                            .icon(IconName::AiClaude)
477                            .timestamp("3h"),
478                    )
479                    .into_any_element(),
480            ),
481            single_example(
482                "Notified (weeks)",
483                container()
484                    .child(
485                        ThreadItem::new("ti-2", "Refine thread view scrolling behavior")
486                            .timestamp("1w")
487                            .notified(true),
488                    )
489                    .into_any_element(),
490            ),
491            single_example(
492                "Waiting for Confirmation",
493                container()
494                    .child(
495                        ThreadItem::new("ti-2b", "Execute shell command in terminal")
496                            .timestamp("2h")
497                            .status(AgentThreadStatus::WaitingForConfirmation),
498                    )
499                    .into_any_element(),
500            ),
501            single_example(
502                "Error",
503                container()
504                    .child(
505                        ThreadItem::new("ti-2c", "Failed to connect to language server")
506                            .timestamp("5h")
507                            .status(AgentThreadStatus::Error),
508                    )
509                    .into_any_element(),
510            ),
511            single_example(
512                "Running Agent",
513                container()
514                    .child(
515                        ThreadItem::new("ti-3", "Add line numbers option to FileEditBlock")
516                            .icon(IconName::AiClaude)
517                            .timestamp("23h")
518                            .status(AgentThreadStatus::Running),
519                    )
520                    .into_any_element(),
521            ),
522            single_example(
523                "In Worktree",
524                container()
525                    .child(
526                        ThreadItem::new("ti-4", "Add line numbers option to FileEditBlock")
527                            .icon(IconName::AiClaude)
528                            .timestamp("2w")
529                            .worktree("link-agent-panel"),
530                    )
531                    .into_any_element(),
532            ),
533            single_example(
534                "With Changes (months)",
535                container()
536                    .child(
537                        ThreadItem::new("ti-5", "Managing user and project settings interactions")
538                            .icon(IconName::AiClaude)
539                            .timestamp("1mo")
540                            .added(10)
541                            .removed(3),
542                    )
543                    .into_any_element(),
544            ),
545            single_example(
546                "Worktree + Changes + Timestamp",
547                container()
548                    .child(
549                        ThreadItem::new("ti-5b", "Full metadata example")
550                            .icon(IconName::AiClaude)
551                            .worktree("my-project")
552                            .added(42)
553                            .removed(17)
554                            .timestamp("3w"),
555                    )
556                    .into_any_element(),
557            ),
558            single_example(
559                "Selected Item",
560                container()
561                    .child(
562                        ThreadItem::new("ti-6", "Refine textarea interaction behavior")
563                            .icon(IconName::AiGemini)
564                            .timestamp("45m")
565                            .selected(true),
566                    )
567                    .into_any_element(),
568            ),
569            single_example(
570                "Focused Item (Keyboard Selection)",
571                container()
572                    .child(
573                        ThreadItem::new("ti-7", "Implement keyboard navigation")
574                            .icon(IconName::AiClaude)
575                            .timestamp("12h")
576                            .focused(true),
577                    )
578                    .into_any_element(),
579            ),
580            single_example(
581                "Selected + Focused",
582                container()
583                    .child(
584                        ThreadItem::new("ti-8", "Active and keyboard-focused thread")
585                            .icon(IconName::AiGemini)
586                            .timestamp("2mo")
587                            .selected(true)
588                            .focused(true),
589                    )
590                    .into_any_element(),
591            ),
592            single_example(
593                "Hovered with Action Slot",
594                container()
595                    .child(
596                        ThreadItem::new("ti-9", "Hover to see action button")
597                            .icon(IconName::AiClaude)
598                            .timestamp("6h")
599                            .hovered(true)
600                            .action_slot(
601                                IconButton::new("delete", IconName::Trash)
602                                    .icon_size(IconSize::Small)
603                                    .icon_color(Color::Muted),
604                            ),
605                    )
606                    .into_any_element(),
607            ),
608            single_example(
609                "Search Highlight",
610                container()
611                    .child(
612                        ThreadItem::new("ti-10", "Implement keyboard navigation")
613                            .icon(IconName::AiClaude)
614                            .timestamp("4w")
615                            .highlight_positions(vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9]),
616                    )
617                    .into_any_element(),
618            ),
619            single_example(
620                "Worktree Search Highlight",
621                container()
622                    .child(
623                        ThreadItem::new("ti-11", "Search in worktree name")
624                            .icon(IconName::AiClaude)
625                            .timestamp("3mo")
626                            .worktree("my-project-name")
627                            .worktree_highlight_positions(vec![3, 4, 5, 6, 7, 8, 9, 10, 11]),
628                    )
629                    .into_any_element(),
630            ),
631        ];
632
633        Some(
634            example_group(thread_item_examples)
635                .vertical()
636                .into_any_element(),
637        )
638    }
639}