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