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