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