assistant: Show error messages in popover notification (#15808)

Bennet Bo Fenner and Thorsten created

<img width="644" alt="image"
src="https://github.com/user-attachments/assets/ee6f2e60-e50a-481e-98b4-6c4b72a9b882">


Release Notes:

- N/A

---------

Co-authored-by: Thorsten <thorsten@zed.dev>

Change summary

crates/assistant/src/assistant_panel.rs | 133 ++++++++++++++++++--------
crates/assistant/src/context.rs         |   5 +
2 files changed, 98 insertions(+), 40 deletions(-)

Detailed changes

crates/assistant/src/assistant_panel.rs 🔗

@@ -34,9 +34,9 @@ use fs::Fs;
 use gpui::{
     div, percentage, point, Action, Animation, AnimationExt, AnyElement, AnyView, AppContext,
     AsyncWindowContext, ClipboardItem, Context as _, DismissEvent, Empty, Entity, EventEmitter,
-    FocusHandle, FocusableView, InteractiveElement, IntoElement, Model, ParentElement, Pixels,
-    Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Transformation,
-    UpdateGlobal, View, ViewContext, VisualContext, WeakView, WindowContext,
+    FocusHandle, FocusableView, FontWeight, InteractiveElement, IntoElement, Model, ParentElement,
+    Pixels, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Task,
+    Transformation, UpdateGlobal, View, ViewContext, VisualContext, WeakView, WindowContext,
 };
 use indexed_docs::IndexedDocsStore;
 use language::{
@@ -1316,6 +1316,7 @@ pub struct ContextEditor {
     _subscriptions: Vec<Subscription>,
     active_edit_step: Option<ActiveEditStep>,
     assistant_panel: WeakView<AssistantPanel>,
+    error_message: Option<SharedString>,
 }
 
 const DEFAULT_TAB_TITLE: &str = "New Context";
@@ -1373,6 +1374,7 @@ impl ContextEditor {
             _subscriptions,
             active_edit_step: None,
             assistant_panel,
+            error_message: None,
         };
         this.update_message_headers(cx);
         this.insert_slash_command_output_sections(sections, cx);
@@ -1406,7 +1408,9 @@ impl ContextEditor {
 
     fn assist(&mut self, _: &Assist, cx: &mut ViewContext<Self>) {
         if !self.apply_edit_step(cx) {
+            self.error_message = None;
             self.send_to_model(cx);
+            cx.notify();
         }
     }
 
@@ -1804,6 +1808,9 @@ impl ContextEditor {
                 }
             }
             ContextEvent::Operation(_) => {}
+            ContextEvent::AssistError(error_message) => {
+                self.error_message = Some(SharedString::from(error_message.clone()));
+            }
         }
     }
 
@@ -2142,7 +2149,10 @@ impl ContextEditor {
                                             div()
                                                 .id("error")
                                                 .tooltip(move |cx| Tooltip::text(error.clone(), cx))
-                                                .child(Icon::new(IconName::XCircle)),
+                                                .child(
+                                                    Icon::new(IconName::ExclamationTriangle)
+                                                        .color(Color::Error),
+                                                ),
                                         )
                                     } else {
                                         None
@@ -2327,25 +2337,33 @@ impl ContextEditor {
             .unwrap_or_else(|| Cow::Borrowed(DEFAULT_TAB_TITLE))
     }
 
-    fn render_notice(&self, cx: &mut ViewContext<Self>) -> Option<impl IntoElement> {
+    fn dismiss_error_message(&mut self, cx: &mut ViewContext<Self>) {
+        self.error_message = None;
+        cx.notify();
+    }
+
+    fn render_notice(&self, cx: &mut ViewContext<Self>) -> Option<AnyElement> {
         let nudge = self
             .assistant_panel
             .upgrade()
             .map(|assistant_panel| assistant_panel.read(cx).show_zed_ai_notice);
 
-        if nudge.unwrap_or(false) {
+        if let Some(error) = self.error_message.clone() {
+            Some(Self::render_error_popover(error, cx).into_any_element())
+        } else if nudge.unwrap_or(false) {
             Some(
                 v_flex()
                     .elevation_3(cx)
-                    .p_4()
+                    .p_2()
                     .gap_2()
-                    .child(Label::new("Use Zed AI"))
                     .child(
-                        div()
-                            .id("sign-in")
-                            .child(Label::new("Sign in to use Zed AI"))
-                            .cursor_pointer()
-                            .on_click(cx.listener(|this, _event, cx| {
+                        Label::new("Use Zed AI")
+                            .size(LabelSize::Small)
+                            .color(Color::Muted),
+                    )
+                    .child(h_flex().justify_end().child(
+                        Button::new("sign-in", "Sign in to use Zed AI").on_click(cx.listener(
+                            |this, _event, cx| {
                                 let client = this
                                     .workspace
                                     .update(cx, |workspace, _| workspace.client().clone())
@@ -2358,8 +2376,10 @@ impl ContextEditor {
                                     })
                                     .detach_and_log_err(cx)
                                 }
-                            })),
-                    ),
+                            },
+                        )),
+                    ))
+                    .into_any_element(),
             )
         } else if let Some(configuration_error) = configuration_error(cx) {
             let label = match configuration_error {
@@ -2369,27 +2389,51 @@ impl ContextEditor {
             Some(
                 v_flex()
                     .elevation_3(cx)
-                    .p_4()
+                    .p_2()
                     .gap_2()
-                    .child(Label::new(label))
+                    .child(Label::new(label).size(LabelSize::Small).color(Color::Muted))
                     .child(
-                        div()
-                            .id("open-configuration")
-                            .child(Label::new("Open configuration"))
-                            .cursor_pointer()
-                            .on_click({
-                                let focus_handle = self.focus_handle(cx).clone();
-                                move |_event, cx| {
-                                    focus_handle.dispatch_action(&ShowConfiguration, cx);
-                                }
-                            }),
-                    ),
+                        h_flex().justify_end().child(
+                            Button::new("open-configuration", "Open configuration")
+                                .icon(IconName::Settings)
+                                .icon_size(IconSize::Small)
+                                .on_click({
+                                    let focus_handle = self.focus_handle(cx).clone();
+                                    move |_event, cx| {
+                                        focus_handle.dispatch_action(&ShowConfiguration, cx);
+                                    }
+                                }),
+                        ),
+                    )
+                    .into_any_element(),
             )
         } else {
             None
         }
     }
 
+    fn render_error_popover(error: SharedString, cx: &mut ViewContext<Self>) -> Div {
+        v_flex()
+            .p_2()
+            .elevation_2(cx)
+            .bg(cx.theme().colors().surface_background)
+            .min_w_24()
+            .occlude()
+            .child(
+                Label::new("Error interacting with language model")
+                    .size(LabelSize::Small)
+                    .weight(FontWeight::BOLD)
+                    .color(Color::Muted),
+            )
+            .child(Label::new(error).size(LabelSize::Small))
+            .child(
+                h_flex().justify_end().child(
+                    Button::new("dismiss", "Dismiss")
+                        .on_click(cx.listener(|this, _, cx| this.dismiss_error_message(cx))),
+                ),
+            )
+    }
+
     fn render_send_button(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
         let focus_handle = self.focus_handle(cx).clone();
         let button_text = match self.workflow_step_for_cursor(cx) {
@@ -2487,23 +2531,32 @@ impl Render for ContextEditor {
                 div()
                     .flex_grow()
                     .bg(cx.theme().colors().editor_background)
-                    .child(self.editor.clone())
+                    .child(self.editor.clone()),
+            )
+            .child(
+                h_flex()
+                    .flex_none()
+                    .relative()
+                    .when_some(self.render_notice(cx), |this, notice| {
+                        this.child(
+                            div()
+                                .absolute()
+                                .w_3_4()
+                                .min_w_24()
+                                .max_w_128()
+                                .right_4()
+                                .bottom_9()
+                                .child(notice),
+                        )
+                    })
                     .child(
                         h_flex()
                             .w_full()
                             .absolute()
-                            .bottom_0()
-                            .p_4()
+                            .right_4()
+                            .bottom_2()
                             .justify_end()
-                            .child(
-                                v_flex()
-                                    .gap_2()
-                                    .items_end()
-                                    .when_some(self.render_notice(cx), |this, notice| {
-                                        this.child(notice)
-                                    })
-                                    .child(self.render_send_button(cx)),
-                            ),
+                            .child(self.render_send_button(cx)),
                     ),
             )
     }

crates/assistant/src/context.rs 🔗

@@ -281,6 +281,7 @@ impl ContextOperation {
 
 #[derive(Debug, Clone)]
 pub enum ContextEvent {
+    AssistError(String),
     MessagesEdited,
     SummaryChanged,
     EditStepsChanged,
@@ -1580,6 +1581,10 @@ impl Context {
                         .err()
                         .map(|error| error.to_string().trim().to_string());
 
+                    if let Some(error_message) = error_message.as_ref() {
+                        cx.emit(ContextEvent::AssistError(error_message.to_string()));
+                    }
+
                     this.update_metadata(assistant_message_id, cx, |metadata| {
                         if let Some(error_message) = error_message.as_ref() {
                             metadata.status =