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