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 GitHub
parent d49409caba
commit 19764794b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 217 additions and 101 deletions

View file

@ -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 {