ui: Add circular progress component (#49100)

Danilo Leal created

This PR adds a `CircularProgress` component, soon to be used in the
agent panel!

<img width="500" height="434" alt="Screenshot 2026-02-13 at 10  59@2x"
src="https://github.com/user-attachments/assets/3e64aa93-f63e-4abc-bf88-063e10a02389"
/>

Release Notes:

- N/A

Change summary

crates/feature_flags/src/flags.rs                      |   2 
crates/ui/src/components/progress.rs                   |   3 
crates/ui/src/components/progress/circular_progress.rs | 215 ++++++++++++
crates/ui/src/components/progress/progress_bar.rs      |  93 ++--
4 files changed, 260 insertions(+), 53 deletions(-)

Detailed changes

crates/feature_flags/src/flags.rs 🔗

@@ -40,7 +40,7 @@ impl FeatureFlag for SubagentsFeatureFlag {
     const NAME: &'static str = "subagents";
 
     fn enabled_for_staff() -> bool {
-        false
+        true
     }
 }
 

crates/ui/src/components/progress/circular_progress.rs 🔗

@@ -0,0 +1,215 @@
+use documented::Documented;
+use gpui::{Hsla, PathBuilder, canvas, point};
+use std::f32::consts::PI;
+
+use crate::prelude::*;
+
+/// A circular progress indicator that displays progress as an arc growing clockwise from the top.
+#[derive(IntoElement, RegisterComponent, Documented)]
+pub struct CircularProgress {
+    value: f32,
+    max_value: f32,
+    size: Pixels,
+    bg_color: Hsla,
+    progress_color: Hsla,
+}
+
+impl CircularProgress {
+    pub fn new(value: f32, max_value: f32, size: Pixels, cx: &App) -> Self {
+        Self {
+            value,
+            max_value,
+            size,
+            bg_color: cx.theme().colors().border_variant,
+            progress_color: cx.theme().status().info,
+        }
+    }
+
+    /// Sets the current progress value.
+    pub fn value(mut self, value: f32) -> Self {
+        self.value = value;
+        self
+    }
+
+    /// Sets the maximum value for the progress indicator.
+    pub fn max_value(mut self, max_value: f32) -> Self {
+        self.max_value = max_value;
+        self
+    }
+
+    /// Sets the size (diameter) of the circular progress indicator.
+    pub fn size(mut self, size: Pixels) -> Self {
+        self.size = size;
+        self
+    }
+
+    /// Sets the background circle color.
+    pub fn bg_color(mut self, color: Hsla) -> Self {
+        self.bg_color = color;
+        self
+    }
+
+    /// Sets the progress arc color.
+    pub fn progress_color(mut self, color: Hsla) -> Self {
+        self.progress_color = color;
+        self
+    }
+}
+
+impl RenderOnce for CircularProgress {
+    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
+        let value = self.value;
+        let max_value = self.max_value;
+        let size = self.size;
+        let bg_color = self.bg_color;
+        let progress_color = self.progress_color;
+
+        canvas(
+            |_, _, _| {},
+            move |bounds, _, window, _cx| {
+                let current_value = value;
+
+                let center_x = bounds.origin.x + bounds.size.width / 2.0;
+                let center_y = bounds.origin.y + bounds.size.height / 2.0;
+
+                let stroke_width = px(4.0);
+                let radius = (size / 2.0) - stroke_width;
+
+                // Draw background circle (full 360 degrees)
+                let mut bg_builder = PathBuilder::stroke(stroke_width);
+
+                // Start at rightmost point
+                bg_builder.move_to(point(center_x + radius, center_y));
+
+                // Draw full circle using two 180-degree arcs
+                bg_builder.arc_to(
+                    point(radius, radius),
+                    px(0.),
+                    false,
+                    true,
+                    point(center_x - radius, center_y),
+                );
+                bg_builder.arc_to(
+                    point(radius, radius),
+                    px(0.),
+                    false,
+                    true,
+                    point(center_x + radius, center_y),
+                );
+                bg_builder.close();
+
+                if let Ok(path) = bg_builder.build() {
+                    window.paint_path(path, bg_color);
+                }
+
+                // Draw progress arc if there's any progress
+                let progress = (current_value / max_value).clamp(0.0, 1.0);
+                if progress > 0.0 {
+                    let mut progress_builder = PathBuilder::stroke(stroke_width);
+
+                    // Handle 100% progress as a special case by drawing a full circle
+                    if progress >= 0.999 {
+                        // Start at rightmost point
+                        progress_builder.move_to(point(center_x + radius, center_y));
+
+                        // Draw full circle using two 180-degree arcs
+                        progress_builder.arc_to(
+                            point(radius, radius),
+                            px(0.),
+                            false,
+                            true,
+                            point(center_x - radius, center_y),
+                        );
+                        progress_builder.arc_to(
+                            point(radius, radius),
+                            px(0.),
+                            false,
+                            true,
+                            point(center_x + radius, center_y),
+                        );
+                        progress_builder.close();
+                    } else {
+                        // Start at 12 o'clock (top) position
+                        let start_x = center_x;
+                        let start_y = center_y - radius;
+                        progress_builder.move_to(point(start_x, start_y));
+
+                        // Calculate the end point of the arc based on progress
+                        // Progress sweeps clockwise from -90° (top)
+                        let angle = -PI / 2.0 + (progress * 2.0 * PI);
+                        let end_x = center_x + radius * angle.cos();
+                        let end_y = center_y + radius * angle.sin();
+
+                        // Use large_arc flag when progress > 0.5 (more than 180 degrees)
+                        let large_arc = progress > 0.5;
+
+                        progress_builder.arc_to(
+                            point(radius, radius),
+                            px(0.),
+                            large_arc,
+                            true, // sweep clockwise
+                            point(end_x, end_y),
+                        );
+                    }
+
+                    if let Ok(path) = progress_builder.build() {
+                        window.paint_path(path, progress_color);
+                    }
+                }
+            },
+        )
+        .size(size)
+    }
+}
+
+impl Component for CircularProgress {
+    fn scope() -> ComponentScope {
+        ComponentScope::Status
+    }
+
+    fn description() -> Option<&'static str> {
+        Some(
+            "A circular progress indicator that displays progress as an arc growing clockwise from the top.",
+        )
+    }
+
+    fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
+        let max_value = 100.0;
+        let container = || v_flex().items_center().gap_1();
+
+        Some(
+            example_group(vec![single_example(
+                "Examples",
+                h_flex()
+                    .gap_6()
+                    .child(
+                        container()
+                            .child(CircularProgress::new(0.0, max_value, px(48.0), cx))
+                            .child(Label::new("0%").size(LabelSize::Small)),
+                    )
+                    .child(
+                        container()
+                            .child(CircularProgress::new(25.0, max_value, px(48.0), cx))
+                            .child(Label::new("25%").size(LabelSize::Small)),
+                    )
+                    .child(
+                        container()
+                            .child(CircularProgress::new(50.0, max_value, px(48.0), cx))
+                            .child(Label::new("50%").size(LabelSize::Small)),
+                    )
+                    .child(
+                        container()
+                            .child(CircularProgress::new(75.0, max_value, px(48.0), cx))
+                            .child(Label::new("75%").size(LabelSize::Small)),
+                    )
+                    .child(
+                        container()
+                            .child(CircularProgress::new(100.0, max_value, px(48.0), cx))
+                            .child(Label::new("100%").size(LabelSize::Small)),
+                    )
+                    .into_any_element(),
+            )])
+            .into_any_element(),
+        )
+    }
+}

crates/ui/src/components/progress/progress_bar.rs 🔗

@@ -67,9 +67,9 @@ impl RenderOnce for ProgressBar {
         div()
             .id(self.id.clone())
             .w_full()
-            .h(px(8.0))
+            .h_2()
+            .p_0p5()
             .rounded_full()
-            .p(px(2.0))
             .bg(self.bg_color)
             .shadow(vec![gpui::BoxShadow {
                 color: gpui::black().opacity(0.08),
@@ -99,58 +99,47 @@ impl Component for ProgressBar {
 
     fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
         let max_value = 180.0;
+        let container = || v_flex().w_full().gap_1();
 
         Some(
-            div()
-                .flex()
-                .flex_col()
-                .gap_4()
-                .p_4()
-                .w(px(240.0))
-                .child(div().child("Progress Bar"))
-                .child(
-                    div()
-                        .flex()
-                        .flex_col()
-                        .gap_2()
-                        .child(
-                            div()
-                                .flex()
-                                .justify_between()
-                                .child(Label::new("0%"))
-                                .child(Label::new("Empty")),
-                        )
-                        .child(ProgressBar::new("empty", 0.0, max_value, cx)),
-                )
-                .child(
-                    div()
-                        .flex()
-                        .flex_col()
-                        .gap_2()
-                        .child(
-                            div()
-                                .flex()
-                                .justify_between()
-                                .child(Label::new("38%"))
-                                .child(Label::new("Partial")),
-                        )
-                        .child(ProgressBar::new("partial", max_value * 0.35, max_value, cx)),
-                )
-                .child(
-                    div()
-                        .flex()
-                        .flex_col()
-                        .gap_2()
-                        .child(
-                            div()
-                                .flex()
-                                .justify_between()
-                                .child(Label::new("100%"))
-                                .child(Label::new("Complete")),
-                        )
-                        .child(ProgressBar::new("filled", max_value, max_value, cx)),
-                )
-                .into_any_element(),
+            example_group(vec![single_example(
+                "Examples",
+                v_flex()
+                    .w_full()
+                    .gap_2()
+                    .child(
+                        container()
+                            .child(
+                                h_flex()
+                                    .justify_between()
+                                    .child(Label::new("0%"))
+                                    .child(Label::new("Empty")),
+                            )
+                            .child(ProgressBar::new("empty", 0.0, max_value, cx)),
+                    )
+                    .child(
+                        container()
+                            .child(
+                                h_flex()
+                                    .justify_between()
+                                    .child(Label::new("38%"))
+                                    .child(Label::new("Partial")),
+                            )
+                            .child(ProgressBar::new("partial", max_value * 0.35, max_value, cx)),
+                    )
+                    .child(
+                        container()
+                            .child(
+                                h_flex()
+                                    .justify_between()
+                                    .child(Label::new("100%"))
+                                    .child(Label::new("Complete")),
+                            )
+                            .child(ProgressBar::new("filled", max_value, max_value, cx)),
+                    )
+                    .into_any_element(),
+            )])
+            .into_any_element(),
         )
     }
 }