thread_item.rs

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