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