thread_item.rs

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