thread view: Add improvements to the UI (#36680)

Danilo Leal created

Release Notes:

- N/A

Change summary

crates/agent_servers/src/claude.rs              |   2 
crates/agent_ui/src/acp/thread_view.rs          | 108 ++++++----
crates/ui/src/components/disclosure.rs          |  14 +
crates/ui/src/components/label.rs               |   2 
crates/ui/src/components/label/spinner_label.rs | 192 +++++++++++++++++++
5 files changed, 269 insertions(+), 49 deletions(-)

Detailed changes

crates/agent_servers/src/claude.rs 🔗

@@ -44,7 +44,7 @@ pub struct ClaudeCode;
 
 impl AgentServer for ClaudeCode {
     fn name(&self) -> &'static str {
-        "Claude Code"
+        "Welcome to Claude Code"
     }
 
     fn empty_state_headline(&self) -> &'static str {

crates/agent_ui/src/acp/thread_view.rs 🔗

@@ -41,7 +41,7 @@ use text::Anchor;
 use theme::ThemeSettings;
 use ui::{
     Callout, Disclosure, Divider, DividerColor, ElevationIndex, KeyBinding, PopoverMenuHandle,
-    Scrollbar, ScrollbarState, Tooltip, prelude::*,
+    Scrollbar, ScrollbarState, SpinnerLabel, Tooltip, prelude::*,
 };
 use util::{ResultExt, size::format_file_size, time::duration_alt_display};
 use workspace::{CollaboratorId, Workspace};
@@ -1205,7 +1205,7 @@ impl AcpThreadView {
                                 div()
                                     .py_3()
                                     .px_2()
-                                    .rounded_lg()
+                                    .rounded_md()
                                     .shadow_md()
                                     .bg(cx.theme().colors().editor_background)
                                     .border_1()
@@ -1263,7 +1263,7 @@ impl AcpThreadView {
                     .into_any()
             }
             AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => {
-                let style = default_markdown_style(false, window, cx);
+                let style = default_markdown_style(false, false, window, cx);
                 let message_body = v_flex()
                     .w_full()
                     .gap_2p5()
@@ -1398,8 +1398,6 @@ impl AcpThreadView {
                     .relative()
                     .w_full()
                     .gap_1p5()
-                    .opacity(0.8)
-                    .hover(|style| style.opacity(1.))
                     .child(
                         h_flex()
                             .size_4()
@@ -1440,6 +1438,7 @@ impl AcpThreadView {
                     .child(
                         div()
                             .text_size(self.tool_name_font_size())
+                            .text_color(cx.theme().colors().text_muted)
                             .child("Thinking"),
                     )
                     .on_click(cx.listener({
@@ -1463,9 +1462,10 @@ impl AcpThreadView {
                         .border_l_1()
                         .border_color(self.tool_card_border_color(cx))
                         .text_ui_sm(cx)
-                        .child(
-                            self.render_markdown(chunk, default_markdown_style(false, window, cx)),
-                        ),
+                        .child(self.render_markdown(
+                            chunk,
+                            default_markdown_style(false, false, window, cx),
+                        )),
                 )
             })
             .into_any_element()
@@ -1555,11 +1555,11 @@ impl AcpThreadView {
             | ToolCallStatus::Completed => None,
             ToolCallStatus::InProgress => Some(
                 Icon::new(IconName::ArrowCircle)
-                    .color(Color::Accent)
+                    .color(Color::Muted)
                     .size(IconSize::Small)
                     .with_animation(
                         "running",
-                        Animation::new(Duration::from_secs(2)).repeat(),
+                        Animation::new(Duration::from_secs(3)).repeat(),
                         |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
                     )
                     .into_any(),
@@ -1572,6 +1572,10 @@ impl AcpThreadView {
             ),
         };
 
+        let failed_tool_call = matches!(
+            tool_call.status,
+            ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed
+        );
         let needs_confirmation = matches!(
             tool_call.status,
             ToolCallStatus::WaitingForConfirmation { .. }
@@ -1652,7 +1656,7 @@ impl AcpThreadView {
 
         v_flex()
             .when(use_card_layout, |this| {
-                this.rounded_lg()
+                this.rounded_md()
                     .border_1()
                     .border_color(self.tool_card_border_color(cx))
                     .bg(cx.theme().colors().editor_background)
@@ -1664,20 +1668,16 @@ impl AcpThreadView {
                     .w_full()
                     .gap_1()
                     .justify_between()
-                    .map(|this| {
-                        if use_card_layout {
-                            this.pl_2()
-                                .pr_1p5()
-                                .py_1()
-                                .rounded_t_md()
-                                .when(is_open, |this| {
-                                    this.border_b_1()
-                                        .border_color(self.tool_card_border_color(cx))
-                                })
-                                .bg(self.tool_card_header_bg(cx))
-                        } else {
-                            this.opacity(0.8).hover(|style| style.opacity(1.))
-                        }
+                    .when(use_card_layout, |this| {
+                        this.pl_2()
+                            .pr_1p5()
+                            .py_1()
+                            .rounded_t_md()
+                            .when(is_open && !failed_tool_call, |this| {
+                                this.border_b_1()
+                                    .border_color(self.tool_card_border_color(cx))
+                            })
+                            .bg(self.tool_card_header_bg(cx))
                     })
                     .child(
                         h_flex()
@@ -1709,13 +1709,15 @@ impl AcpThreadView {
                                     .px_1p5()
                                     .rounded_sm()
                                     .overflow_x_scroll()
-                                    .opacity(0.8)
                                     .hover(|label| {
-                                        label.opacity(1.).bg(cx
-                                            .theme()
-                                            .colors()
-                                            .element_hover
-                                            .opacity(0.5))
+                                        label.bg(cx.theme().colors().element_hover.opacity(0.5))
+                                    })
+                                    .map(|this| {
+                                        if use_card_layout {
+                                            this.text_color(cx.theme().colors().text)
+                                        } else {
+                                            this.text_color(cx.theme().colors().text_muted)
+                                        }
                                     })
                                     .child(name)
                                     .tooltip(Tooltip::text("Jump to File"))
@@ -1738,7 +1740,7 @@ impl AcpThreadView {
                                             .overflow_x_scroll()
                                             .child(self.render_markdown(
                                                 tool_call.label.clone(),
-                                                default_markdown_style(false, window, cx),
+                                                default_markdown_style(false, true, window, cx),
                                             )),
                                     )
                                     .child(gradient_overlay(gradient_color))
@@ -1804,9 +1806,9 @@ impl AcpThreadView {
             .border_color(self.tool_card_border_color(cx))
             .text_sm()
             .text_color(cx.theme().colors().text_muted)
-            .child(self.render_markdown(markdown, default_markdown_style(false, window, cx)))
+            .child(self.render_markdown(markdown, default_markdown_style(false, false, window, cx)))
             .child(
-                Button::new(button_id, "Collapse Output")
+                Button::new(button_id, "Collapse")
                     .full_width()
                     .style(ButtonStyle::Outlined)
                     .label_size(LabelSize::Small)
@@ -2131,7 +2133,7 @@ impl AcpThreadView {
                             .to_string()
                     } else {
                         format!(
-                            "Output is {} long—to avoid unexpected token usage, \
+                            "Output is {} long, and to avoid unexpected token usage, \
                                 only 16 KB was sent back to the model.",
                             format_file_size(output.original_content_len as u64, true),
                         )
@@ -2199,7 +2201,7 @@ impl AcpThreadView {
             .border_1()
             .when(tool_failed || command_failed, |card| card.border_dashed())
             .border_color(border_color)
-            .rounded_lg()
+            .rounded_md()
             .overflow_hidden()
             .child(
                 v_flex()
@@ -2553,9 +2555,10 @@ impl AcpThreadView {
                     .into_any(),
             )
             .children(description.map(|desc| {
-                div().text_ui(cx).text_center().child(
-                    self.render_markdown(desc.clone(), default_markdown_style(false, window, cx)),
-                )
+                div().text_ui(cx).text_center().child(self.render_markdown(
+                    desc.clone(),
+                    default_markdown_style(false, false, window, cx),
+                ))
             }))
             .children(
                 configuration_view
@@ -3379,7 +3382,7 @@ impl AcpThreadView {
                                         "used-tokens-label",
                                         Animation::new(Duration::from_secs(2))
                                             .repeat()
-                                            .with_easing(pulsating_between(0.6, 1.)),
+                                            .with_easing(pulsating_between(0.3, 0.8)),
                                         |label, delta| label.alpha(delta),
                                     )
                                     .into_any()
@@ -4636,9 +4639,9 @@ impl Render for AcpThreadView {
                                     ThreadStatus::Idle
                                     | ThreadStatus::WaitingForToolConfirmation => None,
                                     ThreadStatus::Generating => div()
-                                        .px_5()
                                         .py_2()
-                                        .child(LoadingLabel::new("").size(LabelSize::Small))
+                                        .px(rems_from_px(22.))
+                                        .child(SpinnerLabel::new().size(LabelSize::Small))
                                         .into(),
                                 },
                             )
@@ -4671,7 +4674,12 @@ impl Render for AcpThreadView {
     }
 }
 
-fn default_markdown_style(buffer_font: bool, window: &Window, cx: &App) -> MarkdownStyle {
+fn default_markdown_style(
+    buffer_font: bool,
+    muted_text: bool,
+    window: &Window,
+    cx: &App,
+) -> MarkdownStyle {
     let theme_settings = ThemeSettings::get_global(cx);
     let colors = cx.theme().colors();
 
@@ -4692,20 +4700,26 @@ fn default_markdown_style(buffer_font: bool, window: &Window, cx: &App) -> Markd
         TextSize::Default.rems(cx)
     };
 
+    let text_color = if muted_text {
+        colors.text_muted
+    } else {
+        colors.text
+    };
+
     text_style.refine(&TextStyleRefinement {
         font_family: Some(font_family),
         font_fallbacks: theme_settings.ui_font.fallbacks.clone(),
         font_features: Some(theme_settings.ui_font.features.clone()),
         font_size: Some(font_size.into()),
         line_height: Some(line_height.into()),
-        color: Some(cx.theme().colors().text),
+        color: Some(text_color),
         ..Default::default()
     });
 
     MarkdownStyle {
         base_text_style: text_style.clone(),
         syntax: cx.theme().syntax().clone(),
-        selection_background_color: cx.theme().colors().element_selection_background,
+        selection_background_color: colors.element_selection_background,
         code_block_overflow_x_scroll: true,
         table_overflow_x_scroll: true,
         heading_level_styles: Some(HeadingLevelStyles {
@@ -4791,7 +4805,7 @@ fn plan_label_markdown_style(
     window: &Window,
     cx: &App,
 ) -> MarkdownStyle {
-    let default_md_style = default_markdown_style(false, window, cx);
+    let default_md_style = default_markdown_style(false, false, window, cx);
 
     MarkdownStyle {
         base_text_style: TextStyle {
@@ -4811,7 +4825,7 @@ fn plan_label_markdown_style(
 }
 
 fn terminal_command_markdown_style(window: &Window, cx: &App) -> MarkdownStyle {
-    let default_md_style = default_markdown_style(true, window, cx);
+    let default_md_style = default_markdown_style(true, false, window, cx);
 
     MarkdownStyle {
         base_text_style: TextStyle {

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

@@ -1,6 +1,6 @@
 use std::sync::Arc;
 
-use gpui::{ClickEvent, CursorStyle};
+use gpui::{ClickEvent, CursorStyle, SharedString};
 
 use crate::{Color, IconButton, IconButtonShape, IconName, IconSize, prelude::*};
 
@@ -14,6 +14,7 @@ pub struct Disclosure {
     cursor_style: CursorStyle,
     opened_icon: IconName,
     closed_icon: IconName,
+    visible_on_hover: Option<SharedString>,
 }
 
 impl Disclosure {
@@ -27,6 +28,7 @@ impl Disclosure {
             cursor_style: CursorStyle::PointingHand,
             opened_icon: IconName::ChevronDown,
             closed_icon: IconName::ChevronRight,
+            visible_on_hover: None,
         }
     }
 
@@ -73,6 +75,13 @@ impl Clickable for Disclosure {
     }
 }
 
+impl VisibleOnHover for Disclosure {
+    fn visible_on_hover(mut self, group_name: impl Into<SharedString>) -> Self {
+        self.visible_on_hover = Some(group_name.into());
+        self
+    }
+}
+
 impl RenderOnce for Disclosure {
     fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
         IconButton::new(
@@ -87,6 +96,9 @@ impl RenderOnce for Disclosure {
         .icon_size(IconSize::Small)
         .disabled(self.disabled)
         .toggle_state(self.selected)
+        .when_some(self.visible_on_hover.clone(), |this, group_name| {
+            this.visible_on_hover(group_name)
+        })
         .when_some(self.on_toggle, move |this, on_toggle| {
             this.on_click(move |event, window, cx| on_toggle(event, window, cx))
         })

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

@@ -2,8 +2,10 @@ mod highlighted_label;
 mod label;
 mod label_like;
 mod loading_label;
+mod spinner_label;
 
 pub use highlighted_label::*;
 pub use label::*;
 pub use label_like::*;
 pub use loading_label::*;
+pub use spinner_label::*;

crates/ui/src/components/label/spinner_label.rs 🔗

@@ -0,0 +1,192 @@
+use crate::prelude::*;
+use gpui::{Animation, AnimationExt, FontWeight};
+use std::time::Duration;
+
+/// Different types of spinner animations
+#[derive(Debug, Default, Clone, Copy, PartialEq)]
+pub enum SpinnerVariant {
+    #[default]
+    Dots,
+    DotsVariant,
+}
+
+/// A spinner indication, based on the label component, that loops through
+/// frames of the specified animation. It implements `LabelCommon` as well.
+///
+/// # Default Example
+///
+/// ```
+/// use ui::{SpinnerLabel};
+///
+/// SpinnerLabel::new();
+/// ```
+///
+/// # Variant Example
+///
+/// ```
+/// use ui::{SpinnerLabel};
+///
+/// SpinnerLabel::dots_variant();
+/// ```
+#[derive(IntoElement, RegisterComponent)]
+pub struct SpinnerLabel {
+    base: Label,
+    variant: SpinnerVariant,
+    frames: Vec<&'static str>,
+    duration: Duration,
+}
+
+impl SpinnerVariant {
+    fn frames(&self) -> Vec<&'static str> {
+        match self {
+            SpinnerVariant::Dots => vec!["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
+            SpinnerVariant::DotsVariant => vec!["⣼", "⣹", "⢻", "⠿", "⡟", "⣏", "⣧", "⣶"],
+        }
+    }
+
+    fn duration(&self) -> Duration {
+        match self {
+            SpinnerVariant::Dots => Duration::from_millis(1000),
+            SpinnerVariant::DotsVariant => Duration::from_millis(1000),
+        }
+    }
+
+    fn animation_id(&self) -> &'static str {
+        match self {
+            SpinnerVariant::Dots => "spinner_label_dots",
+            SpinnerVariant::DotsVariant => "spinner_label_dots_variant",
+        }
+    }
+}
+
+impl SpinnerLabel {
+    pub fn new() -> Self {
+        Self::with_variant(SpinnerVariant::default())
+    }
+
+    pub fn with_variant(variant: SpinnerVariant) -> Self {
+        let frames = variant.frames();
+        let duration = variant.duration();
+
+        SpinnerLabel {
+            base: Label::new(frames[0]),
+            variant,
+            frames,
+            duration,
+        }
+    }
+
+    pub fn dots() -> Self {
+        Self::with_variant(SpinnerVariant::Dots)
+    }
+
+    pub fn dots_variant() -> Self {
+        Self::with_variant(SpinnerVariant::DotsVariant)
+    }
+}
+
+impl LabelCommon for SpinnerLabel {
+    fn size(mut self, size: LabelSize) -> Self {
+        self.base = self.base.size(size);
+        self
+    }
+
+    fn weight(mut self, weight: FontWeight) -> Self {
+        self.base = self.base.weight(weight);
+        self
+    }
+
+    fn line_height_style(mut self, line_height_style: LineHeightStyle) -> Self {
+        self.base = self.base.line_height_style(line_height_style);
+        self
+    }
+
+    fn color(mut self, color: Color) -> Self {
+        self.base = self.base.color(color);
+        self
+    }
+
+    fn strikethrough(mut self) -> Self {
+        self.base = self.base.strikethrough();
+        self
+    }
+
+    fn italic(mut self) -> Self {
+        self.base = self.base.italic();
+        self
+    }
+
+    fn alpha(mut self, alpha: f32) -> Self {
+        self.base = self.base.alpha(alpha);
+        self
+    }
+
+    fn underline(mut self) -> Self {
+        self.base = self.base.underline();
+        self
+    }
+
+    fn truncate(mut self) -> Self {
+        self.base = self.base.truncate();
+        self
+    }
+
+    fn single_line(mut self) -> Self {
+        self.base = self.base.single_line();
+        self
+    }
+
+    fn buffer_font(mut self, cx: &App) -> Self {
+        self.base = self.base.buffer_font(cx);
+        self
+    }
+
+    fn inline_code(mut self, cx: &App) -> Self {
+        self.base = self.base.inline_code(cx);
+        self
+    }
+}
+
+impl RenderOnce for SpinnerLabel {
+    fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
+        let frames = self.frames.clone();
+        let duration = self.duration;
+
+        self.base.color(Color::Muted).with_animation(
+            self.variant.animation_id(),
+            Animation::new(duration).repeat(),
+            move |mut label, delta| {
+                let frame_index = (delta * frames.len() as f32) as usize % frames.len();
+
+                label.set_text(frames[frame_index]);
+                label
+            },
+        )
+    }
+}
+
+impl Component for SpinnerLabel {
+    fn scope() -> ComponentScope {
+        ComponentScope::Loading
+    }
+
+    fn name() -> &'static str {
+        "Spinner Label"
+    }
+
+    fn sort_name() -> &'static str {
+        "Spinner Label"
+    }
+
+    fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
+        let examples = vec![
+            single_example("Default", SpinnerLabel::new().into_any_element()),
+            single_example(
+                "Dots Variant",
+                SpinnerLabel::dots_variant().into_any_element(),
+            ),
+        ];
+
+        Some(example_group(examples).vertical().into_any_element())
+    }
+}