acp: Animate loading context creases (#36814)

- 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
This commit is contained in:
Cole Miller 2025-08-23 16:39:14 -04:00 committed by Joseph T. Lyons
parent ad6bc4586a
commit a313e9d869
4 changed files with 217 additions and 101 deletions

1
Cargo.lock generated
View file

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

View file

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

View file

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

View file

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