ui: Add `ThreadItem` component (#43167)

Danilo Leal created

Release Notes:

- N/A

Change summary

crates/ui/src/components.rs             |   2 
crates/ui/src/components/thread_item.rs | 214 +++++++++++++++++++++++++++
crates/zed/src/zed/component_preview.rs |   9 
3 files changed, 221 insertions(+), 4 deletions(-)

Detailed changes

crates/ui/src/components.rs 🔗

@@ -35,6 +35,7 @@ mod stack;
 mod sticky_items;
 mod tab;
 mod tab_bar;
+mod thread_item;
 mod toggle;
 mod tooltip;
 mod tree_view_item;
@@ -79,6 +80,7 @@ pub use stack::*;
 pub use sticky_items::*;
 pub use tab::*;
 pub use tab_bar::*;
+pub use thread_item::*;
 pub use toggle::*;
 pub use tooltip::*;
 pub use tree_view_item::*;

crates/ui/src/components/thread_item.rs 🔗

@@ -0,0 +1,214 @@
+use crate::{Chip, Indicator, SpinnerLabel, prelude::*};
+use gpui::{ClickEvent, SharedString};
+
+#[derive(IntoElement, RegisterComponent)]
+pub struct ThreadItem {
+    id: ElementId,
+    icon: IconName,
+    title: SharedString,
+    timestamp: SharedString,
+    running: bool,
+    generation_done: bool,
+    selected: bool,
+    has_changes: bool,
+    worktree: Option<SharedString>,
+    on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
+}
+
+impl ThreadItem {
+    pub fn new(id: impl Into<ElementId>, title: impl Into<SharedString>) -> Self {
+        Self {
+            id: id.into(),
+            icon: IconName::ZedAgent,
+            title: title.into(),
+            timestamp: "".into(),
+            running: false,
+            generation_done: false,
+            selected: false,
+            has_changes: false,
+            worktree: None,
+            on_click: None,
+        }
+    }
+
+    pub fn timestamp(mut self, timestamp: impl Into<SharedString>) -> Self {
+        self.timestamp = timestamp.into();
+        self
+    }
+
+    pub fn icon(mut self, icon: IconName) -> Self {
+        self.icon = icon;
+        self
+    }
+
+    pub fn running(mut self, running: bool) -> Self {
+        self.running = running;
+        self
+    }
+
+    pub fn generation_done(mut self, generation_done: bool) -> Self {
+        self.generation_done = generation_done;
+        self
+    }
+
+    pub fn selected(mut self, selected: bool) -> Self {
+        self.selected = selected;
+        self
+    }
+
+    pub fn has_changes(mut self, has_changes: bool) -> Self {
+        self.has_changes = has_changes;
+        self
+    }
+
+    pub fn worktree(mut self, worktree: impl Into<SharedString>) -> Self {
+        self.worktree = Some(worktree.into());
+        self
+    }
+
+    pub fn on_click(
+        mut self,
+        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+    ) -> Self {
+        self.on_click = Some(Box::new(handler));
+        self
+    }
+}
+
+impl RenderOnce for ThreadItem {
+    fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
+        let icon_container = || h_flex().size_4().justify_center();
+        let icon = if self.generation_done {
+            icon_container().child(Indicator::dot().color(Color::Accent))
+        } else if self.running {
+            icon_container().child(SpinnerLabel::new().color(Color::Accent))
+        } else {
+            icon_container().child(
+                Icon::new(self.icon)
+                    .color(Color::Muted)
+                    .size(IconSize::Small),
+            )
+        };
+
+        v_flex()
+            .id(self.id)
+            .cursor_pointer()
+            .p_2()
+            .when(self.selected, |this| {
+                this.bg(cx.theme().colors().element_active)
+            })
+            .hover(|s| s.bg(cx.theme().colors().element_hover))
+            .child(
+                h_flex()
+                    .w_full()
+                    .gap_1p5()
+                    .child(icon)
+                    .child(Label::new(self.title).truncate()),
+            )
+            .child(
+                h_flex()
+                    .gap_1p5()
+                    .child(icon_container()) // Icon Spacing
+                    .when_some(self.worktree, |this, name| {
+                        this.child(Chip::new(name).label_size(LabelSize::XSmall))
+                    })
+                    .child(
+                        Label::new(self.timestamp)
+                            .size(LabelSize::Small)
+                            .color(Color::Muted),
+                    )
+                    .child(
+                        Label::new("•")
+                            .size(LabelSize::Small)
+                            .color(Color::Muted)
+                            .alpha(0.5),
+                    )
+                    .when(!self.has_changes, |this| {
+                        this.child(
+                            Label::new("No Changes")
+                                .size(LabelSize::Small)
+                                .color(Color::Muted),
+                        )
+                    }),
+            )
+            .when_some(self.on_click, |this, on_click| this.on_click(on_click))
+    }
+}
+
+impl Component for ThreadItem {
+    fn scope() -> ComponentScope {
+        ComponentScope::Agent
+    }
+
+    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
+        let container = || {
+            v_flex()
+                .w_72()
+                .border_1()
+                .border_color(cx.theme().colors().border_variant)
+                .bg(cx.theme().colors().panel_background)
+        };
+
+        let thread_item_examples = vec![
+            single_example(
+                "Default",
+                container()
+                    .child(
+                        ThreadItem::new("ti-1", "Linking to the Agent Panel Depending on Settings")
+                            .icon(IconName::AiOpenAi)
+                            .timestamp("1:33 AM"),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "Generation Done",
+                container()
+                    .child(
+                        ThreadItem::new("ti-2", "Refine thread view scrolling behavior")
+                            .timestamp("12:12 AM")
+                            .generation_done(true),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "Running Agent",
+                container()
+                    .child(
+                        ThreadItem::new("ti-3", "Add line numbers option to FileEditBlock")
+                            .icon(IconName::AiClaude)
+                            .timestamp("7:30 PM")
+                            .running(true),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "In Worktree",
+                container()
+                    .child(
+                        ThreadItem::new("ti-4", "Add line numbers option to FileEditBlock")
+                            .icon(IconName::AiClaude)
+                            .timestamp("7:37 PM")
+                            .worktree("link-agent-panel"),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "Selected Item",
+                container()
+                    .child(
+                        ThreadItem::new("ti-5", "Refine textarea interaction behavior")
+                            .icon(IconName::AiGemini)
+                            .timestamp("3:00 PM")
+                            .selected(true),
+                    )
+                    .into_any_element(),
+            ),
+        ];
+
+        Some(
+            example_group(thread_item_examples)
+                .vertical()
+                .into_any_element(),
+        )
+    }
+}

crates/zed/src/zed/component_preview.rs 🔗

@@ -934,15 +934,16 @@ impl ComponentPreviewPage {
 
     fn render_header(&self, _: &Window, cx: &App) -> impl IntoElement {
         v_flex()
-            .py_12()
-            .px_16()
+            .min_w_0()
+            .w_full()
+            .p_12()
             .gap_6()
             .bg(cx.theme().colors().surface_background)
             .border_b_1()
             .border_color(cx.theme().colors().border)
             .child(
                 v_flex()
-                    .gap_0p5()
+                    .gap_1()
                     .child(
                         Label::new(self.component.scope().to_string())
                             .size(LabelSize::Small)
@@ -959,7 +960,7 @@ impl ComponentPreviewPage {
                     ),
             )
             .when_some(self.component.description(), |this, description| {
-                this.child(div().text_sm().child(description))
+                this.child(Label::new(description).size(LabelSize::Small))
             })
     }