thread_item.rs

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