Add sidebar status icons for agent thread states (#49505)

Richard Feldman created

<img width="800" height="1200" alt="image"
src="https://github.com/user-attachments/assets/2b765edc-4be4-476e-891a-9dd81fac2626"
/>

Add icon decorations on the agent icon in the sidebar to show thread
status:

- **Generation done**: blue dot (existing behavior, unchanged)
- **Blocked on tool permission/confirmation**: yellow warning triangle
- **Thread stopped due to an error**: red X

Priority order for decorations: confirmation > error > done > running
spinner.

Confirmation and error decorations persist as long as the thread is
actually in that state and always show regardless of whether the
workspace is active (unlike the blue dot which is notification-based).

(No release notes because this is all feature-flagged.)

Release Notes:

- N/A

Change summary

crates/acp_thread/src/acp_thread.rs        |  25 +++
crates/sidebar/src/sidebar.rs              |  38 ++--
crates/ui/src/components/ai/thread_item.rs |  97 +++++++++++--
crates/zed/src/visual_test_runner.rs       | 175 +++++++++++++++++++++++
4 files changed, 295 insertions(+), 40 deletions(-)

Detailed changes

crates/acp_thread/src/acp_thread.rs 🔗

@@ -958,6 +958,7 @@ pub struct AcpThread {
     terminals: HashMap<acp::TerminalId, Entity<Terminal>>,
     pending_terminal_output: HashMap<acp::TerminalId, Vec<Vec<u8>>>,
     pending_terminal_exit: HashMap<acp::TerminalId, acp::TerminalExitStatus>,
+    had_error: bool,
 }
 
 impl From<&AcpThread> for ActionLogTelemetry {
@@ -1193,6 +1194,7 @@ impl AcpThread {
             terminals: HashMap::default(),
             pending_terminal_output: HashMap::default(),
             pending_terminal_exit: HashMap::default(),
+            had_error: false,
         }
     }
 
@@ -1236,6 +1238,24 @@ impl AcpThread {
         }
     }
 
+    pub fn had_error(&self) -> bool {
+        self.had_error
+    }
+
+    pub fn is_waiting_for_confirmation(&self) -> bool {
+        for entry in self.entries.iter().rev() {
+            match entry {
+                AgentThreadEntry::UserMessage(_) => return false,
+                AgentThreadEntry::ToolCall(ToolCall {
+                    status: ToolCallStatus::WaitingForConfirmation { .. },
+                    ..
+                }) => return true,
+                AgentThreadEntry::ToolCall(_) | AgentThreadEntry::AssistantMessage(_) => {}
+            }
+        }
+        false
+    }
+
     pub fn token_usage(&self) -> Option<&TokenUsage> {
         self.token_usage.as_ref()
     }
@@ -1926,6 +1946,7 @@ impl AcpThread {
         f: impl 'static + AsyncFnOnce(WeakEntity<Self>, &mut AsyncApp) -> Result<acp::PromptResponse>,
     ) -> BoxFuture<'static, Result<Option<acp::PromptResponse>>> {
         self.clear_completed_plan_entries(cx);
+        self.had_error = false;
 
         let (tx, rx) = oneshot::channel();
         let cancel_task = self.cancel(cx);
@@ -1949,7 +1970,6 @@ impl AcpThread {
             this.update(cx, |this, cx| {
                 this.project
                     .update(cx, |project, cx| project.set_agent_location(None, cx));
-
                 let Ok(response) = response else {
                     // tx dropped, just return
                     return Ok(None);
@@ -1970,6 +1990,7 @@ impl AcpThread {
                 match response {
                     Ok(r) => {
                         if r.stop_reason == acp::StopReason::MaxTokens {
+                            this.had_error = true;
                             cx.emit(AcpThreadEvent::Error);
                             log::error!("Max tokens reached. Usage: {:?}", this.token_usage);
                             return Err(anyhow!("Max tokens reached"));
@@ -1982,6 +2003,7 @@ impl AcpThread {
 
                         // Handle refusal - distinguish between user prompt and tool call refusals
                         if let acp::StopReason::Refusal = r.stop_reason {
+                            this.had_error = true;
                             if let Some((user_msg_ix, _)) = this.last_user_message() {
                                 // Check if there's a completed tool call with results after the last user message
                                 // This indicates the refusal is in response to tool output, not the user's prompt
@@ -2019,6 +2041,7 @@ impl AcpThread {
                         Ok(Some(r))
                     }
                     Err(e) => {
+                        this.had_error = true;
                         cx.emit(AcpThreadEvent::Error);
                         log::error!("Error in run turn: {:?}", e);
                         Err(e)

crates/sidebar/src/sidebar.rs 🔗

@@ -20,7 +20,10 @@ use std::path::{Path, PathBuf};
 use std::sync::Arc;
 use theme::ActiveTheme;
 use ui::utils::TRAFFIC_LIGHT_PADDING;
-use ui::{Divider, DividerColor, KeyBinding, ListSubHeader, Tab, ThreadItem, Tooltip, prelude::*};
+use ui::{
+    AgentThreadStatus, Divider, DividerColor, KeyBinding, ListSubHeader, Tab, ThreadItem, Tooltip,
+    prelude::*,
+};
 use ui_input::ErasedEditor;
 use util::ResultExt as _;
 use workspace::{
@@ -28,12 +31,6 @@ use workspace::{
     SidebarEvent, ToggleWorkspaceSidebar, Workspace,
 };
 
-#[derive(Clone, Debug, PartialEq, Eq)]
-pub enum AgentThreadStatus {
-    Running,
-    Completed,
-}
-
 #[derive(Clone, Debug)]
 struct AgentThreadInfo {
     title: SharedString,
@@ -123,9 +120,15 @@ impl WorkspaceThreadEntry {
         let icon = thread_view.agent_icon;
         let title = thread.title();
 
-        let status = match thread.status() {
-            ThreadStatus::Generating => AgentThreadStatus::Running,
-            ThreadStatus::Idle => AgentThreadStatus::Completed,
+        let status = if thread.is_waiting_for_confirmation() {
+            AgentThreadStatus::WaitingForConfirmation
+        } else if thread.had_error() {
+            AgentThreadStatus::Error
+        } else {
+            match thread.status() {
+                ThreadStatus::Generating => AgentThreadStatus::Running,
+                ThreadStatus::Idle => AgentThreadStatus::Completed,
+            }
         };
         Some(AgentThreadInfo {
             title,
@@ -213,7 +216,7 @@ impl WorkspacePickerDelegate {
                 SidebarEntry::WorkspaceThread(thread) => thread
                     .thread_info
                     .as_ref()
-                    .map(|info| (thread.index, info.status.clone())),
+                    .map(|info| (thread.index, info.status)),
                 _ => None,
             })
             .collect();
@@ -626,12 +629,12 @@ impl PickerDelegate for WorkspacePickerDelegate {
 
                 let has_notification = self.notified_workspaces.contains(&workspace_index);
                 let thread_subtitle = thread_info.as_ref().map(|info| info.title.clone());
+                let status = thread_info
+                    .as_ref()
+                    .map_or(AgentThreadStatus::default(), |info| info.status);
                 let running = matches!(
-                    thread_info,
-                    Some(AgentThreadInfo {
-                        status: AgentThreadStatus::Running,
-                        ..
-                    })
+                    status,
+                    AgentThreadStatus::Running | AgentThreadStatus::WaitingForConfirmation
                 );
 
                 Some(
@@ -646,6 +649,7 @@ impl PickerDelegate for WorkspacePickerDelegate {
                     )
                     .running(running)
                     .generation_done(has_notification)
+                    .status(status)
                     .selected(selected)
                     .worktree(worktree_label.clone())
                     .worktree_highlight_positions(positions.clone())
@@ -1177,7 +1181,7 @@ mod tests {
         cx: &mut gpui::VisualTestContext,
     ) {
         sidebar.update_in(cx, |s, _window, _cx| {
-            s.set_test_thread_info(index, SharedString::from(title.to_string()), status.clone());
+            s.set_test_thread_info(index, SharedString::from(title.to_string()), status);
         });
         multi_workspace.update_in(cx, |_, _window, cx| cx.notify());
         cx.run_until_parked();

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

@@ -5,6 +5,15 @@ use crate::{
 
 use gpui::{AnyView, ClickEvent, SharedString};
 
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub enum AgentThreadStatus {
+    #[default]
+    Completed,
+    Running,
+    WaitingForConfirmation,
+    Error,
+}
+
 #[derive(IntoElement, RegisterComponent)]
 pub struct ThreadItem {
     id: ElementId,
@@ -13,6 +22,7 @@ pub struct ThreadItem {
     timestamp: SharedString,
     running: bool,
     generation_done: bool,
+    status: AgentThreadStatus,
     selected: bool,
     hovered: bool,
     added: Option<usize>,
@@ -35,6 +45,7 @@ impl ThreadItem {
             timestamp: "".into(),
             running: false,
             generation_done: false,
+            status: AgentThreadStatus::default(),
             selected: false,
             hovered: false,
             added: None,
@@ -69,6 +80,11 @@ impl ThreadItem {
         self
     }
 
+    pub fn status(mut self, status: AgentThreadStatus) -> Self {
+        self.status = status;
+        self
+    }
+
     pub fn selected(mut self, selected: bool) -> Self {
         self.selected = selected;
         self
@@ -143,22 +159,51 @@ impl RenderOnce for ThreadItem {
             .color(Color::Muted)
             .size(IconSize::Small);
 
-        let icon = if self.generation_done {
-            icon_container().child(DecoratedIcon::new(
-                agent_icon,
-                Some(
-                    IconDecoration::new(
-                        IconDecorationKind::Dot,
-                        cx.theme().colors().surface_background,
-                        cx,
-                    )
-                    .color(cx.theme().colors().text_accent)
-                    .position(gpui::Point {
-                        x: px(-2.),
-                        y: px(-2.),
-                    }),
-                ),
-            ))
+        let decoration = if self.status == AgentThreadStatus::WaitingForConfirmation {
+            Some(
+                IconDecoration::new(
+                    IconDecorationKind::Triangle,
+                    cx.theme().colors().surface_background,
+                    cx,
+                )
+                .color(cx.theme().status().warning)
+                .position(gpui::Point {
+                    x: px(-2.),
+                    y: px(-2.),
+                }),
+            )
+        } else if self.status == AgentThreadStatus::Error {
+            Some(
+                IconDecoration::new(
+                    IconDecorationKind::X,
+                    cx.theme().colors().surface_background,
+                    cx,
+                )
+                .color(cx.theme().status().error)
+                .position(gpui::Point {
+                    x: px(-2.),
+                    y: px(-2.),
+                }),
+            )
+        } else if self.generation_done {
+            Some(
+                IconDecoration::new(
+                    IconDecorationKind::Dot,
+                    cx.theme().colors().surface_background,
+                    cx,
+                )
+                .color(cx.theme().colors().text_accent)
+                .position(gpui::Point {
+                    x: px(-2.),
+                    y: px(-2.),
+                }),
+            )
+        } else {
+            None
+        };
+
+        let icon = if let Some(decoration) = decoration {
+            icon_container().child(DecoratedIcon::new(agent_icon, Some(decoration)))
         } else {
             icon_container().child(agent_icon)
         };
@@ -311,6 +356,26 @@ impl Component for ThreadItem {
                     )
                     .into_any_element(),
             ),
+            single_example(
+                "Waiting for Confirmation",
+                container()
+                    .child(
+                        ThreadItem::new("ti-2b", "Execute shell command in terminal")
+                            .timestamp("12:15 AM")
+                            .status(AgentThreadStatus::WaitingForConfirmation),
+                    )
+                    .into_any_element(),
+            ),
+            single_example(
+                "Error",
+                container()
+                    .child(
+                        ThreadItem::new("ti-2c", "Failed to connect to language server")
+                            .timestamp("12:20 AM")
+                            .status(AgentThreadStatus::Error),
+                    )
+                    .into_any_element(),
+            ),
             single_example(
                 "Running Agent",
                 container()

crates/zed/src/visual_test_runner.rs 🔗

@@ -527,8 +527,26 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()>
         }
     }
 
-    // Run Test 8: Tool Permissions Settings UI visual test
-    println!("\n--- Test 8: tool_permissions_settings ---");
+    // Run Test 8: ThreadItem icon decorations visual tests
+    println!("\n--- Test 8: thread_item_icon_decorations ---");
+    match run_thread_item_icon_decorations_visual_tests(app_state.clone(), &mut cx, update_baseline)
+    {
+        Ok(TestResult::Passed) => {
+            println!("✓ thread_item_icon_decorations: PASSED");
+            passed += 1;
+        }
+        Ok(TestResult::BaselineUpdated(_)) => {
+            println!("✓ thread_item_icon_decorations: Baseline updated");
+            updated += 1;
+        }
+        Err(e) => {
+            eprintln!("✗ thread_item_icon_decorations: FAILED - {}", e);
+            failed += 1;
+        }
+    }
+
+    // Run Test 9: Tool Permissions Settings UI visual test
+    println!("\n--- Test 9: tool_permissions_settings ---");
     match run_tool_permissions_visual_tests(app_state.clone(), &mut cx, update_baseline) {
         Ok(TestResult::Passed) => {
             println!("✓ tool_permissions_settings: PASSED");
@@ -544,8 +562,8 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()>
         }
     }
 
-    // Run Test 9: Settings UI sub-page auto-open visual tests
-    println!("\n--- Test 9: settings_ui_subpage_auto_open (2 variants) ---");
+    // Run Test 10: Settings UI sub-page auto-open visual tests
+    println!("\n--- Test 10: settings_ui_subpage_auto_open (2 variants) ---");
     match run_settings_ui_subpage_visual_tests(app_state.clone(), &mut cx, update_baseline) {
         Ok(TestResult::Passed) => {
             println!("✓ settings_ui_subpage_auto_open: PASSED");
@@ -2701,12 +2719,12 @@ fn run_multi_workspace_sidebar_visual_tests(
             sidebar.set_test_thread_info(
                 0,
                 "Refine thread view scrolling behavior".into(),
-                sidebar::AgentThreadStatus::Completed,
+                ui::AgentThreadStatus::Completed,
             );
             sidebar.set_test_thread_info(
                 1,
                 "Add line numbers option to FileEditBlock".into(),
-                sidebar::AgentThreadStatus::Running,
+                ui::AgentThreadStatus::Running,
             );
         });
     });
@@ -2840,6 +2858,151 @@ impl gpui::Render for ErrorWrappingTestView {
     }
 }
 
+#[cfg(target_os = "macos")]
+struct ThreadItemIconDecorationsTestView;
+
+#[cfg(target_os = "macos")]
+impl gpui::Render for ThreadItemIconDecorationsTestView {
+    fn render(
+        &mut self,
+        _window: &mut gpui::Window,
+        cx: &mut gpui::Context<Self>,
+    ) -> impl gpui::IntoElement {
+        use ui::{IconName, Label, LabelSize, ThreadItem, prelude::*};
+
+        let section_label = |text: &str| {
+            Label::new(text.to_string())
+                .size(LabelSize::Small)
+                .color(Color::Muted)
+        };
+
+        let container = || {
+            v_flex()
+                .w_80()
+                .border_1()
+                .border_color(cx.theme().colors().border_variant)
+                .bg(cx.theme().colors().panel_background)
+        };
+
+        v_flex()
+            .size_full()
+            .bg(cx.theme().colors().background)
+            .p_4()
+            .gap_3()
+            .child(
+                Label::new("ThreadItem Icon Decorations")
+                    .size(LabelSize::Large)
+                    .color(Color::Default),
+            )
+            .child(section_label("No decoration (default idle)"))
+            .child(
+                container()
+                    .child(ThreadItem::new("ti-none", "Default idle thread").timestamp("1:00 AM")),
+            )
+            .child(section_label("Blue dot (generation done)"))
+            .child(
+                container().child(
+                    ThreadItem::new("ti-done", "Generation completed successfully")
+                        .timestamp("1:05 AM")
+                        .generation_done(true),
+                ),
+            )
+            .child(section_label("Yellow triangle (waiting for confirmation)"))
+            .child(
+                container().child(
+                    ThreadItem::new("ti-waiting", "Waiting for user confirmation")
+                        .timestamp("1:10 AM")
+                        .status(ui::AgentThreadStatus::WaitingForConfirmation),
+                ),
+            )
+            .child(section_label("Red X (error)"))
+            .child(
+                container().child(
+                    ThreadItem::new("ti-error", "Failed to connect to server")
+                        .timestamp("1:15 AM")
+                        .status(ui::AgentThreadStatus::Error),
+                ),
+            )
+            .child(section_label("Spinner (running)"))
+            .child(
+                container().child(
+                    ThreadItem::new("ti-running", "Generating response...")
+                        .icon(IconName::AiClaude)
+                        .timestamp("1:20 AM")
+                        .running(true),
+                ),
+            )
+            .child(section_label(
+                "Spinner + yellow triangle (running + waiting)",
+            ))
+            .child(
+                container().child(
+                    ThreadItem::new("ti-running-waiting", "Running but needs confirmation")
+                        .icon(IconName::AiClaude)
+                        .timestamp("1:25 AM")
+                        .running(true)
+                        .status(ui::AgentThreadStatus::WaitingForConfirmation),
+                ),
+            )
+    }
+}
+
+#[cfg(target_os = "macos")]
+fn run_thread_item_icon_decorations_visual_tests(
+    _app_state: Arc<AppState>,
+    cx: &mut VisualTestAppContext,
+    update_baseline: bool,
+) -> Result<TestResult> {
+    let window_size = size(px(400.0), px(600.0));
+    let bounds = Bounds {
+        origin: point(px(0.0), px(0.0)),
+        size: window_size,
+    };
+
+    let window = cx
+        .update(|cx| {
+            cx.open_window(
+                WindowOptions {
+                    window_bounds: Some(WindowBounds::Windowed(bounds)),
+                    focus: false,
+                    show: false,
+                    ..Default::default()
+                },
+                |_window, cx| cx.new(|_| ThreadItemIconDecorationsTestView),
+            )
+        })
+        .context("Failed to open thread item icon decorations test window")?;
+
+    cx.run_until_parked();
+
+    cx.update_window(window.into(), |_, window, _cx| {
+        window.refresh();
+    })?;
+
+    cx.run_until_parked();
+
+    let test_result = run_visual_test(
+        "thread_item_icon_decorations",
+        window.into(),
+        cx,
+        update_baseline,
+    )?;
+
+    cx.update_window(window.into(), |_, window, _cx| {
+        window.remove_window();
+    })
+    .log_err();
+
+    cx.run_until_parked();
+
+    for _ in 0..15 {
+        cx.advance_clock(Duration::from_millis(100));
+        cx.run_until_parked();
+    }
+
+    Ok(test_result)
+}
+
 #[cfg(target_os = "macos")]
 fn run_error_wrapping_visual_tests(
     _app_state: Arc<AppState>,