thread_item.rs

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