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