From 0f5a63a9b086bd24bbc31c029c2c4b8bdf14cfac Mon Sep 17 00:00:00 2001
From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com>
Date: Wed, 5 Nov 2025 11:45:44 -0300
Subject: [PATCH] agent_ui: Make "waiting confirmation" state more apparent
(#41998)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
Release Notes:
- agent: Improved the "waiting for confirmation" state visibility so
that you more rapidly know the agent is waiting for you to act.
---
crates/agent_ui/src/acp/thread_view.rs | 54 +++++++++++++++---
.../ui/src/components/label/loading_label.rs | 57 ++++++++-----------
.../ui/src/components/label/spinner_label.rs | 13 +++++
3 files changed, 83 insertions(+), 41 deletions(-)
diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs
index bfaaf1c2f03637253fcf2392f373c8887ed9681b..8ef7c7bbb0597e704a4b3b98b694601c27cacd5e 100644
--- a/crates/agent_ui/src/acp/thread_view.rs
+++ b/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,
@@ -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, cx: &Context) -> Div {
diff --git a/crates/ui/src/components/label/loading_label.rs b/crates/ui/src/components/label/loading_label.rs
index 2a1e7059794d2ebd61399e5f7bdb85a8a8ac28b3..0b6b027e4775aa960df975c0507e4ac08fbbb545 100644
--- a/crates/ui/src/components/label/loading_label.rs
+++ b/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
+ },
+ )
}
}
diff --git a/crates/ui/src/components/label/spinner_label.rs b/crates/ui/src/components/label/spinner_label.rs
index b7b65fbcc98c175e4407d72c3e07df236364f552..de88e9bb7ab04a3d595183513c2b00da70e172aa 100644
--- a/crates/ui/src/components/label/spinner_label.rs
+++ b/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())