thread_item.rs

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