agent_ui: Make "waiting confirmation" state more apparent (#41998)

Danilo Leal created

This PR changes the loading/generating indicator when in the "waiting
for tool call confirmation" state so that's a bit more visible and
discernible as needing your attention, as opposed to a regular
generating state.

<img width="400" alt="Screenshot 2025-11-05 at 10  46@2x"
src="https://github.com/user-attachments/assets/88adbf97-20fb-49c4-9c77-b0a3a22aa14e"
/>

Release Notes:

- agent: Improved the "waiting for confirmation" state visibility so
that you more rapidly know the agent is waiting for you to act.

Change summary

crates/agent_ui/src/acp/thread_view.rs          | 54 +++++++++++++++--
crates/ui/src/components/label/loading_label.rs | 57 ++++++++-----------
crates/ui/src/components/label/spinner_label.rs | 13 ++++
3 files changed, 83 insertions(+), 41 deletions(-)

Detailed changes

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

@@ -2051,6 +2051,15 @@ impl AcpThreadView {
             .into_any(),
         };
 
+        let needs_confirmation = if let AgentThreadEntry::ToolCall(tool_call) = entry {
+            matches!(
+                tool_call.status,
+                ToolCallStatus::WaitingForConfirmation { .. }
+            )
+        } else {
+            false
+        };
+
         let Some(thread) = self.thread() else {
             return primary;
         };
@@ -2059,7 +2068,13 @@ impl AcpThreadView {
             v_flex()
                 .w_full()
                 .child(primary)
-                .child(self.render_thread_controls(&thread, cx))
+                .map(|this| {
+                    if needs_confirmation {
+                        this.child(self.render_generating(true))
+                    } else {
+                        this.child(self.render_thread_controls(&thread, cx))
+                    }
+                })
                 .when_some(
                     self.thread_feedback.comments_editor.clone(),
                     |this, editor| this.child(Self::render_feedback_feedback_editor(editor, cx)),
@@ -4829,6 +4844,31 @@ impl AcpThreadView {
         }
     }
 
+    fn render_generating(&self, confirmation: bool) -> impl IntoElement {
+        h_flex()
+            .id("generating-spinner")
+            .py_2()
+            .px(rems_from_px(22.))
+            .map(|this| {
+                if confirmation {
+                    this.gap_2()
+                        .child(
+                            h_flex()
+                                .w_2()
+                                .child(SpinnerLabel::sand().size(LabelSize::Small)),
+                        )
+                        .child(
+                            LoadingLabel::new("Waiting Confirmation")
+                                .size(LabelSize::Small)
+                                .color(Color::Muted),
+                        )
+                } else {
+                    this.child(SpinnerLabel::new().size(LabelSize::Small))
+                }
+            })
+            .into_any_element()
+    }
+
     fn render_thread_controls(
         &self,
         thread: &Entity<AcpThread>,
@@ -4836,12 +4876,7 @@ impl AcpThreadView {
     ) -> impl IntoElement {
         let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating);
         if is_generating {
-            return h_flex().id("thread-controls-container").child(
-                div()
-                    .py_2()
-                    .px(rems_from_px(22.))
-                    .child(SpinnerLabel::new().size(LabelSize::Small)),
-            );
+            return self.render_generating(false).into_any_element();
         }
 
         let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown)
@@ -4929,7 +4964,10 @@ impl AcpThreadView {
                 );
         }
 
-        container.child(open_as_markdown).child(scroll_to_top)
+        container
+            .child(open_as_markdown)
+            .child(scroll_to_top)
+            .into_any_element()
     }
 
     fn render_feedback_feedback_editor(editor: Entity<Editor>, cx: &Context<Self>) -> Div {

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

@@ -1,5 +1,5 @@
 use crate::prelude::*;
-use gpui::{Animation, AnimationExt, FontWeight, pulsating_between};
+use gpui::{Animation, AnimationExt, FontWeight};
 use std::time::Duration;
 
 #[derive(IntoElement)]
@@ -84,38 +84,29 @@ impl RenderOnce for LoadingLabel {
     fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
         let text = self.text.clone();
 
-        self.base
-            .color(Color::Muted)
-            .with_animations(
-                "loading_label",
-                vec![
-                    Animation::new(Duration::from_secs(1)),
-                    Animation::new(Duration::from_secs(1)).repeat(),
-                ],
-                move |mut label, animation_ix, delta| {
-                    match animation_ix {
-                        0 => {
-                            let chars_to_show = (delta * text.len() as f32).ceil() as usize;
-                            let text = SharedString::from(text[0..chars_to_show].to_string());
-                            label.set_text(text);
-                        }
-                        1 => match delta {
-                            d if d < 0.25 => label.set_text(text.clone()),
-                            d if d < 0.5 => label.set_text(format!("{}.", text)),
-                            d if d < 0.75 => label.set_text(format!("{}..", text)),
-                            _ => label.set_text(format!("{}...", text)),
-                        },
-                        _ => {}
+        self.base.color(Color::Muted).with_animations(
+            "loading_label",
+            vec![
+                Animation::new(Duration::from_secs(1)),
+                Animation::new(Duration::from_secs(1)).repeat(),
+            ],
+            move |mut label, animation_ix, delta| {
+                match animation_ix {
+                    0 => {
+                        let chars_to_show = (delta * text.len() as f32).ceil() as usize;
+                        let text = SharedString::from(text[0..chars_to_show].to_string());
+                        label.set_text(text);
                     }
-                    label
-                },
-            )
-            .with_animation(
-                "pulsating-label",
-                Animation::new(Duration::from_secs(2))
-                    .repeat()
-                    .with_easing(pulsating_between(0.6, 1.)),
-                |label, delta| label.map_element(|label| label.alpha(delta)),
-            )
+                    1 => match delta {
+                        d if d < 0.25 => label.set_text(text.clone()),
+                        d if d < 0.5 => label.set_text(format!("{}.", text)),
+                        d if d < 0.75 => label.set_text(format!("{}..", text)),
+                        _ => label.set_text(format!("{}...", text)),
+                    },
+                    _ => {}
+                }
+                label
+            },
+        )
     }
 }

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

@@ -8,6 +8,7 @@ pub enum SpinnerVariant {
     #[default]
     Dots,
     DotsVariant,
+    Sand,
 }
 
 /// A spinner indication, based on the label component, that loops through
@@ -41,6 +42,11 @@ impl SpinnerVariant {
         match self {
             SpinnerVariant::Dots => vec!["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
             SpinnerVariant::DotsVariant => vec!["⣼", "⣹", "⢻", "⠿", "⡟", "⣏", "⣧", "⣶"],
+            SpinnerVariant::Sand => vec![
+                "⠁", "⠂", "⠄", "⡀", "⡈", "⡐", "⡠", "⣀", "⣁", "⣂", "⣄", "⣌", "⣔", "⣤", "⣥", "⣦",
+                "⣮", "⣶", "⣷", "⣿", "⡿", "⠿", "⢟", "⠟", "⡛", "⠛", "⠫", "⢋", "⠋", "⠍", "⡉", "⠉",
+                "⠑", "⠡", "⢁",
+            ],
         }
     }
 
@@ -48,6 +54,7 @@ impl SpinnerVariant {
         match self {
             SpinnerVariant::Dots => Duration::from_millis(1000),
             SpinnerVariant::DotsVariant => Duration::from_millis(1000),
+            SpinnerVariant::Sand => Duration::from_millis(2000),
         }
     }
 
@@ -55,6 +62,7 @@ impl SpinnerVariant {
         match self {
             SpinnerVariant::Dots => "spinner_label_dots",
             SpinnerVariant::DotsVariant => "spinner_label_dots_variant",
+            SpinnerVariant::Sand => "spinner_label_dots_variant_2",
         }
     }
 }
@@ -83,6 +91,10 @@ impl SpinnerLabel {
     pub fn dots_variant() -> Self {
         Self::with_variant(SpinnerVariant::DotsVariant)
     }
+
+    pub fn sand() -> Self {
+        Self::with_variant(SpinnerVariant::Sand)
+    }
 }
 
 impl LabelCommon for SpinnerLabel {
@@ -185,6 +197,7 @@ impl Component for SpinnerLabel {
                 "Dots Variant",
                 SpinnerLabel::dots_variant().into_any_element(),
             ),
+            single_example("Sand Variant", SpinnerLabel::sand().into_any_element()),
         ];
 
         Some(example_group(examples).vertical().into_any_element())