thread_item.rs

  1use crate::{
  2    DecoratedIcon, DiffStat, HighlightedLabel, IconDecoration, IconDecorationKind, SpinnerLabel,
  3    prelude::*,
  4};
  5
  6use gpui::{AnyView, ClickEvent, 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    title: SharedString,
 22    timestamp: SharedString,
 23    running: bool,
 24    generation_done: bool,
 25    status: AgentThreadStatus,
 26    selected: bool,
 27    hovered: bool,
 28    added: Option<usize>,
 29    removed: Option<usize>,
 30    worktree: Option<SharedString>,
 31    highlight_positions: Vec<usize>,
 32    worktree_highlight_positions: Vec<usize>,
 33    on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
 34    on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
 35    action_slot: Option<AnyElement>,
 36    tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
 37}
 38
 39impl ThreadItem {
 40    pub fn new(id: impl Into<ElementId>, title: impl Into<SharedString>) -> Self {
 41        Self {
 42            id: id.into(),
 43            icon: IconName::ZedAgent,
 44            title: title.into(),
 45            timestamp: "".into(),
 46            running: false,
 47            generation_done: false,
 48            status: AgentThreadStatus::default(),
 49            selected: false,
 50            hovered: false,
 51            added: None,
 52            removed: None,
 53            worktree: None,
 54            highlight_positions: Vec::new(),
 55            worktree_highlight_positions: Vec::new(),
 56            on_click: None,
 57            on_hover: Box::new(|_, _, _| {}),
 58            action_slot: None,
 59            tooltip: None,
 60        }
 61    }
 62
 63    pub fn timestamp(mut self, timestamp: impl Into<SharedString>) -> Self {
 64        self.timestamp = timestamp.into();
 65        self
 66    }
 67
 68    pub fn icon(mut self, icon: IconName) -> Self {
 69        self.icon = icon;
 70        self
 71    }
 72
 73    pub fn running(mut self, running: bool) -> Self {
 74        self.running = running;
 75        self
 76    }
 77
 78    pub fn generation_done(mut self, generation_done: bool) -> Self {
 79        self.generation_done = generation_done;
 80        self
 81    }
 82
 83    pub fn status(mut self, status: AgentThreadStatus) -> Self {
 84        self.status = status;
 85        self
 86    }
 87
 88    pub fn selected(mut self, selected: bool) -> Self {
 89        self.selected = selected;
 90        self
 91    }
 92
 93    pub fn added(mut self, added: usize) -> Self {
 94        self.added = Some(added);
 95        self
 96    }
 97
 98    pub fn removed(mut self, removed: usize) -> Self {
 99        self.removed = Some(removed);
100        self
101    }
102
103    pub fn worktree(mut self, worktree: impl Into<SharedString>) -> Self {
104        self.worktree = Some(worktree.into());
105        self
106    }
107
108    pub fn highlight_positions(mut self, positions: Vec<usize>) -> Self {
109        self.highlight_positions = positions;
110        self
111    }
112
113    pub fn worktree_highlight_positions(mut self, positions: Vec<usize>) -> Self {
114        self.worktree_highlight_positions = positions;
115        self
116    }
117
118    pub fn hovered(mut self, hovered: bool) -> Self {
119        self.hovered = hovered;
120        self
121    }
122
123    pub fn on_click(
124        mut self,
125        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
126    ) -> Self {
127        self.on_click = Some(Box::new(handler));
128        self
129    }
130
131    pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
132        self.on_hover = Box::new(on_hover);
133        self
134    }
135
136    pub fn action_slot(mut self, element: impl IntoElement) -> Self {
137        self.action_slot = Some(element.into_any_element());
138        self
139    }
140
141    pub fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self {
142        self.tooltip = Some(Box::new(tooltip));
143        self
144    }
145}
146
147impl RenderOnce for ThreadItem {
148    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
149        let clr = cx.theme().colors();
150        // let dot_separator = || {
151        //     Label::new("•")
152        //         .size(LabelSize::Small)
153        //         .color(Color::Muted)
154        //         .alpha(0.5)
155        // };
156
157        let icon_container = || h_flex().size_4().justify_center();
158        let agent_icon = Icon::new(self.icon)
159            .color(Color::Muted)
160            .size(IconSize::Small);
161
162        let decoration = if self.status == AgentThreadStatus::WaitingForConfirmation {
163            Some(
164                IconDecoration::new(
165                    IconDecorationKind::Triangle,
166                    cx.theme().colors().surface_background,
167                    cx,
168                )
169                .color(cx.theme().status().warning)
170                .position(gpui::Point {
171                    x: px(-2.),
172                    y: px(-2.),
173                }),
174            )
175        } else if self.status == AgentThreadStatus::Error {
176            Some(
177                IconDecoration::new(
178                    IconDecorationKind::X,
179                    cx.theme().colors().surface_background,
180                    cx,
181                )
182                .color(cx.theme().status().error)
183                .position(gpui::Point {
184                    x: px(-2.),
185                    y: px(-2.),
186                }),
187            )
188        } else if self.generation_done {
189            Some(
190                IconDecoration::new(
191                    IconDecorationKind::Dot,
192                    cx.theme().colors().surface_background,
193                    cx,
194                )
195                .color(cx.theme().colors().text_accent)
196                .position(gpui::Point {
197                    x: px(-2.),
198                    y: px(-2.),
199                }),
200            )
201        } else {
202            None
203        };
204
205        let icon = if let Some(decoration) = decoration {
206            icon_container().child(DecoratedIcon::new(agent_icon, Some(decoration)))
207        } else {
208            icon_container().child(agent_icon)
209        };
210
211        let running_or_action = self.running || (self.hovered && self.action_slot.is_some());
212
213        // let has_no_changes = self.added.is_none() && self.removed.is_none();
214
215        let title = self.title;
216        let highlight_positions = self.highlight_positions;
217        let title_label = if highlight_positions.is_empty() {
218            Label::new(title).truncate().into_any_element()
219        } else {
220            HighlightedLabel::new(title, highlight_positions)
221                .truncate()
222                .into_any_element()
223        };
224
225        v_flex()
226            .id(self.id.clone())
227            .cursor_pointer()
228            .map(|this| {
229                if self.worktree.is_some() {
230                    this.p_2()
231                } else {
232                    this.px_2().py_1()
233                }
234            })
235            .when(self.selected, |s| s.bg(clr.element_active))
236            .hover(|s| s.bg(clr.element_hover))
237            .on_hover(self.on_hover)
238            .child(
239                h_flex()
240                    .min_w_0()
241                    .w_full()
242                    .gap_2()
243                    .justify_between()
244                    .child(
245                        h_flex()
246                            .id("content")
247                            .min_w_0()
248                            .flex_1()
249                            .gap_1p5()
250                            .child(icon)
251                            .child(title_label)
252                            .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)),
253                    )
254                    .when(running_or_action, |this| {
255                        this.child(
256                            h_flex()
257                                .gap_1()
258                                .when(self.running, |this| {
259                                    this.child(
260                                        icon_container()
261                                            .child(SpinnerLabel::new().color(Color::Accent)),
262                                    )
263                                })
264                                .when(self.hovered, |this| {
265                                    this.when_some(self.action_slot, |this, slot| this.child(slot))
266                                }),
267                        )
268                    }),
269            )
270            .when_some(self.worktree, |this, worktree| {
271                let worktree_highlight_positions = self.worktree_highlight_positions;
272                let worktree_label = if worktree_highlight_positions.is_empty() {
273                    Label::new(worktree)
274                        .size(LabelSize::Small)
275                        .color(Color::Muted)
276                        .truncate_start()
277                        .into_any_element()
278                } else {
279                    HighlightedLabel::new(worktree, worktree_highlight_positions)
280                        .size(LabelSize::Small)
281                        .color(Color::Muted)
282                        .into_any_element()
283                };
284
285                this.child(
286                    h_flex()
287                        .min_w_0()
288                        .gap_1p5()
289                        .child(icon_container()) // Icon Spacing
290                        .child(worktree_label)
291                        // TODO: Uncomment the elements below when we're ready to expose this data
292                        // .child(dot_separator())
293                        // .child(
294                        //     Label::new(self.timestamp)
295                        //         .size(LabelSize::Small)
296                        //         .color(Color::Muted),
297                        // )
298                        // .child(
299                        //     Label::new("•")
300                        //         .size(LabelSize::Small)
301                        //         .color(Color::Muted)
302                        //         .alpha(0.5),
303                        // )
304                        // .when(has_no_changes, |this| {
305                        //     this.child(
306                        //         Label::new("No Changes")
307                        //             .size(LabelSize::Small)
308                        //             .color(Color::Muted),
309                        //     )
310                        // })
311                        .when(self.added.is_some() || self.removed.is_some(), |this| {
312                            this.child(DiffStat::new(
313                                self.id,
314                                self.added.unwrap_or(0),
315                                self.removed.unwrap_or(0),
316                            ))
317                        }),
318                )
319            })
320            .when_some(self.on_click, |this, on_click| this.on_click(on_click))
321    }
322}
323
324impl Component for ThreadItem {
325    fn scope() -> ComponentScope {
326        ComponentScope::Agent
327    }
328
329    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
330        let container = || {
331            v_flex()
332                .w_72()
333                .border_1()
334                .border_color(cx.theme().colors().border_variant)
335                .bg(cx.theme().colors().panel_background)
336        };
337
338        let thread_item_examples = vec![
339            single_example(
340                "Default",
341                container()
342                    .child(
343                        ThreadItem::new("ti-1", "Linking to the Agent Panel Depending on Settings")
344                            .icon(IconName::AiOpenAi)
345                            .timestamp("1:33 AM"),
346                    )
347                    .into_any_element(),
348            ),
349            single_example(
350                "Generation Done",
351                container()
352                    .child(
353                        ThreadItem::new("ti-2", "Refine thread view scrolling behavior")
354                            .timestamp("12:12 AM")
355                            .generation_done(true),
356                    )
357                    .into_any_element(),
358            ),
359            single_example(
360                "Waiting for Confirmation",
361                container()
362                    .child(
363                        ThreadItem::new("ti-2b", "Execute shell command in terminal")
364                            .timestamp("12:15 AM")
365                            .status(AgentThreadStatus::WaitingForConfirmation),
366                    )
367                    .into_any_element(),
368            ),
369            single_example(
370                "Error",
371                container()
372                    .child(
373                        ThreadItem::new("ti-2c", "Failed to connect to language server")
374                            .timestamp("12:20 AM")
375                            .status(AgentThreadStatus::Error),
376                    )
377                    .into_any_element(),
378            ),
379            single_example(
380                "Running Agent",
381                container()
382                    .child(
383                        ThreadItem::new("ti-3", "Add line numbers option to FileEditBlock")
384                            .icon(IconName::AiClaude)
385                            .timestamp("7:30 PM")
386                            .running(true),
387                    )
388                    .into_any_element(),
389            ),
390            single_example(
391                "In Worktree",
392                container()
393                    .child(
394                        ThreadItem::new("ti-4", "Add line numbers option to FileEditBlock")
395                            .icon(IconName::AiClaude)
396                            .timestamp("7:37 PM")
397                            .worktree("link-agent-panel"),
398                    )
399                    .into_any_element(),
400            ),
401            single_example(
402                "With Changes",
403                container()
404                    .child(
405                        ThreadItem::new("ti-5", "Managing user and project settings interactions")
406                            .icon(IconName::AiClaude)
407                            .timestamp("7:37 PM")
408                            .added(10)
409                            .removed(3),
410                    )
411                    .into_any_element(),
412            ),
413            single_example(
414                "Selected Item",
415                container()
416                    .child(
417                        ThreadItem::new("ti-6", "Refine textarea interaction behavior")
418                            .icon(IconName::AiGemini)
419                            .timestamp("3:00 PM")
420                            .selected(true),
421                    )
422                    .into_any_element(),
423            ),
424        ];
425
426        Some(
427            example_group(thread_item_examples)
428                .vertical()
429                .into_any_element(),
430        )
431    }
432}