From c5830a9378cc5fca8e810c7bec64b2a362f0fa40 Mon Sep 17 00:00:00 2001 From: Yifeng Xu <110726416+peakxy@users.noreply.github.com> Date: Sun, 19 Jul 2026 06:40:55 +0800 Subject: [PATCH] agent_ui: Show controls after every agent message (#61245) # Objective Fixes #61244 In the Agent Panel, after a multi-turn conversation, the action buttons ("Copy this Agent response", "Scroll to User Message", and "Scroll to top") were only shown after the last assistant response. This was inconvenient for users who wanted to copy or navigate from earlier responses. Additionally, the "Scroll to User Message" button always jumped to the most recent user message in the entire thread, even when clicked from a historical assistant response. This PR addresses both issues by rendering the controls after every non-blank assistant response, and by making "Scroll to User Message" jump to the user prompt associated with that specific response. ## Solution - Refactored `render_thread_controls` in `crates/agent_ui/src/conversation_view/thread_view.rs` to accept an explicit `entry_ix`, `copy_response_index`, `is_thread_bottom`, and `user_message_index`, making it reusable per assistant message. - Rendered the control bar after each non-blank `AssistantMessage` entry, not just the last entry. - Made button IDs unique by using tuple IDs like `("name", entry_ix)`, matching the existing style in this file and avoiding `format!` allocations. - Keyed the copy button ID by the control bar location (`entry_ix`) while still copying `response_index`'s content, so the bottom fallback bar doesn't duplicate IDs with the per-response bar. - Gated the generation/confirmation early return to `is_thread_bottom`, so historical controls remain accessible while the thread is generating or waiting for confirmation. - Gated turn stats and thread feedback buttons to the bottom of the thread so they only appear on the last response. - Replaced `scroll_to_most_recent_user_prompt` with a more general `scroll_to_user_message_index` that takes an optional target user message index. - For each per-response button, computed the associated user message index as the most recent user message before that assistant response. - For the bottom fallback (when the last entry is not an assistant message), passed `None` so the button still scrolls to the most recent user message. - Updated the tooltip from "Scroll to Most Recent User Message" to "Scroll to User Message". - Updated existing tests and added assertions for `scroll_to_user_message_index(Some(ix), cx)`. ## Testing - `cargo check -p agent_ui` - `./script/clippy -p agent_ui` - `cargo test -p agent_ui --lib` (399 passed) - Added assertions in `test_scroll_to_most_recent_user_prompt` to verify `scroll_to_user_message_index(Some(0), cx)` and `scroll_to_user_message_index(Some(2), cx)` scroll to the correct user message indices. - Manual testing recommended: open the Agent Panel, send multiple messages, and verify that each assistant response has the three buttons and that "Scroll to User Message" jumps to the corresponding user prompt. ## Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments (no unsafe blocks) - [x] The content adheres to Zed's UI standards ([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) and [icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md) guidelines) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable ## Showcase Before: Only the last assistant response in the Agent Panel showed the "Copy this Agent response", "Scroll to User Message", and "Scroll to top" buttons. Clicking "Scroll to User Message" always jumped to the most recent user message. After: Every non-blank assistant response displays these buttons. Clicking "Scroll to User Message" on a historical response scrolls to the user prompt that triggered that specific response. https://github.com/user-attachments/assets/46e02bcc-8bed-4cf3-a08a-9ef91a206d08 --- Release Notes: - Added "Copy this Agent response", "Scroll to User Message", and "Scroll to top" buttons to every agent response in the Agent Panel. - Fixed "Scroll to User Message" so it jumps to the user prompt associated with the current response, rather than the most recent user message. --- crates/agent_ui/src/conversation_view.rs | 14 +- .../src/conversation_view/thread_view.rs | 217 +++++++++++------- 2 files changed, 146 insertions(+), 85 deletions(-) diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index baae526764..507c96c902 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -6951,10 +6951,20 @@ pub(crate) mod tests { cx.run_until_parked(); active_thread(&conversation_view, cx).update(cx, |view, cx| { - view.scroll_to_most_recent_user_prompt(cx); + view.scroll_to_user_message_index(None, cx); let scroll_top = view.list_state.logical_scroll_top(); // Entries layout is: [User1, Assistant1, User2, Assistant2] assert_eq!(scroll_top.item_ix, 2); + + view.scroll_to_top(cx); + view.scroll_to_user_message_index(Some(0), cx); + let scroll_top = view.list_state.logical_scroll_top(); + assert_eq!(scroll_top.item_ix, 0); + + view.scroll_to_top(cx); + view.scroll_to_user_message_index(Some(2), cx); + let scroll_top = view.list_state.logical_scroll_top(); + assert_eq!(scroll_top.item_ix, 2); }); } @@ -6969,7 +6979,7 @@ pub(crate) mod tests { // With no entries, scrolling should be a no-op and must not panic. active_thread(&conversation_view, cx).update(cx, |view, cx| { - view.scroll_to_most_recent_user_prompt(cx); + view.scroll_to_user_message_index(None, cx); let scroll_top = view.list_state.logical_scroll_top(); assert_eq!(scroll_top.item_ix, 0); }); diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 290bebd591..35b7145a1b 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -6054,6 +6054,8 @@ impl ThreadView { .get(entry_ix.saturating_sub(1)) .is_none_or(|entry| !entry.is_indented()); + let mut assistant_message_is_blank = false; + let primary = match &entry { AgentThreadEntry::UserMessage(message) => { let Some(editor) = self @@ -6288,6 +6290,8 @@ impl ThreadView { )) .into_any(); + assistant_message_is_blank = is_blank; + if is_blank { Empty.into_any() } else { @@ -6438,17 +6442,55 @@ impl ThreadView { primary }; - let needs_confirmation = thread.read(cx).is_waiting_for_confirmation() - || self.has_pending_request_elicitation(cx); + let primary = if matches!(entry, AgentThreadEntry::AssistantMessage(_)) + && !assistant_message_is_blank + { + let user_message_index = thread + .read(cx) + .entries() + .iter() + .take(entry_ix) + .rposition(|entry| matches!(entry, AgentThreadEntry::UserMessage(_))); + + v_flex() + .w_full() + .child(primary) + .child(self.render_thread_controls( + &thread, + entry_ix, + Some(entry_ix), + entry_ix + 1 == total_entries, + user_message_index, + cx, + )) + .into_any_element() + } else { + primary + }; + + let is_assistant = matches!(entry, AgentThreadEntry::AssistantMessage(_)); let comments_editor = self.thread_feedback.comments_editor.clone(); let primary = if entry_ix + 1 == total_entries { + let last_assistant_index = thread + .read(cx) + .entries() + .iter() + .rposition(|entry| matches!(entry, AgentThreadEntry::AssistantMessage(_))); + v_flex() .w_full() .child(primary) - .when(!needs_confirmation, |this| { - this.child(self.render_thread_controls(&thread, cx)) + .when(!is_assistant, |this| { + this.child(self.render_thread_controls( + &thread, + entry_ix, + last_assistant_index, + true, + None, + cx, + )) }) .when_some(comments_editor, |this, editor| { this.child(Self::render_feedback_feedback_editor(editor, cx)) @@ -6621,22 +6663,22 @@ impl ThreadView { fn render_thread_controls( &self, thread: &Entity, + entry_ix: usize, + copy_response_index: Option, + is_thread_bottom: bool, + user_message_index: Option, cx: &Context, ) -> impl IntoElement { let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating); + let needs_confirmation = thread.read(cx).is_waiting_for_confirmation() + || self.has_pending_request_elicitation(cx); - if is_generating { + if is_thread_bottom && (is_generating || needs_confirmation) { return Empty.into_any_element(); } - let last_response_index = thread - .read(cx) - .entries() - .iter() - .rposition(|entry| matches!(entry, AgentThreadEntry::AssistantMessage(_))); - - let copy_response_button = last_response_index.map(|response_index| { - IconButton::new("copy_agent_response", IconName::Copy) + let copy_response_button = copy_response_index.map(|response_index| { + IconButton::new(("copy_agent_response", entry_ix), IconName::Copy) .icon_size(IconSize::Small) .icon_color(Color::Muted) .tooltip(Tooltip::text("Copy This Agent Response")) @@ -6649,16 +6691,18 @@ impl ThreadView { })) }); - let scroll_to_recent_user_prompt = - IconButton::new("scroll_to_recent_user_prompt", IconName::UserArrowUp) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .tooltip(Tooltip::text("Scroll to Most Recent User Message")) - .on_click(cx.listener(move |this, _, _, cx| { - this.scroll_to_most_recent_user_prompt(cx); - })); + let scroll_to_recent_user_prompt = IconButton::new( + ("scroll_to_recent_user_prompt", entry_ix), + IconName::UserArrowUp, + ) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .tooltip(Tooltip::text("Scroll to User Message")) + .on_click(cx.listener(move |this, _, _, cx| { + this.scroll_to_user_message_index(user_message_index, cx); + })); - let scroll_to_top = IconButton::new("scroll_to_top", IconName::ArrowUp) + let scroll_to_top = IconButton::new(("scroll_to_top", entry_ix), IconName::ArrowUp) .icon_size(IconSize::Small) .icon_color(Color::Muted) .tooltip(Tooltip::text("Scroll to Top")) @@ -6666,7 +6710,7 @@ impl ThreadView { this.scroll_to_top(cx); })); - let show_stats = AgentSettings::get_global(cx).show_turn_stats; + let show_stats = is_thread_bottom && AgentSettings::get_global(cx).show_turn_stats; let last_turn_clock = show_stats .then(|| { @@ -6695,61 +6739,63 @@ impl ThreadView { }) .flatten(); - let feedback_buttons = (self.is_subagent() && self.is_thread_feedback_enabled(cx)).then( - || { - let feedback = self.thread_feedback.feedback; - let tooltip_meta = - "Rating the thread sends all of your current conversation to the Zed team."; + let feedback_buttons = is_thread_bottom + .then(|| { + (self.is_subagent() && self.is_thread_feedback_enabled(cx)).then(|| { + let feedback = self.thread_feedback.feedback; + let tooltip_meta = + "Rating the thread sends all of your current conversation to the Zed team."; - h_flex() - .child( - IconButton::new("feedback-thumbs-up", IconName::ThumbsUp) - .icon_size(IconSize::Small) - .icon_color(match feedback { - Some(ThreadFeedback::Positive) => Color::Accent, - _ => Color::Muted, - }) - .tooltip(move |window, cx| match feedback { - Some(ThreadFeedback::Positive) => { - Tooltip::text("Thanks for your feedback!")(window, cx) - } - _ => Tooltip::with_meta( - "Helpful Response", - None, - tooltip_meta, - cx, - ), - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.handle_feedback_click(ThreadFeedback::Positive, window, cx); - })), - ) - .child( - IconButton::new("feedback-thumbs-down", IconName::ThumbsDown) - .icon_size(IconSize::Small) - .icon_color(match feedback { - Some(ThreadFeedback::Negative) => Color::Accent, - _ => Color::Muted, - }) - .tooltip(move |window, cx| match feedback { - Some(ThreadFeedback::Negative) => Tooltip::text( - "We appreciate your feedback and will use it to improve in the future.", - )( - window, cx - ), - _ => Tooltip::with_meta( - "Not Helpful Response", - None, - tooltip_meta, - cx, - ), - }) - .on_click(cx.listener(move |this, _, window, cx| { - this.handle_feedback_click(ThreadFeedback::Negative, window, cx); - })), - ) - }, - ); + h_flex() + .child( + IconButton::new("feedback-thumbs-up", IconName::ThumbsUp) + .icon_size(IconSize::Small) + .icon_color(match feedback { + Some(ThreadFeedback::Positive) => Color::Accent, + _ => Color::Muted, + }) + .tooltip(move |window, cx| match feedback { + Some(ThreadFeedback::Positive) => { + Tooltip::text("Thanks for your feedback!")(window, cx) + } + _ => Tooltip::with_meta( + "Helpful Response", + None, + tooltip_meta, + cx, + ), + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.handle_feedback_click(ThreadFeedback::Positive, window, cx); + })), + ) + .child( + IconButton::new("feedback-thumbs-down", IconName::ThumbsDown) + .icon_size(IconSize::Small) + .icon_color(match feedback { + Some(ThreadFeedback::Negative) => Color::Accent, + _ => Color::Muted, + }) + .tooltip(move |window, cx| match feedback { + Some(ThreadFeedback::Negative) => Tooltip::text( + "We appreciate your feedback and will use it to improve in the future.", + )( + window, cx + ), + _ => Tooltip::with_meta( + "Not Helpful Response", + None, + tooltip_meta, + cx, + ), + }) + .on_click(cx.listener(move |this, _, window, cx| { + this.handle_feedback_click(ThreadFeedback::Negative, window, cx); + })), + ) + }) + }) + .flatten(); h_flex() .w_full() @@ -6833,18 +6879,23 @@ impl ThreadView { .unwrap_or_default() } - pub(crate) fn scroll_to_most_recent_user_prompt(&mut self, cx: &mut Context) { + pub(crate) fn scroll_to_user_message_index( + &mut self, + user_message_index: Option, + cx: &mut Context, + ) { let entries = self.thread.read(cx).entries(); if entries.is_empty() { return; } - // Find the most recent user message and scroll it to the top of the viewport. + // Scroll to the provided user message, or fall back to the most recent one. // (Fallback: if no user message exists, scroll to the bottom.) - if let Some(ix) = entries - .iter() - .rposition(|entry| matches!(entry, AgentThreadEntry::UserMessage(_))) - { + if let Some(ix) = user_message_index.or_else(|| { + entries + .iter() + .rposition(|entry| matches!(entry, AgentThreadEntry::UserMessage(_))) + }) { self.list_state.scroll_to(ListOffset { item_ix: ix, offset_in_item: px(0.0),