thread_item.rs

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