Animate Zeta button while generating completions (#22899)

Antonio Scandurra and Thorsten created

Release Notes:

- N/A

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

Change summary

Cargo.lock                                                      |  1 
crates/copilot/src/copilot_completion_provider.rs               | 23 
crates/editor/src/inline_completion_tests.rs                    |  4 
crates/inline_completion/src/inline_completion.rs               |  6 
crates/inline_completion_button/Cargo.toml                      |  1 
crates/inline_completion_button/src/inline_completion_button.rs | 47 ++
crates/supermaven/src/supermaven_completion_provider.rs         | 17 
crates/ui/src/components/button/icon_button.rs                  | 10 
crates/ui/src/components/popover_menu.rs                        | 22 +
crates/zeta/src/zeta.rs                                         |  4 
10 files changed, 106 insertions(+), 29 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -6298,6 +6298,7 @@ dependencies = [
  "futures 0.3.31",
  "gpui",
  "indoc",
+ "inline_completion",
  "language",
  "lsp",
  "paths",

crates/copilot/src/copilot_completion_provider.rs 🔗

@@ -17,8 +17,8 @@ pub struct CopilotCompletionProvider {
     completions: Vec<Completion>,
     active_completion_index: usize,
     file_extension: Option<String>,
-    pending_refresh: Task<Result<()>>,
-    pending_cycling_refresh: Task<Result<()>>,
+    pending_refresh: Option<Task<Result<()>>>,
+    pending_cycling_refresh: Option<Task<Result<()>>>,
     copilot: Model<Copilot>,
 }
 
@@ -30,8 +30,8 @@ impl CopilotCompletionProvider {
             completions: Vec::new(),
             active_completion_index: 0,
             file_extension: None,
-            pending_refresh: Task::ready(Ok(())),
-            pending_cycling_refresh: Task::ready(Ok(())),
+            pending_refresh: None,
+            pending_cycling_refresh: None,
             copilot,
         }
     }
@@ -67,6 +67,10 @@ impl InlineCompletionProvider for CopilotCompletionProvider {
         false
     }
 
+    fn is_refreshing(&self) -> bool {
+        self.pending_refresh.is_some()
+    }
+
     fn is_enabled(
         &self,
         buffer: &Model<Buffer>,
@@ -92,7 +96,7 @@ impl InlineCompletionProvider for CopilotCompletionProvider {
         cx: &mut ModelContext<Self>,
     ) {
         let copilot = self.copilot.clone();
-        self.pending_refresh = cx.spawn(|this, mut cx| async move {
+        self.pending_refresh = Some(cx.spawn(|this, mut cx| async move {
             if debounce {
                 cx.background_executor()
                     .timer(COPILOT_DEBOUNCE_TIMEOUT)
@@ -108,7 +112,8 @@ impl InlineCompletionProvider for CopilotCompletionProvider {
             this.update(&mut cx, |this, cx| {
                 if !completions.is_empty() {
                     this.cycled = false;
-                    this.pending_cycling_refresh = Task::ready(Ok(()));
+                    this.pending_refresh = None;
+                    this.pending_cycling_refresh = None;
                     this.completions.clear();
                     this.active_completion_index = 0;
                     this.buffer_id = Some(buffer.entity_id());
@@ -129,7 +134,7 @@ impl InlineCompletionProvider for CopilotCompletionProvider {
             })?;
 
             Ok(())
-        });
+        }));
     }
 
     fn cycle(
@@ -161,7 +166,7 @@ impl InlineCompletionProvider for CopilotCompletionProvider {
             cx.notify();
         } else {
             let copilot = self.copilot.clone();
-            self.pending_cycling_refresh = cx.spawn(|this, mut cx| async move {
+            self.pending_cycling_refresh = Some(cx.spawn(|this, mut cx| async move {
                 let completions = copilot
                     .update(&mut cx, |copilot, cx| {
                         copilot.completions_cycling(&buffer, cursor_position, cx)
@@ -185,7 +190,7 @@ impl InlineCompletionProvider for CopilotCompletionProvider {
                 })?;
 
                 Ok(())
-            });
+            }));
         }
     }
 

crates/editor/src/inline_completion_tests.rs 🔗

@@ -387,6 +387,10 @@ impl InlineCompletionProvider for FakeInlineCompletionProvider {
         true
     }
 
+    fn is_refreshing(&self) -> bool {
+        false
+    }
+
     fn refresh(
         &mut self,
         _buffer: gpui::Model<language::Buffer>,

crates/inline_completion/src/inline_completion.rs 🔗

@@ -28,6 +28,7 @@ pub trait InlineCompletionProvider: 'static + Sized {
         cursor_position: language::Anchor,
         cx: &AppContext,
     ) -> bool;
+    fn is_refreshing(&self) -> bool;
     fn refresh(
         &mut self,
         buffer: Model<Buffer>,
@@ -63,6 +64,7 @@ pub trait InlineCompletionProviderHandle {
     ) -> bool;
     fn show_completions_in_menu(&self) -> bool;
     fn show_completions_in_normal_mode(&self) -> bool;
+    fn is_refreshing(&self, cx: &AppContext) -> bool;
     fn refresh(
         &self,
         buffer: Model<Buffer>,
@@ -116,6 +118,10 @@ where
         self.read(cx).is_enabled(buffer, cursor_position, cx)
     }
 
+    fn is_refreshing(&self, cx: &AppContext) -> bool {
+        self.read(cx).is_refreshing()
+    }
+
     fn refresh(
         &self,
         buffer: Model<Buffer>,

crates/inline_completion_button/Cargo.toml 🔗

@@ -19,6 +19,7 @@ editor.workspace = true
 feature_flags.workspace = true
 fs.workspace = true
 gpui.workspace = true
+inline_completion.workspace = true
 language.workspace = true
 paths.workspace = true
 settings.workspace = true

crates/inline_completion_button/src/inline_completion_button.rs 🔗

@@ -4,8 +4,9 @@ use editor::{scroll::Autoscroll, Editor};
 use feature_flags::{FeatureFlagAppExt, ZetaFeatureFlag};
 use fs::Fs;
 use gpui::{
-    actions, div, Action, AppContext, AsyncWindowContext, Corner, Entity, IntoElement,
-    ParentElement, Render, Subscription, View, ViewContext, WeakView, WindowContext,
+    actions, div, pulsating_between, Action, Animation, AnimationExt, AppContext,
+    AsyncWindowContext, Corner, Entity, IntoElement, ParentElement, Render, Subscription, View,
+    ViewContext, WeakView, WindowContext,
 };
 use language::{
     language_settings::{
@@ -14,7 +15,7 @@ use language::{
     File, Language,
 };
 use settings::{update_settings_file, Settings, SettingsStore};
-use std::{path::Path, sync::Arc};
+use std::{path::Path, sync::Arc, time::Duration};
 use supermaven::{AccountStatus, Supermaven};
 use workspace::{
     create_and_open_local_file,
@@ -39,6 +40,7 @@ pub struct InlineCompletionButton {
     editor_enabled: Option<bool>,
     language: Option<Arc<Language>>,
     file: Option<Arc<dyn File>>,
+    inline_completion_provider: Option<Arc<dyn inline_completion::InlineCompletionProviderHandle>>,
     fs: Arc<dyn Fs>,
     workspace: WeakView<Workspace>,
 }
@@ -205,17 +207,34 @@ impl Render for InlineCompletionButton {
                 }
 
                 let this = cx.view().clone();
-                div().child(
-                    PopoverMenu::new("zeta")
-                        .menu(move |cx| {
-                            Some(this.update(cx, |this, cx| this.build_zeta_context_menu(cx)))
-                        })
-                        .anchor(Corner::BottomRight)
-                        .trigger(
-                            IconButton::new("zeta", IconName::ZedPredict)
-                                .tooltip(|cx| Tooltip::text("Zed Predict", cx)),
+                let button = IconButton::new("zeta", IconName::ZedPredict)
+                    .tooltip(|cx| Tooltip::text("Zed Predict", cx));
+
+                let is_refreshing = self
+                    .inline_completion_provider
+                    .as_ref()
+                    .map_or(false, |provider| provider.is_refreshing(cx));
+
+                let mut popover_menu = PopoverMenu::new("zeta")
+                    .menu(move |cx| {
+                        Some(this.update(cx, |this, cx| this.build_zeta_context_menu(cx)))
+                    })
+                    .anchor(Corner::BottomRight);
+                if is_refreshing {
+                    popover_menu = popover_menu.trigger(
+                        button.with_animation(
+                            "pulsating-label",
+                            Animation::new(Duration::from_secs(2))
+                                .repeat()
+                                .with_easing(pulsating_between(0.2, 1.0)),
+                            |icon_button, delta| icon_button.alpha(delta),
                         ),
-                )
+                    );
+                } else {
+                    popover_menu = popover_menu.trigger(button);
+                }
+
+                div().child(popover_menu.into_any_element())
             }
         }
     }
@@ -239,6 +258,7 @@ impl InlineCompletionButton {
             editor_enabled: None,
             language: None,
             file: None,
+            inline_completion_provider: None,
             workspace,
             fs,
         }
@@ -390,6 +410,7 @@ impl InlineCompletionButton {
                     ),
             )
         };
+        self.inline_completion_provider = editor.inline_completion_provider();
         self.language = language.cloned();
         self.file = file;
 

crates/supermaven/src/supermaven_completion_provider.rs 🔗

@@ -19,7 +19,7 @@ pub struct SupermavenCompletionProvider {
     buffer_id: Option<EntityId>,
     completion_id: Option<SupermavenCompletionStateId>,
     file_extension: Option<String>,
-    pending_refresh: Task<Result<()>>,
+    pending_refresh: Option<Task<Result<()>>>,
 }
 
 impl SupermavenCompletionProvider {
@@ -29,7 +29,7 @@ impl SupermavenCompletionProvider {
             buffer_id: None,
             completion_id: None,
             file_extension: None,
-            pending_refresh: Task::ready(Ok(())),
+            pending_refresh: None,
         }
     }
 }
@@ -122,6 +122,10 @@ impl InlineCompletionProvider for SupermavenCompletionProvider {
         settings.inline_completions_enabled(language.as_ref(), file.map(|f| f.path().as_ref()), cx)
     }
 
+    fn is_refreshing(&self) -> bool {
+        self.pending_refresh.is_some()
+    }
+
     fn refresh(
         &mut self,
         buffer_handle: Model<Buffer>,
@@ -135,7 +139,7 @@ impl InlineCompletionProvider for SupermavenCompletionProvider {
             return;
         };
 
-        self.pending_refresh = cx.spawn(|this, mut cx| async move {
+        self.pending_refresh = Some(cx.spawn(|this, mut cx| async move {
             if debounce {
                 cx.background_executor().timer(DEBOUNCE_TIMEOUT).await;
             }
@@ -152,11 +156,12 @@ impl InlineCompletionProvider for SupermavenCompletionProvider {
                                 .to_string(),
                         )
                     });
+                    this.pending_refresh = None;
                     cx.notify();
                 })?;
             }
             Ok(())
-        });
+        }));
     }
 
     fn cycle(
@@ -169,12 +174,12 @@ impl InlineCompletionProvider for SupermavenCompletionProvider {
     }
 
     fn accept(&mut self, _cx: &mut ModelContext<Self>) {
-        self.pending_refresh = Task::ready(Ok(()));
+        self.pending_refresh = None;
         self.completion_id = None;
     }
 
     fn discard(&mut self, _cx: &mut ModelContext<Self>) {
-        self.pending_refresh = Task::ready(Ok(()));
+        self.pending_refresh = None;
         self.completion_id = None;
     }
 

crates/ui/src/components/button/icon_button.rs 🔗

@@ -22,6 +22,7 @@ pub struct IconButton {
     icon_size: IconSize,
     icon_color: Color,
     selected_icon: Option<IconName>,
+    alpha: Option<f32>,
 }
 
 impl IconButton {
@@ -33,6 +34,7 @@ impl IconButton {
             icon_size: IconSize::default(),
             icon_color: Color::Default,
             selected_icon: None,
+            alpha: None,
         };
         this.base.base = this.base.base.debug_selector(|| format!("ICON-{:?}", icon));
         this
@@ -53,6 +55,11 @@ impl IconButton {
         self
     }
 
+    pub fn alpha(mut self, alpha: f32) -> Self {
+        self.alpha = Some(alpha);
+        self
+    }
+
     pub fn selected_icon(mut self, icon: impl Into<Option<IconName>>) -> Self {
         self.selected_icon = icon.into();
         self
@@ -146,6 +153,7 @@ impl RenderOnce for IconButton {
         let is_selected = self.base.selected;
         let selected_style = self.base.selected_style;
 
+        let color = self.icon_color.color(cx).opacity(self.alpha.unwrap_or(1.0));
         self.base
             .map(|this| match self.shape {
                 IconButtonShape::Square => {
@@ -161,7 +169,7 @@ impl RenderOnce for IconButton {
                     .selected_icon(self.selected_icon)
                     .when_some(selected_style, |this, style| this.selected_style(style))
                     .size(self.icon_size)
-                    .color(self.icon_color),
+                    .color(Color::Custom(color)),
             )
     }
 }

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

@@ -15,6 +15,28 @@ pub trait PopoverTrigger: IntoElement + Clickable + Toggleable + 'static {}
 
 impl<T: IntoElement + Clickable + Toggleable + 'static> PopoverTrigger for T {}
 
+impl<T: Clickable> Clickable for gpui::AnimationElement<T>
+where
+    T: Clickable + 'static,
+{
+    fn on_click(self, handler: impl Fn(&gpui::ClickEvent, &mut WindowContext) + 'static) -> Self {
+        self.map_element(|e| e.on_click(handler))
+    }
+
+    fn cursor_style(self, cursor_style: gpui::CursorStyle) -> Self {
+        self.map_element(|e| e.cursor_style(cursor_style))
+    }
+}
+
+impl<T: Toggleable> Toggleable for gpui::AnimationElement<T>
+where
+    T: Toggleable + 'static,
+{
+    fn toggle_state(self, selected: bool) -> Self {
+        self.map_element(|e| e.toggle_state(selected))
+    }
+}
+
 pub struct PopoverMenuHandle<M>(Rc<RefCell<Option<PopoverMenuHandleState<M>>>>);
 
 impl<M> Clone for PopoverMenuHandle<M> {

crates/zeta/src/zeta.rs 🔗

@@ -1027,6 +1027,10 @@ impl inline_completion::InlineCompletionProvider for ZetaInlineCompletionProvide
         settings.inline_completions_enabled(language.as_ref(), file.map(|f| f.path().as_ref()), cx)
     }
 
+    fn is_refreshing(&self) -> bool {
+        !self.pending_completions.is_empty()
+    }
+
     fn refresh(
         &mut self,
         buffer: Model<Buffer>,