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_container = || h_flex().size_4().flex_none().justify_center();
190        let agent_icon = if let Some(custom_svg) = self.custom_icon_from_external_svg {
191            Icon::from_external_svg(custom_svg)
192                .color(Color::Muted)
193                .size(IconSize::Small)
194        } else {
195            Icon::new(self.icon)
196                .color(Color::Muted)
197                .size(IconSize::Small)
198        };
199
200        let decoration = |icon: IconDecorationKind, color: Hsla| {
201            IconDecoration::new(icon, cx.theme().colors().surface_background, cx)
202                .color(color)
203                .position(gpui::Point {
204                    x: px(-2.),
205                    y: px(-2.),
206                })
207        };
208
209        let decoration = if self.status == AgentThreadStatus::Error {
210            Some(decoration(IconDecorationKind::X, cx.theme().status().error))
211        } else if self.notified {
212            Some(decoration(IconDecorationKind::Dot, color.text_accent))
213        } else {
214            None
215        };
216
217        let icon = if self.status == AgentThreadStatus::Running {
218            icon_container()
219                .child(
220                    Icon::new(IconName::LoadCircle)
221                        .size(IconSize::Small)
222                        .color(Color::Muted)
223                        .with_rotate_animation(2),
224                )
225                .into_any_element()
226        } else if self.status == AgentThreadStatus::WaitingForConfirmation {
227            icon_container()
228                .id("waiting-for-confirmation")
229                .child(
230                    Icon::new(IconName::Warning)
231                        .size(IconSize::Small)
232                        .color(Color::Warning),
233                )
234                .tooltip(Tooltip::text("Waiting for confirmation"))
235                .into_any_element()
236        } else if let Some(decoration) = decoration {
237            icon_container()
238                .child(DecoratedIcon::new(agent_icon, Some(decoration)))
239                .into_any_element()
240        } else {
241            icon_container().child(agent_icon).into_any_element()
242        };
243
244        let title = self.title;
245        let highlight_positions = self.highlight_positions;
246        let title_label = if self.generating_title {
247            Label::new(title)
248                .color(Color::Muted)
249                .with_animation(
250                    "generating-title",
251                    Animation::new(Duration::from_secs(2))
252                        .repeat()
253                        .with_easing(pulsating_between(0.4, 0.8)),
254                    |label, delta| label.alpha(delta),
255                )
256                .into_any_element()
257        } else if highlight_positions.is_empty() {
258            let label = Label::new(title);
259            let label = if let Some(color) = self.title_label_color {
260                label.color(color)
261            } else {
262                label
263            };
264            label.into_any_element()
265        } else {
266            let label = HighlightedLabel::new(title, highlight_positions);
267            let label = if let Some(color) = self.title_label_color {
268                label.color(color)
269            } else {
270                label
271            };
272            label.into_any_element()
273        };
274
275        let b_bg = color
276            .title_bar_background
277            .blend(color.panel_background.opacity(0.8));
278
279        let base_bg = if self.selected {
280            color.element_active
281        } else {
282            b_bg
283        };
284
285        let gradient_overlay =
286            GradientFade::new(base_bg, color.element_hover, color.element_active)
287                .width(px(64.0))
288                .right(px(-10.0))
289                .gradient_stop(0.75)
290                .group_name("thread-item");
291
292        let has_diff_stats = self.added.is_some() || self.removed.is_some();
293        let added_count = self.added.unwrap_or(0);
294        let removed_count = self.removed.unwrap_or(0);
295        let diff_stat_id = self.id.clone();
296        let has_worktree = self.worktree.is_some();
297        let has_timestamp = !self.timestamp.is_empty();
298        let timestamp = self.timestamp;
299
300        v_flex()
301            .id(self.id.clone())
302            .group("thread-item")
303            .relative()
304            .overflow_hidden()
305            .cursor_pointer()
306            .w_full()
307            .p_1()
308            .when(self.selected, |s| s.bg(color.element_active))
309            .border_1()
310            .border_color(gpui::transparent_black())
311            .when(self.focused, |s| {
312                s.when(self.docked_right, |s| s.border_r_2())
313                    .border_color(color.border_focused)
314            })
315            .hover(|s| s.bg(color.element_hover))
316            .active(|s| s.bg(color.element_active))
317            .on_hover(self.on_hover)
318            .child(
319                h_flex()
320                    .min_w_0()
321                    .w_full()
322                    .gap_2()
323                    .justify_between()
324                    .child(
325                        h_flex()
326                            .id("content")
327                            .min_w_0()
328                            .flex_1()
329                            .gap_1p5()
330                            .child(icon)
331                            .child(title_label)
332                            .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)),
333                    )
334                    .child(gradient_overlay)
335                    .when(self.hovered, |this| {
336                        this.when_some(self.action_slot, |this, slot| {
337                            let overlay = GradientFade::new(
338                                base_bg,
339                                color.element_hover,
340                                color.element_active,
341                            )
342                            .width(px(64.0))
343                            .right(px(6.))
344                            .gradient_stop(0.75)
345                            .group_name("thread-item");
346
347                            this.child(
348                                h_flex()
349                                    .relative()
350                                    .on_mouse_down(MouseButton::Left, |_, _, cx| {
351                                        cx.stop_propagation()
352                                    })
353                                    .child(overlay)
354                                    .child(slot),
355                            )
356                        })
357                    }),
358            )
359            .when_some(self.worktree, |this, worktree| {
360                let worktree_highlight_positions = self.worktree_highlight_positions;
361                let worktree_label = if worktree_highlight_positions.is_empty() {
362                    Label::new(worktree)
363                        .size(LabelSize::Small)
364                        .color(Color::Muted)
365                        .into_any_element()
366                } else {
367                    HighlightedLabel::new(worktree, worktree_highlight_positions)
368                        .size(LabelSize::Small)
369                        .color(Color::Muted)
370                        .into_any_element()
371                };
372
373                this.child(
374                    h_flex()
375                        .min_w_0()
376                        .gap_1p5()
377                        .child(icon_container()) // Icon Spacing
378                        .child(worktree_label)
379                        .when(has_diff_stats || has_timestamp, |this| {
380                            this.child(dot_separator())
381                        })
382                        .when(has_diff_stats, |this| {
383                            this.child(
384                                DiffStat::new(diff_stat_id.clone(), added_count, removed_count)
385                                    .tooltip("Unreviewed changes"),
386                            )
387                        })
388                        .when(has_diff_stats && has_timestamp, |this| {
389                            this.child(dot_separator())
390                        })
391                        .when(has_timestamp, |this| {
392                            this.child(
393                                Label::new(timestamp.clone())
394                                    .size(LabelSize::Small)
395                                    .color(Color::Muted),
396                            )
397                        }),
398                )
399            })
400            .when(!has_worktree && (has_diff_stats || has_timestamp), |this| {
401                this.child(
402                    h_flex()
403                        .min_w_0()
404                        .gap_1p5()
405                        .child(icon_container()) // Icon Spacing
406                        .when(has_diff_stats, |this| {
407                            this.child(
408                                DiffStat::new(diff_stat_id, added_count, removed_count)
409                                    .tooltip("Unreviewed Changes"),
410                            )
411                        })
412                        .when(has_diff_stats && has_timestamp, |this| {
413                            this.child(dot_separator())
414                        })
415                        .when(has_timestamp, |this| {
416                            this.child(
417                                Label::new(timestamp.clone())
418                                    .size(LabelSize::Small)
419                                    .color(Color::Muted),
420                            )
421                        }),
422                )
423            })
424            .when_some(self.on_click, |this, on_click| this.on_click(on_click))
425    }
426}
427
428impl Component for ThreadItem {
429    fn scope() -> ComponentScope {
430        ComponentScope::Agent
431    }
432
433    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
434        let container = || {
435            v_flex()
436                .w_72()
437                .border_1()
438                .border_color(cx.theme().colors().border_variant)
439                .bg(cx.theme().colors().panel_background)
440        };
441
442        let thread_item_examples = vec![
443            single_example(
444                "Default (minutes)",
445                container()
446                    .child(
447                        ThreadItem::new("ti-1", "Linking to the Agent Panel Depending on Settings")
448                            .icon(IconName::AiOpenAi)
449                            .timestamp("15m"),
450                    )
451                    .into_any_element(),
452            ),
453            single_example(
454                "Timestamp Only (hours)",
455                container()
456                    .child(
457                        ThreadItem::new("ti-1b", "Thread with just a timestamp")
458                            .icon(IconName::AiClaude)
459                            .timestamp("3h"),
460                    )
461                    .into_any_element(),
462            ),
463            single_example(
464                "Notified (weeks)",
465                container()
466                    .child(
467                        ThreadItem::new("ti-2", "Refine thread view scrolling behavior")
468                            .timestamp("1w")
469                            .notified(true),
470                    )
471                    .into_any_element(),
472            ),
473            single_example(
474                "Waiting for Confirmation",
475                container()
476                    .child(
477                        ThreadItem::new("ti-2b", "Execute shell command in terminal")
478                            .timestamp("2h")
479                            .status(AgentThreadStatus::WaitingForConfirmation),
480                    )
481                    .into_any_element(),
482            ),
483            single_example(
484                "Error",
485                container()
486                    .child(
487                        ThreadItem::new("ti-2c", "Failed to connect to language server")
488                            .timestamp("5h")
489                            .status(AgentThreadStatus::Error),
490                    )
491                    .into_any_element(),
492            ),
493            single_example(
494                "Running Agent",
495                container()
496                    .child(
497                        ThreadItem::new("ti-3", "Add line numbers option to FileEditBlock")
498                            .icon(IconName::AiClaude)
499                            .timestamp("23h")
500                            .status(AgentThreadStatus::Running),
501                    )
502                    .into_any_element(),
503            ),
504            single_example(
505                "In Worktree",
506                container()
507                    .child(
508                        ThreadItem::new("ti-4", "Add line numbers option to FileEditBlock")
509                            .icon(IconName::AiClaude)
510                            .timestamp("2w")
511                            .worktree("link-agent-panel"),
512                    )
513                    .into_any_element(),
514            ),
515            single_example(
516                "With Changes (months)",
517                container()
518                    .child(
519                        ThreadItem::new("ti-5", "Managing user and project settings interactions")
520                            .icon(IconName::AiClaude)
521                            .timestamp("1mo")
522                            .added(10)
523                            .removed(3),
524                    )
525                    .into_any_element(),
526            ),
527            single_example(
528                "Worktree + Changes + Timestamp",
529                container()
530                    .child(
531                        ThreadItem::new("ti-5b", "Full metadata example")
532                            .icon(IconName::AiClaude)
533                            .worktree("my-project")
534                            .added(42)
535                            .removed(17)
536                            .timestamp("3w"),
537                    )
538                    .into_any_element(),
539            ),
540            single_example(
541                "Selected Item",
542                container()
543                    .child(
544                        ThreadItem::new("ti-6", "Refine textarea interaction behavior")
545                            .icon(IconName::AiGemini)
546                            .timestamp("45m")
547                            .selected(true),
548                    )
549                    .into_any_element(),
550            ),
551            single_example(
552                "Focused Item (Keyboard Selection)",
553                container()
554                    .child(
555                        ThreadItem::new("ti-7", "Implement keyboard navigation")
556                            .icon(IconName::AiClaude)
557                            .timestamp("12h")
558                            .focused(true),
559                    )
560                    .into_any_element(),
561            ),
562            single_example(
563                "Focused + Docked Right",
564                container()
565                    .child(
566                        ThreadItem::new("ti-7b", "Focused with right dock border")
567                            .icon(IconName::AiClaude)
568                            .timestamp("1w")
569                            .focused(true)
570                            .docked_right(true),
571                    )
572                    .into_any_element(),
573            ),
574            single_example(
575                "Selected + Focused",
576                container()
577                    .child(
578                        ThreadItem::new("ti-8", "Active and keyboard-focused thread")
579                            .icon(IconName::AiGemini)
580                            .timestamp("2mo")
581                            .selected(true)
582                            .focused(true),
583                    )
584                    .into_any_element(),
585            ),
586            single_example(
587                "Hovered with Action Slot",
588                container()
589                    .child(
590                        ThreadItem::new("ti-9", "Hover to see action button")
591                            .icon(IconName::AiClaude)
592                            .timestamp("6h")
593                            .hovered(true)
594                            .action_slot(
595                                IconButton::new("delete", IconName::Trash)
596                                    .icon_size(IconSize::Small)
597                                    .icon_color(Color::Muted),
598                            ),
599                    )
600                    .into_any_element(),
601            ),
602            single_example(
603                "Search Highlight",
604                container()
605                    .child(
606                        ThreadItem::new("ti-10", "Implement keyboard navigation")
607                            .icon(IconName::AiClaude)
608                            .timestamp("4w")
609                            .highlight_positions(vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9]),
610                    )
611                    .into_any_element(),
612            ),
613            single_example(
614                "Worktree Search Highlight",
615                container()
616                    .child(
617                        ThreadItem::new("ti-11", "Search in worktree name")
618                            .icon(IconName::AiClaude)
619                            .timestamp("3mo")
620                            .worktree("my-project-name")
621                            .worktree_highlight_positions(vec![3, 4, 5, 6, 7, 8, 9, 10, 11]),
622                    )
623                    .into_any_element(),
624            ),
625        ];
626
627        Some(
628            example_group(thread_item_examples)
629                .vertical()
630                .into_any_element(),
631        )
632    }
633}