agent_ui: Preserve selection mentions when starting a new thread (#55203)

When the "+" button created a fresh draft, `active_initial_content`
fell back to the raw editor text when the async `draft_prompt`
observer had not yet resolved. That raw text contains fold placeholder
strings (e.g. "selection") rather than the mention links, so creases
and their registered URIs were dropped from the carried-over draft.

Related to https://github.com/zed-industries/zed/issues/53981.

Release Notes:

- Fixed a bug where selection mentions would resolve to the literal
`selection` rather than the URI in draft threads.
This commit is contained in:
Neel 2026-05-07 13:40:48 +01:00 committed by GitHub
parent e6b8b30e22
commit 9a125a553d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 205 additions and 103 deletions

View file

@ -2597,31 +2597,26 @@ impl AgentPanel {
}
fn active_initial_content(&self, cx: &App) -> Option<AgentInitialContent> {
self.active_thread_view(cx).and_then(|thread_view| {
let thread_view = self.active_thread_view(cx)?;
let thread_view = thread_view.read(cx);
let saved = thread_view
.thread
.read(cx)
.draft_prompt()
.map(|blocks| blocks.to_vec())
.filter(|blocks| !blocks.is_empty());
let blocks = saved.unwrap_or_else(|| {
thread_view
.message_editor
.read(cx)
.thread
.read(cx)
.draft_prompt()
.map(|draft| AgentInitialContent::ContentBlock {
blocks: draft.to_vec(),
auto_submit: false,
})
.filter(|initial_content| match initial_content {
AgentInitialContent::ContentBlock { blocks, .. } => !blocks.is_empty(),
_ => true,
})
.or_else(|| {
let text = thread_view.read(cx).message_editor.read(cx).text(cx);
if text.trim().is_empty() {
None
} else {
Some(AgentInitialContent::ContentBlock {
blocks: vec![acp::ContentBlock::Text(acp::TextContent::new(text))],
auto_submit: false,
})
}
})
.draft_content_blocks_snapshot(cx)
});
if blocks.is_empty() {
return None;
}
Some(AgentInitialContent::ContentBlock {
blocks,
auto_submit: false,
})
}

View file

@ -178,6 +178,16 @@ impl MentionSet {
self.mentions.get(crease_id).map(|(uri, _)| uri.clone())
}
/// Returns the resolved mention for a crease, if any.
pub fn resolved_mention_for_crease(
&self,
crease_id: &CreaseId,
) -> Option<(MentionUri, Option<Mention>)> {
let (uri, task) = self.mentions.get(crease_id)?;
let mention = task.clone().now_or_never().and_then(|result| result.ok());
Some((uri.clone(), mention))
}
pub fn set_mentions(&mut self, mentions: HashMap<CreaseId, (MentionUri, MentionTask)>) {
self.mentions = mentions;
}

View file

@ -17,6 +17,7 @@ use editor::{
EditorStyle, Inlay, MultiBuffer, MultiBufferOffset, MultiBufferSnapshot, ToOffset,
actions::{Copy, Paste},
code_context_menus::CodeContextMenu,
display_map::{CreaseId, CreaseSnapshot},
scroll::Autoscroll,
};
use futures::{FutureExt as _, future::join_all};
@ -768,90 +769,46 @@ impl MessageEditor {
self.session_capabilities.read().supports_embedded_context();
cx.spawn(async move |_, cx| {
let contents = contents.await?;
let mut all_tracked_buffers = Vec::new();
let result = editor.update(cx, |editor, cx| {
let mut contents = contents.await?;
Ok(editor.update(cx, |editor, cx| {
let crease_snapshot = editor.display_map.read(cx).crease_snapshot();
let buffer_snapshot = editor.buffer().read(cx).snapshot(cx);
let text = editor.text(cx);
let (mut ix, _) = text
.char_indices()
.find(|(_, c)| !c.is_whitespace())
.unwrap_or((0, '\0'));
let mut chunks: Vec<acp::ContentBlock> = Vec::new();
editor.display_map.update(cx, |map, cx| {
let snapshot = map.snapshot(cx);
for (crease_id, crease) in snapshot.crease_snapshot.creases() {
let Some((uri, mention)) = contents.get(&crease_id) else {
continue;
};
let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot());
if crease_range.start.0 > ix {
let chunk = text[ix..crease_range.start.0].into();
chunks.push(chunk);
}
let chunk = match mention {
Mention::Text {
content,
tracked_buffers,
} => {
all_tracked_buffers.extend(tracked_buffers.iter().cloned());
if supports_embedded_context {
acp::ContentBlock::Resource(acp::EmbeddedResource::new(
acp::EmbeddedResourceResource::TextResourceContents(
acp::TextResourceContents::new(
content.clone(),
uri.to_uri().to_string(),
),
),
))
} else {
acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
uri.name(),
uri.to_uri().to_string(),
))
}
}
Mention::Image(mention_image) => acp::ContentBlock::Image(
acp::ImageContent::new(
mention_image.data.clone(),
mention_image.format.mime_type(),
)
.uri(match uri {
MentionUri::File { .. } => Some(uri.to_uri().to_string()),
MentionUri::PastedImage { .. } => {
Some(uri.to_uri().to_string())
}
other => {
debug_panic!(
"unexpected mention uri for image: {:?}",
other
);
None
}
}),
),
Mention::Link => acp::ContentBlock::ResourceLink(
acp::ResourceLink::new(uri.name(), uri.to_uri().to_string()),
),
};
chunks.push(chunk);
ix = crease_range.end.0;
}
if ix < text.len() {
let last_chunk = text[ix..].trim_end().to_owned();
if !last_chunk.is_empty() {
chunks.push(last_chunk.into());
}
}
});
anyhow::Ok((chunks, all_tracked_buffers))
})?;
Ok(result)
build_chunks_from_creases(
&text,
&crease_snapshot,
&buffer_snapshot,
supports_embedded_context,
|crease_id| {
contents
.remove(crease_id)
.map(|(uri, mention)| (uri, Some(mention)))
},
)
}))
})
}
/// Snapshots the editor's current draft into a list of `ContentBlock`s
/// without awaiting any pending mention resolution.
pub fn draft_content_blocks_snapshot(&self, cx: &App) -> Vec<acp::ContentBlock> {
let editor = self.editor.read(cx);
let crease_snapshot = editor.display_map.read(cx).crease_snapshot();
let buffer_snapshot = editor.buffer().read(cx).snapshot(cx);
let text = editor.text(cx);
let mention_set = self.mention_set.read(cx);
let supports_embedded_context =
self.session_capabilities.read().supports_embedded_context();
let (chunks, _tracked_buffers) = build_chunks_from_creases(
&text,
&crease_snapshot,
&buffer_snapshot,
supports_embedded_context,
|crease_id| mention_set.resolved_mention_for_crease(crease_id),
);
chunks
}
pub fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.editor.update(cx, |editor, cx| {
editor.clear(window, cx);
@ -1874,6 +1831,92 @@ impl Addon for MessageEditorAddon {
}
}
/// Walks the editor's creases in order, interleaving plain-text chunks from
/// `text` with mention blocks produced from `resolve`.
fn build_chunks_from_creases(
text: &str,
crease_snapshot: &CreaseSnapshot,
buffer_snapshot: &MultiBufferSnapshot,
supports_embedded_context: bool,
mut resolve: impl FnMut(&CreaseId) -> Option<(MentionUri, Option<Mention>)>,
) -> (Vec<acp::ContentBlock>, Vec<Entity<Buffer>>) {
let mut ix = text
.char_indices()
.find(|(_, c)| !c.is_whitespace())
.map_or(text.len(), |(i, _)| i);
let mut chunks = Vec::new();
let mut tracked_buffers = Vec::new();
for (crease_id, crease) in crease_snapshot.creases() {
let Some((uri, mention)) = resolve(&crease_id) else {
continue;
};
let crease_range = crease.range().to_offset(buffer_snapshot);
if crease_range.start.0 > ix {
chunks.push(text[ix..crease_range.start.0].into());
}
chunks.push(mention_to_content_block(
&uri,
mention.as_ref(),
supports_embedded_context,
&mut tracked_buffers,
));
ix = crease_range.end.0;
}
if ix < text.len() {
let last_chunk = text[ix..].trim_end().to_owned();
if !last_chunk.is_empty() {
chunks.push(last_chunk.into());
}
}
(chunks, tracked_buffers)
}
fn mention_to_content_block(
uri: &MentionUri,
mention: Option<&Mention>,
supports_embedded_context: bool,
tracked_buffers: &mut Vec<Entity<Buffer>>,
) -> acp::ContentBlock {
match mention {
Some(Mention::Text {
content,
tracked_buffers: mention_tracked_buffers,
}) => {
tracked_buffers.extend(mention_tracked_buffers.iter().cloned());
if supports_embedded_context {
acp::ContentBlock::Resource(acp::EmbeddedResource::new(
acp::EmbeddedResourceResource::TextResourceContents(
acp::TextResourceContents::new(content.clone(), uri.to_uri().to_string()),
),
))
} else {
acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
uri.name(),
uri.to_uri().to_string(),
))
}
}
Some(Mention::Image(mention_image)) => acp::ContentBlock::Image(
acp::ImageContent::new(mention_image.data.clone(), mention_image.format.mime_type())
.uri(match uri {
MentionUri::File { .. } | MentionUri::PastedImage { .. } => {
Some(uri.to_uri().to_string())
}
other => {
debug_panic!("unexpected mention uri for image: {:?}", other);
None
}
}),
),
_ => acp::ContentBlock::ResourceLink(acp::ResourceLink::new(
uri.name(),
uri.to_uri().to_string(),
)),
}
}
/// Parses markdown mention links in the format `[@name](uri)` from text.
/// Returns a vector of (range, MentionUri) pairs where range is the byte range in the text.
fn parse_mention_links(text: &str, path_style: PathStyle) -> Vec<(Range<usize>, MentionUri)> {
@ -4197,6 +4240,56 @@ mod tests {
assert_eq!(copied, None);
}
#[gpui::test]
async fn test_draft_content_blocks_snapshot_preserves_selection_mentions(
cx: &mut TestAppContext,
) {
init_test(cx);
let (fixture, mut cx) = setup_selection_mention_fixture(cx).await;
let blocks = fixture.message_editor.update(&mut cx, |editor, cx| {
editor
.session_capabilities
.write()
.set_prompt_capabilities(acp::PromptCapabilities::new().embedded_context(true));
editor.draft_content_blocks_snapshot(cx)
});
// Each selection mention must round-trip as a `Resource` block carrying
// its URI and content, not as a `Text` block containing the fold
// placeholder string.
let resource_uris: Vec<&str> =
blocks
.iter()
.filter_map(|block| match block {
acp::ContentBlock::Resource(acp::EmbeddedResource {
resource:
acp::EmbeddedResourceResource::TextResourceContents(
acp::TextResourceContents { uri, .. },
),
..
}) => Some(uri.as_str()),
_ => None,
})
.collect();
assert_eq!(
resource_uris.len(),
2,
"snapshot should emit one Resource block per selection mention; got {blocks:#?}"
);
assert!(resource_uris.contains(&fixture.first_uri.to_uri().to_string().as_str()));
for block in &blocks {
if let acp::ContentBlock::Text(text) = block {
assert!(
!text.text.split_whitespace().any(|word| word == "selection"),
"text block must not contain bare fold placeholder: {:?}",
text.text
);
}
}
}
#[gpui::test]
async fn test_paste_mention_link_with_completion_trigger_does_not_panic(
cx: &mut TestAppContext,

View file

@ -689,6 +689,10 @@ impl DisplayMap {
}
}
pub fn crease_snapshot(&self) -> CreaseSnapshot {
self.crease_map.snapshot()
}
#[instrument(skip_all)]
pub fn set_state(&mut self, other: &DisplaySnapshot, cx: &mut Context<Self>) {
self.fold(