acp: Animate loading context creases (#36814)

Cole Miller created

- Add pulsating animation for context creases while they're loading
- Add spinner in message editors (replacing send button) during the
window where sending has been requested, but we haven't finished loading
the message contents to send to the model
- During the same window, ignore further send requests, so we don't end
up sending the same message twice if you mash enter while loading is in
progress
- Wait for context to load before rewinding the thread when sending an
edited past message, avoiding an empty-looking state during the same
window

Release Notes:

- N/A

Change summary

Cargo.lock                                |   1 
crates/agent_ui/Cargo.toml                |   1 
crates/agent_ui/src/acp/message_editor.rs | 224 ++++++++++++++++--------
crates/agent_ui/src/acp/thread_view.rs    |  92 +++++++--
4 files changed, 217 insertions(+), 101 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -403,6 +403,7 @@ dependencies = [
  "parking_lot",
  "paths",
  "picker",
+ "postage",
  "pretty_assertions",
  "project",
  "prompt_store",

crates/agent_ui/Cargo.toml 🔗

@@ -67,6 +67,7 @@ ordered-float.workspace = true
 parking_lot.workspace = true
 paths.workspace = true
 picker.workspace = true
+postage.workspace = true
 project.workspace = true
 prompt_store.workspace = true
 proto.workspace = true

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

@@ -21,12 +21,13 @@ use futures::{
     future::{Shared, join_all},
 };
 use gpui::{
-    AppContext, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable,
-    HighlightStyle, Image, ImageFormat, Img, KeyContext, Subscription, Task, TextStyle,
-    UnderlineStyle, WeakEntity,
+    Animation, AnimationExt as _, AppContext, ClipboardEntry, Context, Entity, EntityId,
+    EventEmitter, FocusHandle, Focusable, HighlightStyle, Image, ImageFormat, Img, KeyContext,
+    Subscription, Task, TextStyle, UnderlineStyle, WeakEntity, pulsating_between,
 };
 use language::{Buffer, Language};
 use language_model::LanguageModelImage;
+use postage::stream::Stream as _;
 use project::{CompletionIntent, Project, ProjectItem, ProjectPath, Worktree};
 use prompt_store::{PromptId, PromptStore};
 use rope::Point;
@@ -44,10 +45,10 @@ use std::{
 use text::{OffsetRangeExt, ToOffset as _};
 use theme::ThemeSettings;
 use ui::{
-    ActiveTheme, AnyElement, App, ButtonCommon, ButtonLike, ButtonStyle, Color, Icon, IconName,
-    IconSize, InteractiveElement, IntoElement, Label, LabelCommon, LabelSize, ParentElement,
-    Render, SelectableButton, SharedString, Styled, TextSize, TintColor, Toggleable, Window, div,
-    h_flex, px,
+    ActiveTheme, AnyElement, App, ButtonCommon, ButtonLike, ButtonStyle, Color, Element as _,
+    FluentBuilder as _, Icon, IconName, IconSize, InteractiveElement, IntoElement, Label,
+    LabelCommon, LabelSize, ParentElement, Render, SelectableButton, SharedString, Styled,
+    TextSize, TintColor, Toggleable, Window, div, h_flex, px,
 };
 use util::{ResultExt, debug_panic};
 use workspace::{Workspace, notifications::NotifyResultExt as _};
@@ -246,7 +247,7 @@ impl MessageEditor {
             .buffer_snapshot
             .anchor_before(start_anchor.to_offset(&snapshot.buffer_snapshot) + content_len + 1);
 
-        let crease_id = if let MentionUri::File { abs_path } = &mention_uri
+        let crease = if let MentionUri::File { abs_path } = &mention_uri
             && let Some(extension) = abs_path.extension()
             && let Some(extension) = extension.to_str()
             && Img::extensions().contains(&extension)
@@ -272,29 +273,31 @@ impl MessageEditor {
                     Ok(image)
                 })
                 .shared();
-            insert_crease_for_image(
+            insert_crease_for_mention(
                 *excerpt_id,
                 start,
                 content_len,
-                Some(abs_path.as_path().into()),
-                image,
+                mention_uri.name().into(),
+                IconName::Image.path().into(),
+                Some(image),
                 self.editor.clone(),
                 window,
                 cx,
             )
         } else {
-            crate::context_picker::insert_crease_for_mention(
+            insert_crease_for_mention(
                 *excerpt_id,
                 start,
                 content_len,
                 crease_text,
                 mention_uri.icon_path(cx),
+                None,
                 self.editor.clone(),
                 window,
                 cx,
             )
         };
-        let Some(crease_id) = crease_id else {
+        let Some((crease_id, tx)) = crease else {
             return Task::ready(());
         };
 
@@ -331,7 +334,9 @@ impl MessageEditor {
 
         // Notify the user if we failed to load the mentioned context
         cx.spawn_in(window, async move |this, cx| {
-            if task.await.notify_async_err(cx).is_none() {
+            let result = task.await.notify_async_err(cx);
+            drop(tx);
+            if result.is_none() {
                 this.update(cx, |this, cx| {
                     this.editor.update(cx, |editor, cx| {
                         // Remove mention
@@ -857,12 +862,13 @@ impl MessageEditor {
                 snapshot.anchor_before(start_anchor.to_offset(&snapshot) + content_len)
             });
             let image = Arc::new(image);
-            let Some(crease_id) = insert_crease_for_image(
+            let Some((crease_id, tx)) = insert_crease_for_mention(
                 excerpt_id,
                 text_anchor,
                 content_len,
-                None.clone(),
-                Task::ready(Ok(image.clone())).shared(),
+                MentionUri::PastedImage.name().into(),
+                IconName::Image.path().into(),
+                Some(Task::ready(Ok(image.clone())).shared()),
                 self.editor.clone(),
                 window,
                 cx,
@@ -877,6 +883,7 @@ impl MessageEditor {
                             .update(|_, cx| LanguageModelImage::from_image(image, cx))
                             .map_err(|e| e.to_string())?
                             .await;
+                        drop(tx);
                         if let Some(image) = image {
                             Ok(Mention::Image(MentionImage {
                                 data: image.source,
@@ -1097,18 +1104,20 @@ impl MessageEditor {
 
         for (range, mention_uri, mention) in mentions {
             let anchor = snapshot.anchor_before(range.start);
-            let Some(crease_id) = crate::context_picker::insert_crease_for_mention(
+            let Some((crease_id, tx)) = insert_crease_for_mention(
                 anchor.excerpt_id,
                 anchor.text_anchor,
                 range.end - range.start,
                 mention_uri.name().into(),
                 mention_uri.icon_path(cx),
+                None,
                 self.editor.clone(),
                 window,
                 cx,
             ) else {
                 continue;
             };
+            drop(tx);
 
             self.mention_set.mentions.insert(
                 crease_id,
@@ -1227,23 +1236,21 @@ impl Render for MessageEditor {
     }
 }
 
-pub(crate) fn insert_crease_for_image(
+pub(crate) fn insert_crease_for_mention(
     excerpt_id: ExcerptId,
     anchor: text::Anchor,
     content_len: usize,
-    abs_path: Option<Arc<Path>>,
-    image: Shared<Task<Result<Arc<Image>, String>>>,
+    crease_label: SharedString,
+    crease_icon: SharedString,
+    // abs_path: Option<Arc<Path>>,
+    image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
     editor: Entity<Editor>,
     window: &mut Window,
     cx: &mut App,
-) -> Option<CreaseId> {
-    let crease_label = abs_path
-        .as_ref()
-        .and_then(|path| path.file_name())
-        .map(|name| name.to_string_lossy().to_string().into())
-        .unwrap_or(SharedString::from("Image"));
-
-    editor.update(cx, |editor, cx| {
+) -> Option<(CreaseId, postage::barrier::Sender)> {
+    let (tx, rx) = postage::barrier::channel();
+
+    let crease_id = editor.update(cx, |editor, cx| {
         let snapshot = editor.buffer().read(cx).snapshot(cx);
 
         let start = snapshot.anchor_in_excerpt(excerpt_id, anchor)?;
@@ -1252,7 +1259,15 @@ pub(crate) fn insert_crease_for_image(
         let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
 
         let placeholder = FoldPlaceholder {
-            render: render_image_fold_icon_button(crease_label, image, cx.weak_entity()),
+            render: render_fold_icon_button(
+                crease_label,
+                crease_icon,
+                start..end,
+                rx,
+                image,
+                cx.weak_entity(),
+                cx,
+            ),
             merge_adjacent: false,
             ..Default::default()
         };
@@ -1269,63 +1284,112 @@ pub(crate) fn insert_crease_for_image(
         editor.fold_creases(vec![crease], false, window, cx);
 
         Some(ids[0])
-    })
+    })?;
+
+    Some((crease_id, tx))
 }
 
-fn render_image_fold_icon_button(
+fn render_fold_icon_button(
     label: SharedString,
-    image_task: Shared<Task<Result<Arc<Image>, String>>>,
+    icon: SharedString,
+    range: Range<Anchor>,
+    mut loading_finished: postage::barrier::Receiver,
+    image_task: Option<Shared<Task<Result<Arc<Image>, String>>>>,
     editor: WeakEntity<Editor>,
+    cx: &mut App,
 ) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
-    Arc::new({
-        move |fold_id, fold_range, cx| {
-            let is_in_text_selection = editor
-                .update(cx, |editor, cx| editor.is_range_selected(&fold_range, cx))
-                .unwrap_or_default();
-
-            ButtonLike::new(fold_id)
-                .style(ButtonStyle::Filled)
-                .selected_style(ButtonStyle::Tinted(TintColor::Accent))
-                .toggle_state(is_in_text_selection)
-                .child(
-                    h_flex()
-                        .gap_1()
-                        .child(
-                            Icon::new(IconName::Image)
-                                .size(IconSize::XSmall)
-                                .color(Color::Muted),
-                        )
-                        .child(
-                            Label::new(label.clone())
-                                .size(LabelSize::Small)
-                                .buffer_font(cx)
-                                .single_line(),
-                        ),
-                )
-                .hoverable_tooltip({
+    let loading = cx.new(|cx| {
+        let loading = cx.spawn(async move |this, cx| {
+            loading_finished.recv().await;
+            this.update(cx, |this: &mut LoadingContext, cx| {
+                this.loading = None;
+                cx.notify();
+            })
+            .ok();
+        });
+        LoadingContext {
+            id: cx.entity_id(),
+            label,
+            icon,
+            range,
+            editor,
+            loading: Some(loading),
+            image: image_task.clone(),
+        }
+    });
+    Arc::new(move |_fold_id, _fold_range, _cx| loading.clone().into_any_element())
+}
+
+struct LoadingContext {
+    id: EntityId,
+    label: SharedString,
+    icon: SharedString,
+    range: Range<Anchor>,
+    editor: WeakEntity<Editor>,
+    loading: Option<Task<()>>,
+    image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
+}
+
+impl Render for LoadingContext {
+    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        let is_in_text_selection = self
+            .editor
+            .update(cx, |editor, cx| editor.is_range_selected(&self.range, cx))
+            .unwrap_or_default();
+        ButtonLike::new(("loading-context", self.id))
+            .style(ButtonStyle::Filled)
+            .selected_style(ButtonStyle::Tinted(TintColor::Accent))
+            .toggle_state(is_in_text_selection)
+            .when_some(self.image.clone(), |el, image_task| {
+                el.hoverable_tooltip(move |_, cx| {
+                    let image = image_task.peek().cloned().transpose().ok().flatten();
                     let image_task = image_task.clone();
-                    move |_, cx| {
-                        let image = image_task.peek().cloned().transpose().ok().flatten();
-                        let image_task = image_task.clone();
-                        cx.new::<ImageHover>(|cx| ImageHover {
-                            image,
-                            _task: cx.spawn(async move |this, cx| {
-                                if let Ok(image) = image_task.clone().await {
-                                    this.update(cx, |this, cx| {
-                                        if this.image.replace(image).is_none() {
-                                            cx.notify();
-                                        }
-                                    })
-                                    .ok();
-                                }
-                            }),
-                        })
-                        .into()
-                    }
+                    cx.new::<ImageHover>(|cx| ImageHover {
+                        image,
+                        _task: cx.spawn(async move |this, cx| {
+                            if let Ok(image) = image_task.clone().await {
+                                this.update(cx, |this, cx| {
+                                    if this.image.replace(image).is_none() {
+                                        cx.notify();
+                                    }
+                                })
+                                .ok();
+                            }
+                        }),
+                    })
+                    .into()
                 })
-                .into_any_element()
-        }
-    })
+            })
+            .child(
+                h_flex()
+                    .gap_1()
+                    .child(
+                        Icon::from_path(self.icon.clone())
+                            .size(IconSize::XSmall)
+                            .color(Color::Muted),
+                    )
+                    .child(
+                        Label::new(self.label.clone())
+                            .size(LabelSize::Small)
+                            .buffer_font(cx)
+                            .single_line(),
+                    )
+                    .map(|el| {
+                        if self.loading.is_some() {
+                            el.with_animation(
+                                "loading-context-crease",
+                                Animation::new(Duration::from_secs(2))
+                                    .repeat()
+                                    .with_easing(pulsating_between(0.4, 0.8)),
+                                |label, delta| label.opacity(delta),
+                            )
+                            .into_any()
+                        } else {
+                            el.into_any()
+                        }
+                    }),
+            )
+    }
 }
 
 struct ImageHover {

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

@@ -277,6 +277,7 @@ pub struct AcpThreadView {
     terminal_expanded: bool,
     editing_message: Option<usize>,
     prompt_capabilities: Rc<Cell<PromptCapabilities>>,
+    is_loading_contents: bool,
     _cancel_task: Option<Task<()>>,
     _subscriptions: [Subscription; 3],
 }
@@ -389,6 +390,7 @@ impl AcpThreadView {
             history_store,
             hovered_recent_history_item: None,
             prompt_capabilities,
+            is_loading_contents: false,
             _subscriptions: subscriptions,
             _cancel_task: None,
             focus_handle: cx.focus_handle(),
@@ -823,6 +825,11 @@ impl AcpThreadView {
 
     fn send(&mut self, window: &mut Window, cx: &mut Context<Self>) {
         let Some(thread) = self.thread() else { return };
+
+        if self.is_loading_contents {
+            return;
+        }
+
         self.history_store.update(cx, |history, cx| {
             history.push_recently_opened_entry(
                 HistoryEntryId::AcpThread(thread.read(cx).session_id().clone()),
@@ -876,6 +883,15 @@ impl AcpThreadView {
         let Some(thread) = self.thread().cloned() else {
             return;
         };
+
+        self.is_loading_contents = true;
+        let guard = cx.new(|_| ());
+        cx.observe_release(&guard, |this, _guard, cx| {
+            this.is_loading_contents = false;
+            cx.notify();
+        })
+        .detach();
+
         let task = cx.spawn_in(window, async move |this, cx| {
             let (contents, tracked_buffers) = contents.await?;
 
@@ -896,6 +912,7 @@ impl AcpThreadView {
                         action_log.buffer_read(buffer, cx)
                     }
                 });
+                drop(guard);
                 thread.send(contents, cx)
             })?;
             send.await
@@ -950,19 +967,24 @@ impl AcpThreadView {
         let Some(thread) = self.thread().cloned() else {
             return;
         };
+        if self.is_loading_contents {
+            return;
+        }
 
-        let Some(rewind) = thread.update(cx, |thread, cx| {
-            let user_message_id = thread.entries().get(entry_ix)?.user_message()?.id.clone()?;
-            Some(thread.rewind(user_message_id, cx))
+        let Some(user_message_id) = thread.update(cx, |thread, _| {
+            thread.entries().get(entry_ix)?.user_message()?.id.clone()
         }) else {
             return;
         };
 
         let contents = message_editor.update(cx, |message_editor, cx| message_editor.contents(cx));
 
-        let task = cx.foreground_executor().spawn(async move {
-            rewind.await?;
-            contents.await
+        let task = cx.spawn(async move |_, cx| {
+            let contents = contents.await?;
+            thread
+                .update(cx, |thread, cx| thread.rewind(user_message_id, cx))?
+                .await?;
+            Ok(contents)
         });
         self.send_impl(task, window, cx);
     }
@@ -1341,25 +1363,34 @@ impl AcpThreadView {
                                         base_container
                                             .child(
                                                 IconButton::new("cancel", IconName::Close)
+                                                    .disabled(self.is_loading_contents)
                                                     .icon_color(Color::Error)
                                                     .icon_size(IconSize::XSmall)
                                                     .on_click(cx.listener(Self::cancel_editing))
                                             )
                                             .child(
-                                                IconButton::new("regenerate", IconName::Return)
-                                                    .icon_color(Color::Muted)
-                                                    .icon_size(IconSize::XSmall)
-                                                    .tooltip(Tooltip::text(
-                                                        "Editing will restart the thread from this point."
-                                                    ))
-                                                    .on_click(cx.listener({
-                                                        let editor = editor.clone();
-                                                        move |this, _, window, cx| {
-                                                            this.regenerate(
-                                                                entry_ix, &editor, window, cx,
-                                                            );
-                                                        }
-                                                    })),
+                                                if self.is_loading_contents {
+                                                    div()
+                                                        .id("loading-edited-message-content")
+                                                        .tooltip(Tooltip::text("Loading Added Context…"))
+                                                        .child(loading_contents_spinner(IconSize::XSmall))
+                                                        .into_any_element()
+                                                } else {
+                                                    IconButton::new("regenerate", IconName::Return)
+                                                        .icon_color(Color::Muted)
+                                                        .icon_size(IconSize::XSmall)
+                                                        .tooltip(Tooltip::text(
+                                                            "Editing will restart the thread from this point."
+                                                        ))
+                                                        .on_click(cx.listener({
+                                                            let editor = editor.clone();
+                                                            move |this, _, window, cx| {
+                                                                this.regenerate(
+                                                                    entry_ix, &editor, window, cx,
+                                                                );
+                                                            }
+                                                        })).into_any_element()
+                                                }
                                             )
                                     )
                                 } else {
@@ -3542,7 +3573,14 @@ impl AcpThreadView {
             .thread()
             .is_some_and(|thread| thread.read(cx).status() != ThreadStatus::Idle);
 
-        if is_generating && is_editor_empty {
+        if self.is_loading_contents {
+            div()
+                .id("loading-message-content")
+                .px_1()
+                .tooltip(Tooltip::text("Loading Added Context…"))
+                .child(loading_contents_spinner(IconSize::default()))
+                .into_any_element()
+        } else if is_generating && is_editor_empty {
             IconButton::new("stop-generation", IconName::Stop)
                 .icon_color(Color::Error)
                 .style(ButtonStyle::Tinted(ui::TintColor::Error))
@@ -4643,6 +4681,18 @@ impl AcpThreadView {
     }
 }
 
+fn loading_contents_spinner(size: IconSize) -> AnyElement {
+    Icon::new(IconName::LoadCircle)
+        .size(size)
+        .color(Color::Accent)
+        .with_animation(
+            "load_context_circle",
+            Animation::new(Duration::from_secs(3)).repeat(),
+            |icon, delta| icon.transform(Transformation::rotate(percentage(delta))),
+        )
+        .into_any_element()
+}
+
 impl Focusable for AcpThreadView {
     fn focus_handle(&self, cx: &App) -> FocusHandle {
         match self.thread_state {