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.32));
222
223        let raw_bg = self.base_bg.unwrap_or(sidebar_base_bg);
224        let apparent_bg = color.background.blend(raw_bg);
225
226        let base_bg = if self.selected {
227            apparent_bg.blend(color.element_active)
228        } else {
229            apparent_bg
230        };
231
232        let hover_color = color
233            .element_active
234            .blend(color.element_background.opacity(0.2));
235        let hover_bg = apparent_bg.blend(hover_color);
236
237        let gradient_overlay = GradientFade::new(base_bg, hover_bg, hover_bg)
238            .width(px(64.0))
239            .right(px(-10.0))
240            .gradient_stop(0.75)
241            .group_name("thread-item");
242
243        let dot_separator = || {
244            Label::new("")
245                .size(LabelSize::Small)
246                .color(Color::Muted)
247                .alpha(0.5)
248        };
249
250        let icon_id = format!("icon-{}", self.id);
251        let icon_visible = self.icon_visible;
252        let icon_container = || {
253            h_flex()
254                .id(icon_id.clone())
255                .size_4()
256                .flex_none()
257                .justify_center()
258                .when(!icon_visible, |this| this.invisible())
259        };
260        let icon_color = self.icon_color.unwrap_or(Color::Muted);
261        let agent_icon = if let Some(custom_svg) = self.custom_icon_from_external_svg {
262            Icon::from_external_svg(custom_svg)
263                .color(icon_color)
264                .size(IconSize::Small)
265        } else {
266            Icon::new(self.icon).color(icon_color).size(IconSize::Small)
267        };
268
269        let decoration = |icon: IconDecorationKind, color: Hsla| {
270            IconDecoration::new(icon, base_bg, cx)
271                .color(color)
272                .position(gpui::Point {
273                    x: px(-2.),
274                    y: px(-2.),
275                })
276        };
277
278        let (decoration, icon_tooltip) = if self.status == AgentThreadStatus::Error {
279            (
280                Some(decoration(IconDecorationKind::X, cx.theme().status().error)),
281                Some("Thread has an Error"),
282            )
283        } else if self.status == AgentThreadStatus::WaitingForConfirmation {
284            (
285                Some(decoration(
286                    IconDecorationKind::Triangle,
287                    cx.theme().status().warning,
288                )),
289                Some("Thread is Waiting for Confirmation"),
290            )
291        } else if self.notified {
292            (
293                Some(decoration(IconDecorationKind::Dot, color.text_accent)),
294                Some("Thread's Generation is Complete"),
295            )
296        } else {
297            (None, None)
298        };
299
300        let icon = if self.status == AgentThreadStatus::Running {
301            icon_container()
302                .child(
303                    Icon::new(IconName::LoadCircle)
304                        .size(IconSize::Small)
305                        .color(Color::Muted)
306                        .with_rotate_animation(2),
307                )
308                .into_any_element()
309        } else if let Some(decoration) = decoration {
310            icon_container()
311                .child(DecoratedIcon::new(agent_icon, Some(decoration)))
312                .when_some(icon_tooltip, |icon, tooltip| {
313                    icon.tooltip(Tooltip::text(tooltip))
314                })
315                .into_any_element()
316        } else {
317            icon_container().child(agent_icon).into_any_element()
318        };
319
320        let title = self.title;
321        let highlight_positions = self.highlight_positions;
322
323        let title_label = if self.title_generating {
324            Label::new(title)
325                .color(Color::Muted)
326                .with_animation(
327                    "generating-title",
328                    Animation::new(Duration::from_secs(2))
329                        .repeat()
330                        .with_easing(pulsating_between(0.4, 0.8)),
331                    |label, delta| label.alpha(delta),
332                )
333                .into_any_element()
334        } else if highlight_positions.is_empty() {
335            Label::new(title)
336                .when_some(self.title_label_color, |label, color| label.color(color))
337                .into_any_element()
338        } else {
339            HighlightedLabel::new(title, highlight_positions)
340                .when_some(self.title_label_color, |label, color| label.color(color))
341                .into_any_element()
342        };
343
344        let has_diff_stats = self.added.is_some() || self.removed.is_some();
345        let diff_stat_id = self.id.clone();
346        let added_count = self.added.unwrap_or(0);
347        let removed_count = self.removed.unwrap_or(0);
348
349        let project_paths = self.project_paths.as_ref().and_then(|paths| {
350            let paths_str = paths
351                .as_ref()
352                .iter()
353                .filter_map(|p| p.file_name())
354                .filter_map(|name| name.to_str())
355                .join(", ");
356            if paths_str.is_empty() {
357                None
358            } else {
359                Some(paths_str)
360            }
361        });
362
363        let has_project_name = self.project_name.is_some();
364        let has_project_paths = project_paths.is_some();
365        let has_worktree = !self.worktrees.is_empty();
366        let has_timestamp = !self.timestamp.is_empty();
367        let timestamp = self.timestamp;
368
369        v_flex()
370            .id(self.id.clone())
371            .cursor_pointer()
372            .group("thread-item")
373            .relative()
374            .overflow_hidden()
375            .w_full()
376            .py_1()
377            .px_1p5()
378            .when(self.selected, |s| s.bg(color.element_active))
379            .border_1()
380            .border_color(gpui::transparent_black())
381            .when(self.focused, |s| s.border_color(color.border_focused))
382            .when(self.rounded, |s| s.rounded_sm())
383            .hover(|s| s.bg(hover_color))
384            .on_hover(self.on_hover)
385            .child(
386                h_flex()
387                    .min_w_0()
388                    .w_full()
389                    .gap_2()
390                    .justify_between()
391                    .child(
392                        h_flex()
393                            .id("content")
394                            .min_w_0()
395                            .flex_1()
396                            .gap_1p5()
397                            .child(icon)
398                            .child(title_label)
399                            .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)),
400                    )
401                    .child(gradient_overlay)
402                    .when(self.hovered, |this| {
403                        this.when_some(self.action_slot, |this, slot| {
404                            let overlay = GradientFade::new(base_bg, hover_bg, hover_bg)
405                                .width(px(64.0))
406                                .right(px(6.))
407                                .gradient_stop(0.75)
408                                .group_name("thread-item");
409
410                            this.child(
411                                h_flex()
412                                    .relative()
413                                    .on_mouse_down(MouseButton::Left, |_, _, cx| {
414                                        cx.stop_propagation()
415                                    })
416                                    .child(overlay)
417                                    .child(slot),
418                            )
419                        })
420                    }),
421            )
422            .when(
423                has_project_name
424                    || has_project_paths
425                    || has_worktree
426                    || has_diff_stats
427                    || has_timestamp,
428                |this| {
429                    // Collect all full paths for the shared tooltip.
430                    let worktree_tooltip: SharedString = self
431                        .worktrees
432                        .iter()
433                        .map(|wt| wt.full_path.as_ref())
434                        .collect::<Vec<_>>()
435                        .join("\n")
436                        .into();
437
438                    let worktree_tooltip_title = if self.worktrees.len() > 1 {
439                        "Thread Running in Local Git Worktrees"
440                    } else {
441                        "Thread Running in a Local Git Worktree"
442                    };
443
444                    // Deduplicate chips by name — e.g. two paths both named
445                    // "olivetti" produce a single chip. Highlight positions
446                    // come from the first occurrence.
447                    let mut seen_names: Vec<SharedString> = Vec::new();
448                    let mut worktree_labels: Vec<AnyElement> = Vec::new();
449
450                    for wt in self.worktrees {
451                        if seen_names.contains(&wt.name) {
452                            continue;
453                        }
454
455                        let chip_index = seen_names.len();
456                        seen_names.push(wt.name.clone());
457
458                        let label = if wt.highlight_positions.is_empty() {
459                            Label::new(wt.name)
460                                .size(LabelSize::Small)
461                                .color(Color::Muted)
462                                .into_any_element()
463                        } else {
464                            HighlightedLabel::new(wt.name, wt.highlight_positions)
465                                .size(LabelSize::Small)
466                                .color(Color::Muted)
467                                .into_any_element()
468                        };
469                        let tooltip_title = worktree_tooltip_title;
470                        let tooltip_meta = worktree_tooltip.clone();
471
472                        worktree_labels.push(
473                            h_flex()
474                                .id(format!("{}-worktree-{chip_index}", self.id.clone()))
475                                .gap_0p5()
476                                .child(
477                                    Icon::new(IconName::GitWorktree)
478                                        .size(IconSize::XSmall)
479                                        .color(Color::Muted),
480                                )
481                                .child(label)
482                                .tooltip(move |_, cx| {
483                                    Tooltip::with_meta(
484                                        tooltip_title,
485                                        None,
486                                        tooltip_meta.clone(),
487                                        cx,
488                                    )
489                                })
490                                .into_any_element(),
491                        );
492                    }
493
494                    this.child(
495                        h_flex()
496                            .min_w_0()
497                            .gap_1p5()
498                            .child(icon_container()) // Icon Spacing
499                            .when_some(self.project_name, |this, name| {
500                                this.child(
501                                    Label::new(name).size(LabelSize::Small).color(Color::Muted),
502                                )
503                            })
504                            .when(
505                                has_project_name && (has_project_paths || has_worktree),
506                                |this| this.child(dot_separator()),
507                            )
508                            .when_some(project_paths, |this, paths| {
509                                this.child(
510                                    Label::new(paths)
511                                        .size(LabelSize::Small)
512                                        .color(Color::Muted)
513                                        .into_any_element(),
514                                )
515                            })
516                            .when(has_project_paths && has_worktree, |this| {
517                                this.child(dot_separator())
518                            })
519                            .children(worktree_labels)
520                            .when(
521                                (has_project_name || has_project_paths || has_worktree)
522                                    && (has_diff_stats || has_timestamp),
523                                |this| this.child(dot_separator()),
524                            )
525                            .when(has_diff_stats, |this| {
526                                this.child(
527                                    DiffStat::new(diff_stat_id, added_count, removed_count)
528                                        .tooltip("Unreviewed Changes"),
529                                )
530                            })
531                            .when(has_diff_stats && has_timestamp, |this| {
532                                this.child(dot_separator())
533                            })
534                            .when(has_timestamp, |this| {
535                                this.child(
536                                    Label::new(timestamp.clone())
537                                        .size(LabelSize::Small)
538                                        .color(Color::Muted),
539                                )
540                            }),
541                    )
542                },
543            )
544            .when_some(self.on_click, |this, on_click| this.on_click(on_click))
545    }
546}
547
548impl Component for ThreadItem {
549    fn scope() -> ComponentScope {
550        ComponentScope::Agent
551    }
552
553    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
554        let container = || {
555            v_flex()
556                .w_72()
557                .border_1()
558                .border_color(cx.theme().colors().border_variant)
559                .bg(cx.theme().colors().panel_background)
560        };
561
562        let thread_item_examples = vec![
563            single_example(
564                "Default (minutes)",
565                container()
566                    .child(
567                        ThreadItem::new("ti-1", "Linking to the Agent Panel Depending on Settings")
568                            .icon(IconName::AiOpenAi)
569                            .timestamp("15m"),
570                    )
571                    .into_any_element(),
572            ),
573            single_example(
574                "Timestamp Only (hours)",
575                container()
576                    .child(
577                        ThreadItem::new("ti-1b", "Thread with just a timestamp")
578                            .icon(IconName::AiClaude)
579                            .timestamp("3h"),
580                    )
581                    .into_any_element(),
582            ),
583            single_example(
584                "Notified (weeks)",
585                container()
586                    .child(
587                        ThreadItem::new("ti-2", "Refine thread view scrolling behavior")
588                            .timestamp("1w")
589                            .notified(true),
590                    )
591                    .into_any_element(),
592            ),
593            single_example(
594                "Waiting for Confirmation",
595                container()
596                    .child(
597                        ThreadItem::new("ti-2b", "Execute shell command in terminal")
598                            .timestamp("2h")
599                            .status(AgentThreadStatus::WaitingForConfirmation),
600                    )
601                    .into_any_element(),
602            ),
603            single_example(
604                "Error",
605                container()
606                    .child(
607                        ThreadItem::new("ti-2c", "Failed to connect to language server")
608                            .timestamp("5h")
609                            .status(AgentThreadStatus::Error),
610                    )
611                    .into_any_element(),
612            ),
613            single_example(
614                "Running Agent",
615                container()
616                    .child(
617                        ThreadItem::new("ti-3", "Add line numbers option to FileEditBlock")
618                            .icon(IconName::AiClaude)
619                            .timestamp("23h")
620                            .status(AgentThreadStatus::Running),
621                    )
622                    .into_any_element(),
623            ),
624            single_example(
625                "In Worktree",
626                container()
627                    .child(
628                        ThreadItem::new("ti-4", "Add line numbers option to FileEditBlock")
629                            .icon(IconName::AiClaude)
630                            .timestamp("2w")
631                            .worktrees(vec![ThreadItemWorktreeInfo {
632                                name: "link-agent-panel".into(),
633                                full_path: "link-agent-panel".into(),
634                                highlight_positions: Vec::new(),
635                            }]),
636                    )
637                    .into_any_element(),
638            ),
639            single_example(
640                "With Changes (months)",
641                container()
642                    .child(
643                        ThreadItem::new("ti-5", "Managing user and project settings interactions")
644                            .icon(IconName::AiClaude)
645                            .timestamp("1mo")
646                            .added(10)
647                            .removed(3),
648                    )
649                    .into_any_element(),
650            ),
651            single_example(
652                "Worktree + Changes + Timestamp",
653                container()
654                    .child(
655                        ThreadItem::new("ti-5b", "Full metadata example")
656                            .icon(IconName::AiClaude)
657                            .worktrees(vec![ThreadItemWorktreeInfo {
658                                name: "my-project".into(),
659                                full_path: "my-project".into(),
660                                highlight_positions: Vec::new(),
661                            }])
662                            .added(42)
663                            .removed(17)
664                            .timestamp("3w"),
665                    )
666                    .into_any_element(),
667            ),
668            single_example(
669                "Selected Item",
670                container()
671                    .child(
672                        ThreadItem::new("ti-6", "Refine textarea interaction behavior")
673                            .icon(IconName::AiGemini)
674                            .timestamp("45m")
675                            .selected(true),
676                    )
677                    .into_any_element(),
678            ),
679            single_example(
680                "Focused Item (Keyboard Selection)",
681                container()
682                    .child(
683                        ThreadItem::new("ti-7", "Implement keyboard navigation")
684                            .icon(IconName::AiClaude)
685                            .timestamp("12h")
686                            .focused(true),
687                    )
688                    .into_any_element(),
689            ),
690            single_example(
691                "Selected + Focused",
692                container()
693                    .child(
694                        ThreadItem::new("ti-8", "Active and keyboard-focused thread")
695                            .icon(IconName::AiGemini)
696                            .timestamp("2mo")
697                            .selected(true)
698                            .focused(true),
699                    )
700                    .into_any_element(),
701            ),
702            single_example(
703                "Hovered with Action Slot",
704                container()
705                    .child(
706                        ThreadItem::new("ti-9", "Hover to see action button")
707                            .icon(IconName::AiClaude)
708                            .timestamp("6h")
709                            .hovered(true)
710                            .action_slot(
711                                IconButton::new("delete", IconName::Trash)
712                                    .icon_size(IconSize::Small)
713                                    .icon_color(Color::Muted),
714                            ),
715                    )
716                    .into_any_element(),
717            ),
718            single_example(
719                "Search Highlight",
720                container()
721                    .child(
722                        ThreadItem::new("ti-10", "Implement keyboard navigation")
723                            .icon(IconName::AiClaude)
724                            .timestamp("4w")
725                            .highlight_positions(vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9]),
726                    )
727                    .into_any_element(),
728            ),
729            single_example(
730                "Worktree Search Highlight",
731                container()
732                    .child(
733                        ThreadItem::new("ti-11", "Search in worktree name")
734                            .icon(IconName::AiClaude)
735                            .timestamp("3mo")
736                            .worktrees(vec![ThreadItemWorktreeInfo {
737                                name: "my-project-name".into(),
738                                full_path: "my-project-name".into(),
739                                highlight_positions: vec![3, 4, 5, 6, 7, 8, 9, 10, 11],
740                            }]),
741                    )
742                    .into_any_element(),
743            ),
744        ];
745
746        Some(
747            example_group(thread_item_examples)
748                .vertical()
749                .into_any_element(),
750        )
751    }
752}