mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
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:
parent
e6b8b30e22
commit
9a125a553d
4 changed files with 205 additions and 103 deletions
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue