thread_item.rs

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