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