acp_thread: Preserve ordered mixed content in agent messages (#64456)
Preserve ordered mixed content in agent messages without disrupting text streaming for existing native/v1 agents. The previous single-block reduction could replace images with placeholders or lose non-text content as subsequent chunks arrived. - Introduce [ordered message display content](https://github.com/zed-industries/zed/blob/929fba3e16f7a378eb1dca3d85ac2f1df2b902c6/crates/acp_thread/src/acp_thread.rs#L1500), reusing adjacent text entities while retaining image/resource-preview boundaries. - Update rendering, text search, response copy/export, Mermaid cache invalidation, and CLI output to visit the collection. - Use block-local context menus and existing Markdown getters so copy commands target the clicked part rather than another part's cached state. - Keep existing link/mention formatting, resource-button and blob-label search behavior, compact prompt mentions, and original user-message chunks. - Preserve the separator between a leading resource link and subsequent direct or streamed text. Release Notes: - N/A
This commit is contained in:
1 parent
85b8198e86
commit
df673c1b85
5 files changed
+996
-307
No files matched your search
+524
-214
File diff suppressed because it is too large.
Load diff
@@ -5680,6 +5680,31 @@ pub(crate) mod tests {
|
||||
setup_conversation_view_with_initial_content_opt(agent, None, cx).await
|
||||
}
|
||||
|
||||
fn png_image() -> acp::ContentBlock {
|
||||
acp::ContentBlock::Image(acp::ImageContent::new(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
|
||||
"image/png",
|
||||
))
|
||||
}
|
||||
|
||||
async fn setup_full_size_conversation_and_send<'a>(
|
||||
connection: StubAgentConnection,
|
||||
prompt: &str,
|
||||
cx: &'a mut TestAppContext,
|
||||
) -> (Entity<ConversationView>, &'a mut VisualTestContext) {
|
||||
let (conversation_view, cx) =
|
||||
setup_conversation_view(StubAgentServer::new(connection), cx).await;
|
||||
add_to_workspace_with_size(conversation_view.clone(), true, cx);
|
||||
let thread =
|
||||
active_thread(&conversation_view, cx).read_with(cx, |view, _| view.thread.clone());
|
||||
thread
|
||||
.update(cx, |thread, cx| thread.send_raw(prompt, cx))
|
||||
.await
|
||||
.expect("prompt should succeed");
|
||||
cx.run_until_parked();
|
||||
(conversation_view, cx)
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_completed_plan_snapshot_keeps_list_state_in_sync(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
@@ -5791,13 +5816,24 @@ pub(crate) mod tests {
|
||||
}
|
||||
|
||||
fn add_to_workspace(conversation_view: Entity<ConversationView>, cx: &mut VisualTestContext) {
|
||||
add_to_workspace_with_size(conversation_view, false, cx);
|
||||
}
|
||||
|
||||
fn add_to_workspace_with_size(
|
||||
conversation_view: Entity<ConversationView>,
|
||||
fill_space: bool,
|
||||
cx: &mut VisualTestContext,
|
||||
) {
|
||||
let workspace =
|
||||
conversation_view.read_with(cx, |thread_view, _cx| thread_view.workspace.clone());
|
||||
|
||||
workspace
|
||||
.update_in(cx, |workspace, window, cx| {
|
||||
workspace.add_item_to_active_pane(
|
||||
Box::new(cx.new(|_| ThreadViewItem(conversation_view.clone()))),
|
||||
Box::new(cx.new(|_| ThreadViewItem {
|
||||
conversation_view: conversation_view.clone(),
|
||||
fill_space,
|
||||
})),
|
||||
None,
|
||||
true,
|
||||
window,
|
||||
@@ -5807,7 +5843,10 @@ pub(crate) mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
struct ThreadViewItem(Entity<ConversationView>);
|
||||
struct ThreadViewItem {
|
||||
conversation_view: Entity<ConversationView>,
|
||||
fill_space: bool,
|
||||
}
|
||||
|
||||
impl Item for ThreadViewItem {
|
||||
type Event = ();
|
||||
@@ -5825,7 +5864,7 @@ pub(crate) mod tests {
|
||||
|
||||
impl Focusable for ThreadViewItem {
|
||||
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
||||
self.0.read(cx).focus_handle(cx)
|
||||
self.conversation_view.read(cx).focus_handle(cx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5834,12 +5873,15 @@ pub(crate) mod tests {
|
||||
// Render the title editor in the element tree too. In the real app
|
||||
// it is part of the agent panel
|
||||
let title_editor = self
|
||||
.0
|
||||
.conversation_view
|
||||
.read(cx)
|
||||
.active_thread()
|
||||
.map(|t| t.read(cx).title_editor.clone());
|
||||
|
||||
v_flex().children(title_editor).child(self.0.clone())
|
||||
v_flex()
|
||||
.when(self.fill_space, |this| this.size_full())
|
||||
.children(title_editor)
|
||||
.child(self.conversation_view.clone())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7265,6 +7307,17 @@ pub(crate) mod tests {
|
||||
acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new(
|
||||
"Hidden papaya reasoning.".into(),
|
||||
)),
|
||||
acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new(
|
||||
acp::ContentBlock::Resource(acp::EmbeddedResource::new(
|
||||
acp::EmbeddedResourceResource::TextResourceContents(
|
||||
acp::TextResourceContents::new(
|
||||
"A second hidden papaya.",
|
||||
"thought://details",
|
||||
)
|
||||
.mime_type("text/markdown".to_string()),
|
||||
),
|
||||
)),
|
||||
)),
|
||||
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(
|
||||
"Final answer without that fruit.".into(),
|
||||
)),
|
||||
@@ -7331,11 +7384,297 @@ pub(crate) mod tests {
|
||||
|
||||
assert_eq!(
|
||||
bar.read_with(cx, |bar, _| bar.match_count()),
|
||||
1,
|
||||
"expanded thinking content should be searchable",
|
||||
2,
|
||||
"every Markdown leaf in expanded thinking content should be searchable",
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_empty_assistant_text_followed_by_image_is_rendered(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let connection = StubAgentConnection::new();
|
||||
connection.set_next_prompt_updates(vec![
|
||||
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("".into())),
|
||||
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(png_image())),
|
||||
]);
|
||||
let (_conversation_view, cx) =
|
||||
setup_full_size_conversation_and_send(connection, "Show an image", cx).await;
|
||||
|
||||
let image_bounds = cx
|
||||
.debug_bounds("agent-output-image")
|
||||
.expect("image-only output must not be hidden as a blank assistant message");
|
||||
assert!(
|
||||
image_bounds.size.width > px(0.) && image_bounds.size.height > px(0.),
|
||||
"decoded image must occupy nonzero rendered bounds, got {image_bounds:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_mixed_assistant_content_search_and_copy_preserve_order(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let image = png_image();
|
||||
let resource = acp::ContentBlock::Resource(acp::EmbeddedResource::new(
|
||||
acp::EmbeddedResourceResource::TextResourceContents(
|
||||
acp::TextResourceContents::new(
|
||||
"Resource needle follows the image.",
|
||||
"memory://ordered",
|
||||
)
|
||||
.mime_type("text/markdown".to_string()),
|
||||
),
|
||||
));
|
||||
let connection = StubAgentConnection::new();
|
||||
connection.set_next_prompt_updates(vec![
|
||||
acp::SessionUpdate::AgentThoughtChunk(acp::ContentChunk::new(
|
||||
"Private needle must not be copied.".into(),
|
||||
)),
|
||||
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("Before needle.".into())),
|
||||
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(image.clone())),
|
||||
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("After needle.".into())),
|
||||
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(image)),
|
||||
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(resource)),
|
||||
]);
|
||||
|
||||
let (conversation_view, cx) =
|
||||
setup_full_size_conversation_and_send(connection, "Show mixed content", cx).await;
|
||||
let thread_view = active_thread(&conversation_view, cx);
|
||||
let thread = thread_view.read_with(cx, |view, _| view.thread.clone());
|
||||
let first_image_bounds = cx
|
||||
.debug_bounds("message-content-1-1-1")
|
||||
.expect("first mixed-content image should be rendered");
|
||||
let second_image_bounds = cx
|
||||
.debug_bounds("message-content-1-1-3")
|
||||
.expect("second mixed-content image should be rendered");
|
||||
for image_bounds in [first_image_bounds, second_image_bounds] {
|
||||
assert!(
|
||||
image_bounds.size.width > px(0.) && image_bounds.size.height > px(0.),
|
||||
"each decoded image must occupy nonzero rendered bounds, got {image_bounds:?}",
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
first_image_bounds.origin.y < second_image_bounds.origin.y,
|
||||
"images must remain visible in content order: first {first_image_bounds:?}, second {second_image_bounds:?}",
|
||||
);
|
||||
|
||||
let markdowns = thread.read_with(cx, |thread, _| {
|
||||
let message = thread
|
||||
.entries()
|
||||
.iter()
|
||||
.find_map(|entry| match entry {
|
||||
AgentThreadEntry::AssistantMessage(message) => Some(message),
|
||||
_ => None,
|
||||
})
|
||||
.expect("assistant message should exist");
|
||||
message
|
||||
.chunks
|
||||
.iter()
|
||||
.flat_map(|chunk| match chunk {
|
||||
AssistantMessageChunk::Message { block, .. }
|
||||
| AssistantMessageChunk::Thought { block, .. } => block.markdowns(),
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
let first_text_bounds = cx
|
||||
.debug_bounds("message-content-1-1-0")
|
||||
.expect("first text content");
|
||||
cx.simulate_mouse_down(
|
||||
first_text_bounds.center(),
|
||||
gpui::MouseButton::Right,
|
||||
gpui::Modifiers::default(),
|
||||
);
|
||||
cx.simulate_mouse_up(
|
||||
first_text_bounds.center(),
|
||||
gpui::MouseButton::Right,
|
||||
gpui::Modifiers::default(),
|
||||
);
|
||||
let copy_response = cx
|
||||
.debug_bounds("MENU_ITEM-Copy This Agent Response")
|
||||
.expect("Copy This Agent Response");
|
||||
cx.simulate_click(copy_response.center(), gpui::Modifiers::default());
|
||||
let copied = cx
|
||||
.update(|_, cx| cx.read_from_clipboard().and_then(|item| item.text()))
|
||||
.expect("assistant response should be copied");
|
||||
assert_eq!(
|
||||
copied,
|
||||
"Before needle.\n\n`Image`\n\nAfter needle.\n\n`Image`\n\nResource needle follows the image."
|
||||
);
|
||||
assert!(!copied.contains("Private needle"));
|
||||
|
||||
thread_view.update_in(cx, |view, window, cx| {
|
||||
view.toggle_search(&crate::ToggleSearch, window, cx);
|
||||
});
|
||||
let search_bar = thread_view
|
||||
.read_with(cx, |view, _| view.thread_search_bar.clone())
|
||||
.expect("thread search should be open");
|
||||
search_bar.update_in(cx, |search_bar, window, cx| {
|
||||
search_bar.query_editor.update(cx, |editor, cx| {
|
||||
editor.set_text("needle", window, cx);
|
||||
});
|
||||
search_bar.update_matches(window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
|
||||
assert_eq!(search_bar.read_with(cx, |bar, _| bar.match_count()), 3);
|
||||
for (source, should_be_highlighted) in [
|
||||
("Private needle must not be copied.", false),
|
||||
("Before needle.", true),
|
||||
("After needle.", true),
|
||||
("Resource needle follows the image.", true),
|
||||
] {
|
||||
let markdown = markdowns
|
||||
.iter()
|
||||
.find(|markdown| markdown.read_with(cx, |markdown, _| markdown.source() == source))
|
||||
.unwrap_or_else(|| panic!("Markdown entity for {source:?} should exist"));
|
||||
assert_eq!(
|
||||
markdown.read_with(cx, |markdown, _| !markdown.search_highlights().is_empty()),
|
||||
should_be_highlighted,
|
||||
"unexpected search highlights for {source:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_message_context_menu_uses_clicked_content(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let acp::ContentBlock::Image(image) = png_image() else {
|
||||
panic!("expected image fixture");
|
||||
};
|
||||
let linked_image = format!(
|
||||
"[](https://two.example/)",
|
||||
image.mime_type, image.data,
|
||||
);
|
||||
let connection = StubAgentConnection::new();
|
||||
connection.set_next_prompt_updates(vec".into(),
|
||||
)),
|
||||
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(png_image())),
|
||||
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(linked_image.into())),
|
||||
]);
|
||||
let (conversation_view, cx) =
|
||||
setup_conversation_view(StubAgentServer::new(connection), cx).await;
|
||||
add_to_workspace_with_size(conversation_view.clone(), true, cx);
|
||||
let thread_view = active_thread(&conversation_view, cx);
|
||||
let thread = thread_view.read_with(cx, |view, _| view.thread.clone());
|
||||
thread
|
||||
.update(cx, |thread, cx| thread.send_raw("Show links", cx))
|
||||
.await
|
||||
.expect("link prompt");
|
||||
cx.run_until_parked();
|
||||
|
||||
for (selector, offset, expected) in [
|
||||
(
|
||||
"message-content-1-0-0",
|
||||
point(px(12.), px(8.)),
|
||||
"https://one.example/",
|
||||
),
|
||||
(
|
||||
"message-content-1-0-2",
|
||||
point(px(0.5), px(0.5)),
|
||||
"https://two.example/",
|
||||
),
|
||||
] {
|
||||
let bounds = cx.debug_bounds(selector).expect("content leaf");
|
||||
let position = bounds.origin + offset;
|
||||
cx.simulate_mouse_down(
|
||||
position,
|
||||
gpui::MouseButton::Right,
|
||||
gpui::Modifiers::default(),
|
||||
);
|
||||
cx.simulate_mouse_up(
|
||||
position,
|
||||
gpui::MouseButton::Right,
|
||||
gpui::Modifiers::default(),
|
||||
);
|
||||
let copy = cx.debug_bounds("MENU_ITEM-Copy Link").expect("Copy Link");
|
||||
cx.simulate_click(copy.center(), gpui::Modifiers::default());
|
||||
let copied = cx.update(|_, cx| cx.read_from_clipboard().and_then(|item| item.text()));
|
||||
assert_eq!(copied.as_deref(), Some(expected));
|
||||
}
|
||||
|
||||
let image = cx
|
||||
.debug_bounds("message-content-1-0-1")
|
||||
.expect("image leaf");
|
||||
cx.simulate_mouse_down(
|
||||
image.center(),
|
||||
gpui::MouseButton::Right,
|
||||
gpui::Modifiers::default(),
|
||||
);
|
||||
cx.simulate_mouse_up(
|
||||
image.center(),
|
||||
gpui::MouseButton::Right,
|
||||
gpui::Modifiers::default(),
|
||||
);
|
||||
assert!(cx.debug_bounds("MENU_ITEM-Copy Link").is_none());
|
||||
assert!(
|
||||
cx.debug_bounds("MENU_ITEM-Copy This Agent Response")
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_message_context_menu_copies_selected_content(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
let connection = StubAgentConnection::new();
|
||||
connection.set_next_prompt_updates(vec![
|
||||
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("**first**".into())),
|
||||
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(png_image())),
|
||||
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new("*second*".into())),
|
||||
]);
|
||||
let (conversation_view, cx) =
|
||||
setup_conversation_view(StubAgentServer::new(connection), cx).await;
|
||||
add_to_workspace_with_size(conversation_view.clone(), true, cx);
|
||||
let thread_view = active_thread(&conversation_view, cx);
|
||||
let thread = thread_view.read_with(cx, |view, _| view.thread.clone());
|
||||
thread
|
||||
.update(cx, |thread, cx| thread.send_raw("Show text", cx))
|
||||
.await
|
||||
.expect("text prompt");
|
||||
cx.run_until_parked();
|
||||
|
||||
for (selector, plain, markdown) in [
|
||||
("message-content-1-0-0", "first", "**first**"),
|
||||
("message-content-1-0-2", "second", "*second*"),
|
||||
] {
|
||||
for (menu_item, expected) in [
|
||||
("MENU_ITEM-Copy", plain),
|
||||
("MENU_ITEM-Copy as Markdown", markdown),
|
||||
] {
|
||||
let bounds = cx.debug_bounds(selector).expect("content leaf");
|
||||
let position = bounds.origin + point(px(12.), bounds.size.height / 2.);
|
||||
cx.simulate_event(gpui::MouseDownEvent {
|
||||
position,
|
||||
button: gpui::MouseButton::Left,
|
||||
modifiers: gpui::Modifiers::default(),
|
||||
click_count: 2,
|
||||
first_mouse: false,
|
||||
});
|
||||
cx.simulate_mouse_up(
|
||||
position,
|
||||
gpui::MouseButton::Left,
|
||||
gpui::Modifiers::default(),
|
||||
);
|
||||
cx.simulate_mouse_down(
|
||||
position,
|
||||
gpui::MouseButton::Right,
|
||||
gpui::Modifiers::default(),
|
||||
);
|
||||
cx.simulate_mouse_up(
|
||||
position,
|
||||
gpui::MouseButton::Right,
|
||||
gpui::Modifiers::default(),
|
||||
);
|
||||
let copy = cx.debug_bounds(menu_item).expect("selection copy command");
|
||||
cx.simulate_click(copy.center(), gpui::Modifiers::default());
|
||||
let copied =
|
||||
cx.update(|_, cx| cx.read_from_clipboard().and_then(|item| item.text()));
|
||||
assert_eq!(copied.as_deref(), Some(expected));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_thread_search_includes_expanded_tool_call_content(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
@@ -7397,6 +7736,57 @@ pub(crate) mod tests {
|
||||
1,
|
||||
"expanded tool-call content should be searchable",
|
||||
);
|
||||
|
||||
thread
|
||||
.update(cx, |thread, cx| {
|
||||
thread.handle_session_update(
|
||||
acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
|
||||
"search-tool-content",
|
||||
acp::ToolCallUpdateFields::new().content(vec![
|
||||
acp::ToolCallContent::Content(acp::Content::new(
|
||||
acp::ContentBlock::Text(acp::TextContent::new(
|
||||
"Replacement contains mango.",
|
||||
)),
|
||||
)),
|
||||
acp::ToolCallContent::Content(acp::Content::new(
|
||||
acp::ContentBlock::Resource(acp::EmbeddedResource::new(
|
||||
acp::EmbeddedResourceResource::TextResourceContents(
|
||||
acp::TextResourceContents::new(
|
||||
"Resource mango output.",
|
||||
"tool://replacement",
|
||||
)
|
||||
.mime_type("text/markdown".to_string()),
|
||||
),
|
||||
)),
|
||||
)),
|
||||
]),
|
||||
)),
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.expect("tool output replacement should apply");
|
||||
cx.run_until_parked();
|
||||
cx.executor()
|
||||
.advance_clock(super::thread_search_bar::SEARCH_UPDATE_DEBOUNCE * 2);
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
bar.read_with(cx, |bar, _| bar.match_count()),
|
||||
0,
|
||||
"replaced tool output should no longer match the old query",
|
||||
);
|
||||
|
||||
bar.update_in(cx, |bar, window, cx| {
|
||||
bar.query_editor.update(cx, |editor, cx| {
|
||||
editor.set_text("mango", window, cx);
|
||||
});
|
||||
bar.update_matches(window, cx);
|
||||
});
|
||||
cx.run_until_parked();
|
||||
assert_eq!(
|
||||
bar.read_with(cx, |bar, _| bar.match_count()),
|
||||
2,
|
||||
"all Markdown leaves in replacement tool output should be searchable",
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
@@ -7632,7 +8022,7 @@ pub(crate) mod tests {
|
||||
AgentThreadEntry::AssistantMessage(message) => {
|
||||
message.chunks.iter().find_map(|chunk| match chunk {
|
||||
AssistantMessageChunk::Message { block, .. } => {
|
||||
block.markdown().cloned()
|
||||
block.markdowns().next().cloned()
|
||||
}
|
||||
AssistantMessageChunk::Thought { .. } => None,
|
||||
})
|
||||
|
||||
@@ -891,18 +891,14 @@ fn collect_markdowns(
|
||||
for (chunk_ix, chunk) in message.chunks.iter().enumerate() {
|
||||
match chunk {
|
||||
AssistantMessageChunk::Message { block, .. } => {
|
||||
if let Some(md) = block.markdown() {
|
||||
out.push(md.clone());
|
||||
}
|
||||
out.extend(block.markdowns().cloned());
|
||||
}
|
||||
AssistantMessageChunk::Thought { block, .. }
|
||||
if entry_view_state
|
||||
.thinking_block_state((entry_ix, chunk_ix), cx)
|
||||
.0 =>
|
||||
{
|
||||
if let Some(md) = block.markdown() {
|
||||
out.push(md.clone());
|
||||
}
|
||||
out.extend(block.markdowns().cloned());
|
||||
}
|
||||
AssistantMessageChunk::Thought { .. } => {}
|
||||
}
|
||||
@@ -974,7 +970,6 @@ mod tests {
|
||||
)),
|
||||
markdown: unsupported.clone(),
|
||||
},
|
||||
ContentBlock::Empty,
|
||||
],
|
||||
error: Some(error.clone()),
|
||||
};
|
||||
|
||||
@@ -6353,43 +6353,31 @@ impl ThreadView {
|
||||
let mut is_blank = true;
|
||||
let is_last = entry_ix + 1 == total_entries;
|
||||
|
||||
let style = MarkdownStyle::themed(MarkdownFont::Agent, window, cx);
|
||||
let message_body = v_flex()
|
||||
.w_full()
|
||||
.gap_3()
|
||||
.children(chunks.iter().enumerate().filter_map(
|
||||
|(chunk_ix, chunk)| match chunk {
|
||||
AssistantMessageChunk::Message { block, .. } => {
|
||||
block.markdown().and_then(|md| {
|
||||
let this_is_blank = md.read(cx).source().trim().is_empty();
|
||||
is_blank = is_blank && this_is_blank;
|
||||
if this_is_blank {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
self.render_markdown(md.clone(), style.clone(), cx)
|
||||
.into_any_element(),
|
||||
)
|
||||
let this_is_blank = !block.visible_content(cx);
|
||||
is_blank = is_blank && this_is_blank;
|
||||
(!this_is_blank).then(|| {
|
||||
div()
|
||||
.id(("assistant-message-chunk", chunk_ix))
|
||||
.child(self.render_message_content(
|
||||
entry_ix, chunk_ix, block, window, cx,
|
||||
))
|
||||
.into_any_element()
|
||||
})
|
||||
}
|
||||
AssistantMessageChunk::Thought { block, .. } => {
|
||||
block.markdown().and_then(|md| {
|
||||
let this_is_blank = md.read(cx).source().trim().is_empty();
|
||||
is_blank = is_blank && this_is_blank;
|
||||
if this_is_blank {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
self.render_thinking_block(
|
||||
entry_ix,
|
||||
chunk_ix,
|
||||
md.clone(),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
.into_any_element(),
|
||||
let this_is_blank = !block.visible_content(cx);
|
||||
is_blank = is_blank && this_is_blank;
|
||||
(!this_is_blank).then(|| {
|
||||
self.render_thinking_block(
|
||||
entry_ix, chunk_ix, block, window, cx,
|
||||
)
|
||||
.into_any_element()
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -6407,7 +6395,7 @@ impl ThreadView {
|
||||
.when(is_last, |this| this.pb_4())
|
||||
.w_full()
|
||||
.text_ui(cx)
|
||||
.child(self.render_message_context_menu(entry_ix, message_body, cx))
|
||||
.child(message_body)
|
||||
.when_some(
|
||||
self.entry_view_state
|
||||
.read(cx)
|
||||
@@ -7458,11 +7446,44 @@ impl ThreadView {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn render_message_content(
|
||||
&self,
|
||||
entry_ix: usize,
|
||||
chunk_ix: usize,
|
||||
content: &acp_thread::MessageContent,
|
||||
window: &Window,
|
||||
cx: &Context<Self>,
|
||||
) -> Div {
|
||||
v_flex().w_full().gap_3().children(
|
||||
content
|
||||
.blocks()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, block)| block.visible_content(cx))
|
||||
.map(|(block_ix, block)| {
|
||||
let content = self.render_output_content_block(
|
||||
entry_ix, block_ix, block, None, false, window, cx,
|
||||
);
|
||||
div()
|
||||
.id(("message-content-block", block_ix))
|
||||
.debug_selector(move || {
|
||||
format!("message-content-{entry_ix}-{chunk_ix}-{block_ix}")
|
||||
})
|
||||
.child(self.render_message_context_menu(
|
||||
entry_ix,
|
||||
block.markdown().cloned(),
|
||||
content,
|
||||
cx,
|
||||
))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_thinking_block(
|
||||
&self,
|
||||
entry_ix: usize,
|
||||
chunk_ix: usize,
|
||||
chunk: Entity<Markdown>,
|
||||
chunk: &acp_thread::MessageContent,
|
||||
window: &Window,
|
||||
cx: &Context<Self>,
|
||||
) -> AnyElement {
|
||||
@@ -7487,6 +7508,7 @@ impl ThreadView {
|
||||
let panel_bg = cx.theme().colors().panel_background;
|
||||
|
||||
v_flex()
|
||||
.id(("thinking-block", chunk_ix))
|
||||
.gap_1()
|
||||
.child(
|
||||
h_flex()
|
||||
@@ -7524,7 +7546,10 @@ impl ThreadView {
|
||||
)
|
||||
.on_click(cx.listener(move |this, _event: &ClickEvent, window, cx| {
|
||||
this.toggle_thinking_block_expansion(key, window, cx);
|
||||
})),
|
||||
}))
|
||||
.map(|header| {
|
||||
self.render_message_context_menu(entry_ix, None, header.into_any(), cx)
|
||||
}),
|
||||
)
|
||||
.when(is_open, |this| {
|
||||
this.child(
|
||||
@@ -7542,11 +7567,11 @@ impl ThreadView {
|
||||
this.track_scroll(&scroll_handle)
|
||||
})
|
||||
.overflow_hidden()
|
||||
.child(self.render_markdown(
|
||||
chunk,
|
||||
MarkdownStyle::themed(MarkdownFont::Agent, window, cx),
|
||||
cx,
|
||||
)),
|
||||
.child(
|
||||
self.render_message_content(
|
||||
entry_ix, chunk_ix, chunk, window, cx,
|
||||
),
|
||||
),
|
||||
)
|
||||
.when(is_constrained, |this| {
|
||||
this.child(
|
||||
@@ -7570,6 +7595,7 @@ impl ThreadView {
|
||||
fn render_message_context_menu(
|
||||
&self,
|
||||
entry_ix: usize,
|
||||
markdown: Option<Entity<Markdown>>,
|
||||
message_body: AnyElement,
|
||||
cx: &Context<Self>,
|
||||
) -> AnyElement {
|
||||
@@ -7582,51 +7608,18 @@ impl ThreadView {
|
||||
let focus = window.focused(cx);
|
||||
let entity = entity.clone();
|
||||
let workspace = workspace.clone();
|
||||
let markdown = markdown.clone();
|
||||
|
||||
ContextMenu::build(window, cx, move |menu, _, cx| {
|
||||
let this = entity.read(cx);
|
||||
let is_at_top = this.list_state.logical_scroll_top().item_ix == 0;
|
||||
|
||||
let chunks =
|
||||
this.thread.read(cx).entries().get(entry_ix).and_then(
|
||||
|entry| match &entry {
|
||||
AgentThreadEntry::AssistantMessage(msg) => Some(&msg.chunks),
|
||||
_ => None,
|
||||
},
|
||||
);
|
||||
|
||||
let context_menu_link = chunks.and_then(|chunks| {
|
||||
chunks.iter().find_map(|chunk| {
|
||||
let markdown = match chunk {
|
||||
AssistantMessageChunk::Message { block, .. } => block.markdown(),
|
||||
AssistantMessageChunk::Thought { block, .. } => block.markdown(),
|
||||
};
|
||||
markdown
|
||||
.and_then(|markdown| markdown.read(cx).context_menu_link().cloned())
|
||||
})
|
||||
});
|
||||
let selected_text = chunks.and_then(|chunks| {
|
||||
chunks.iter().find_map(|chunk| {
|
||||
let markdown = match chunk {
|
||||
AssistantMessageChunk::Message { block, .. } => block.markdown(),
|
||||
AssistantMessageChunk::Thought { block, .. } => block.markdown(),
|
||||
};
|
||||
markdown.and_then(|markdown| {
|
||||
markdown.read(cx).context_menu_selected_text().cloned()
|
||||
})
|
||||
})
|
||||
});
|
||||
let selected_markdown = chunks.and_then(|chunks| {
|
||||
chunks.iter().find_map(|chunk| {
|
||||
let markdown = match chunk {
|
||||
AssistantMessageChunk::Message { block, .. } => block.markdown(),
|
||||
AssistantMessageChunk::Thought { block, .. } => block.markdown(),
|
||||
};
|
||||
markdown.and_then(|markdown| {
|
||||
markdown.read(cx).context_menu_selected_markdown().cloned()
|
||||
})
|
||||
})
|
||||
});
|
||||
let markdown = markdown.as_ref().map(|markdown| markdown.read(cx));
|
||||
let context_menu_link =
|
||||
markdown.and_then(|markdown| markdown.context_menu_link().cloned());
|
||||
let selected_text = markdown
|
||||
.and_then(|markdown| markdown.context_menu_selected_text().cloned());
|
||||
let selected_markdown = markdown
|
||||
.and_then(|markdown| markdown.context_menu_selected_markdown().cloned());
|
||||
|
||||
let copy_this_agent_response =
|
||||
ContextMenuEntry::new("Copy This Agent Response").handler({
|
||||
@@ -7746,7 +7739,7 @@ impl ThreadView {
|
||||
if markdown.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(markdown.to_string())
|
||||
Some(markdown)
|
||||
}
|
||||
}
|
||||
AssistantMessageChunk::Thought { .. } => None,
|
||||
@@ -10571,6 +10564,7 @@ impl ThreadView {
|
||||
cx: &Context<Self>,
|
||||
) -> AnyElement {
|
||||
v_flex()
|
||||
.debug_selector(|| "agent-output-image".into())
|
||||
.gap_2()
|
||||
.map(|this| {
|
||||
if card_layout {
|
||||
|
||||
@@ -971,7 +971,7 @@ fn log_acp_thread_event(
|
||||
if let Some(acp_thread::AgentThreadEntry::AssistantMessage(message)) = entries.last() {
|
||||
for chunk in &message.chunks {
|
||||
if let acp_thread::AssistantMessageChunk::Message { id: _, block } = chunk {
|
||||
if let acp_thread::ContentBlock::Markdown { markdown } = block {
|
||||
for markdown in block.markdowns() {
|
||||
let text = markdown.read(cx).source().to_string();
|
||||
if !text.is_empty() {
|
||||
eprint!("{text}");
|
||||
|
||||
Reference in new issue
Block a user