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