agent2: Add hover preview for image creases (#36427)

Note that (at least for now) this only works for creases in the "new
message" editor, not when editing past messages. That's because we don't
have the original image available when putting together the creases for
past messages, only the base64-encoded language model content.

Release Notes:

- N/A
This commit is contained in:
Cole Miller 2025-08-18 23:26:15 -04:00 committed by GitHub
parent 1b6fd996f8
commit 821e97a392
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 111 additions and 64 deletions

View file

@ -178,6 +178,56 @@ impl MessageEditor {
return;
};
if let MentionUri::File { abs_path, .. } = &mention_uri {
let extension = abs_path
.extension()
.and_then(OsStr::to_str)
.unwrap_or_default();
if Img::extensions().contains(&extension) && !extension.contains("svg") {
let project = self.project.clone();
let Some(project_path) = project
.read(cx)
.project_path_for_absolute_path(abs_path, cx)
else {
return;
};
let image = cx
.spawn(async move |_, cx| {
let image = project
.update(cx, |project, cx| project.open_image(project_path, cx))
.map_err(|e| e.to_string())?
.await
.map_err(|e| e.to_string())?;
image
.read_with(cx, |image, _cx| image.image.clone())
.map_err(|e| e.to_string())
})
.shared();
let Some(crease_id) = insert_crease_for_image(
*excerpt_id,
start,
content_len,
Some(abs_path.as_path().into()),
image.clone(),
self.editor.clone(),
window,
cx,
) else {
return;
};
self.confirm_mention_for_image(
crease_id,
anchor,
Some(abs_path.clone()),
image,
window,
cx,
);
return;
}
}
let Some(crease_id) = crate::context_picker::insert_crease_for_mention(
*excerpt_id,
start,
@ -195,71 +245,21 @@ impl MessageEditor {
MentionUri::Fetch { url } => {
self.confirm_mention_for_fetch(crease_id, anchor, url, window, cx);
}
MentionUri::File {
abs_path,
is_directory,
} => {
self.confirm_mention_for_file(
crease_id,
anchor,
abs_path,
is_directory,
window,
cx,
);
}
MentionUri::Thread { id, name } => {
self.confirm_mention_for_thread(crease_id, anchor, id, name, window, cx);
}
MentionUri::TextThread { path, name } => {
self.confirm_mention_for_text_thread(crease_id, anchor, path, name, window, cx);
}
MentionUri::Symbol { .. } | MentionUri::Rule { .. } | MentionUri::Selection { .. } => {
MentionUri::File { .. }
| MentionUri::Symbol { .. }
| MentionUri::Rule { .. }
| MentionUri::Selection { .. } => {
self.mention_set.insert_uri(crease_id, mention_uri.clone());
}
}
}
fn confirm_mention_for_file(
&mut self,
crease_id: CreaseId,
anchor: Anchor,
abs_path: PathBuf,
is_directory: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
let extension = abs_path
.extension()
.and_then(OsStr::to_str)
.unwrap_or_default();
if Img::extensions().contains(&extension) && !extension.contains("svg") {
let project = self.project.clone();
let Some(project_path) = project
.read(cx)
.project_path_for_absolute_path(&abs_path, cx)
else {
return;
};
let image = cx.spawn(async move |_, cx| {
let image = project
.update(cx, |project, cx| project.open_image(project_path, cx))?
.await?;
image.read_with(cx, |image, _cx| image.image.clone())
});
self.confirm_mention_for_image(crease_id, anchor, Some(abs_path), image, window, cx);
} else {
self.mention_set.insert_uri(
crease_id,
MentionUri::File {
abs_path,
is_directory,
},
);
}
}
fn confirm_mention_for_fetch(
&mut self,
crease_id: CreaseId,
@ -498,25 +498,20 @@ impl MessageEditor {
let Some(anchor) = multibuffer_anchor else {
return;
};
let task = Task::ready(Ok(Arc::new(image))).shared();
let Some(crease_id) = insert_crease_for_image(
excerpt_id,
text_anchor,
content_len,
None.clone(),
task.clone(),
self.editor.clone(),
window,
cx,
) else {
return;
};
self.confirm_mention_for_image(
crease_id,
anchor,
None,
Task::ready(Ok(Arc::new(image))),
window,
cx,
);
self.confirm_mention_for_image(crease_id, anchor, None, task, window, cx);
}
}
@ -584,7 +579,7 @@ impl MessageEditor {
crease_id: CreaseId,
anchor: Anchor,
abs_path: Option<PathBuf>,
image: Task<Result<Arc<Image>>>,
image: Shared<Task<Result<Arc<Image>, String>>>,
window: &mut Window,
cx: &mut Context<Self>,
) {
@ -937,6 +932,7 @@ pub(crate) fn insert_crease_for_image(
anchor: text::Anchor,
content_len: usize,
abs_path: Option<Arc<Path>>,
image: Shared<Task<Result<Arc<Image>, String>>>,
editor: Entity<Editor>,
window: &mut Window,
cx: &mut App,
@ -956,7 +952,7 @@ 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, cx.weak_entity()),
render: render_image_fold_icon_button(crease_label, image, cx.weak_entity()),
merge_adjacent: false,
..Default::default()
};
@ -978,9 +974,11 @@ pub(crate) fn insert_crease_for_image(
fn render_image_fold_icon_button(
label: SharedString,
image_task: Shared<Task<Result<Arc<Image>, String>>>,
editor: WeakEntity<Editor>,
) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
Arc::new({
let image_task = image_task.clone();
move |fold_id, fold_range, cx| {
let is_in_text_selection = editor
.update(cx, |editor, cx| editor.is_range_selected(&fold_range, cx))
@ -1005,11 +1003,47 @@ fn render_image_fold_icon_button(
.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()
}
})
}
struct ImageHover {
image: Option<Arc<Image>>,
_task: Task<()>,
}
impl Render for ImageHover {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
if let Some(image) = self.image.clone() {
gpui::img(image).max_w_96().max_h_96().into_any_element()
} else {
gpui::Empty.into_any_element()
}
}
}
#[derive(Debug, Eq, PartialEq)]
pub enum Mention {
Text { uri: MentionUri, content: String },

View file

@ -400,6 +400,7 @@ pub struct ButtonLike {
size: ButtonSize,
rounding: Option<ButtonLikeRounding>,
tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView>>,
hoverable_tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView>>,
cursor_style: CursorStyle,
on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
on_right_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
@ -420,6 +421,7 @@ impl ButtonLike {
size: ButtonSize::Default,
rounding: Some(ButtonLikeRounding::All),
tooltip: None,
hoverable_tooltip: None,
children: SmallVec::new(),
cursor_style: CursorStyle::PointingHand,
on_click: None,
@ -463,6 +465,14 @@ impl ButtonLike {
self.on_right_click = Some(Box::new(handler));
self
}
pub fn hoverable_tooltip(
mut self,
tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
) -> Self {
self.hoverable_tooltip = Some(Box::new(tooltip));
self
}
}
impl Disableable for ButtonLike {
@ -654,6 +664,9 @@ impl RenderOnce for ButtonLike {
.when_some(self.tooltip, |this, tooltip| {
this.tooltip(move |window, cx| tooltip(window, cx))
})
.when_some(self.hoverable_tooltip, |this, tooltip| {
this.hoverable_tooltip(move |window, cx| tooltip(window, cx))
})
.children(self.children)
}
}