Cargo.lock 🔗
@@ -403,6 +403,7 @@ dependencies = [
"parking_lot",
"paths",
"picker",
+ "postage",
"pretty_assertions",
"project",
"prompt_store",
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
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(-)
@@ -403,6 +403,7 @@ dependencies = [
"parking_lot",
"paths",
"picker",
+ "postage",
"pretty_assertions",
"project",
"prompt_store",
@@ -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
@@ -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 {
@@ -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 {