From 986d9e7fc9db5a5049353a2cc3884e0c44815086 Mon Sep 17 00:00:00 2001
From: lucascouts <lucascs@proton.me>
Date: Tue, 15 Sep 2026 00:15:58 -0300
Subject: [PATCH] agent: float the thread-navigation controls over the
 conversation

The controls at the end of a turn -- copy this response, scroll to the user
message, scroll to the top -- are rendered by `render_thread_controls` as a row
*inside* a list item. That puts them in the scroll flow, so they leave the
viewport together with the turn they belong to. They are therefore out of
reach in exactly the situation that makes them worth having: a long thread,
scrolled away from both of its ends.

This moves all of them onto the conversation container as an absolutely
positioned overlay -- copy, previous user message, next user message, top,
bottom, in that order -- and makes the three that belong to a turn relative
to the scroll position instead of to the thread's ends.

The first version of this patch floated only the two navigation buttons and
left copy inline, on the argument that a control floating over the whole
conversation cannot say which response it means. It can, once "which" is read
off the scroll position, and leaving it inline had a visible cost: the inline
copy button of the last turn sat right behind the overlay, covered by it.

- Copy acts on the turn the overlay sits over: the entry under the viewport's
  bottom edge, found by walking `bounds_for_item` from the scroll top. Over a
  user message it takes the turn above, since that is the response just read
  and the reply below has not scrolled into view. It copies what the inline
  button copied -- `get_agent_message_content`, the agent's text for the whole
  turn -- and nothing of the user's message or the tool calls.

- Copy is disabled, not hidden, while that turn is still in progress:
  generating, waiting for a tool confirmation, or holding a pending
  elicitation. Copying then would hand over a partial response, and a button
  that appears and disappears with the agent's status reads as a layout
  glitch. An earlier turn stays copyable while the last one is generated.

- "Scroll to Previous User Message" used to call
  `scroll_to_user_message_index(None, ..)`, which always resolves to the MOST
  RECENT user message, so a second click went nowhere. It now searches back
  from the scroll top, so each click lands one message earlier -- every
  landing puts that message at the top, and the next search starts above it --
  and a manual scroll in between is honoured rather than fought. A user
  message already partly scrolled past counts as the one to return to. It is
  measured, not counted: no click counter to fall out of sync.

- It is disabled rather than hidden when there is nothing earlier to go to,
  which covers a thread with no user message at all. It is NOT disabled while
  generating: moving the viewport is harmless mid-turn and is when it is most
  wanted.

- "Scroll to Next User Message" is new, and is the other half of that walk:
  the first user message below the scroll top, measured the same way. The
  message at the scroll top is never the answer -- just landed on or partly
  scrolled past, it is the one being read. It lands with the same
  `scroll_to_user_message_index`, so a back-then-forward pair returns to
  exactly where it started.

- It is disabled when there is nothing later to go to AND when the list's
  end is already in view. The second case is the common one: at the end of a
  thread whose last turn is short, the last user message is still on screen,
  below the scroll top, and a scroll to it is clamped to where the viewport
  already is -- a live button that moves nothing. "In view" is read off the
  last item's `bounds_for_item`, not `ListState::is_scrolled_to_end`, which
  answers `None` while any item is unmeasured; this list measures lazily, so
  in a long thread that is nearly always. Net effect: the button lights up
  only once the conversation has been scrolled up, which is when "next" has
  a meaning.

- Its icon, `UserArrowDown`, is new: `user_arrow_up.svg` with the arrow
  reflected across its own vertical extent, the figure untouched, so the pair
  reads as one control and aligns on the same baseline.

- "Scroll to Bottom" is new, and is the existing `scroll_to_end`.

- `render_thread_controls` early-returns `Empty` while generating or while a
  confirmation is pending. That was right for a row carrying a copy button and
  a turn's stats, and wrong for controls whose job is to move the viewport, so
  the overlay does not inherit it.

- With the buttons gone, every remaining part of the inline row is optional
  and they can all be absent at once: turn stats off, feedback unavailable, or
  simply not the last turn. It now returns `Empty` instead of leaving an
  invisible band of padding where the buttons used to be. It also loses its
  `thread`, `entry_ix` and `copy_response_index` parameters with their last
  use, and the `rposition` search that fed the last one.

- What stays in that row -- the turn's token count and duration -- moves to
  its left end. Right-aligned, as it was beside the buttons, it sat exactly
  under the overlay once the thread was scrolled to its end, the one place
  it is read. The dot after the last stat goes too: it separated the stats
  from buttons that are no longer in the row.

This rides on `ThreadView` being re-rendered on every scroll: the list's
scroll-wheel handler ends in `cx.notify(current_view)`, and the view that
paints the list is this one, so the enabled state and the copy target are
recomputed from the current position without a scroll handler of their own.

The overlay clears the vertical scrollbar the same container draws (`right_4`)
and keeps the inline row's 0.4/1.0 opacity-on-hover, so it reads as the same
affordance relocated rather than a new one.

Not addressed here: in a thread reopened after a compaction, "Scroll to Top"
reaches the compaction summary, not the first prompt. That is the adapter's
history replay stopping at the compact boundary, not this list -- measured
2026-09-26, and it belongs in the adapter.

Pure Zed: nothing here needs the adapter, so like 0010, 0012 and 0014 it rides
the claude-agent-acp-plus flag BY CHOICE rather than by dependency.

(0.2.3_p20261009): the Copy gating is `enabled_copy_target`, tested: the last turn while
it is still written is disabled, an earlier turn is not.
---

diff --git a/assets/icons/user_arrow_down.svg b/assets/icons/user_arrow_down.svg
new file mode 100644
index 0000000..c47550f
--- /dev/null
+++ b/assets/icons/user_arrow_down.svg
@@ -0,0 +1,6 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.0295 9.44867V14.2214" stroke="#DCE0E5" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M14.4177 12.4129L12.0297 14.8008L9.64166 12.4129" stroke="#DCE0E5" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M1.66669 12.3688C1.66667 11.445 1.93321 10.5408 2.43433 9.76481C2.93545 8.98877 3.64985 8.37383 4.49181 7.99376C5.33376 7.61369 6.26751 7.48465 7.18099 7.62211C8.09447 7.75958 8.94888 8.15772 9.64169 8.76875" stroke="#DCE0E5" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+<path d="M7.61473 7.34036C7.25075 7.49113 6.86064 7.56873 6.46668 7.56873C5.67103 7.56873 4.90796 7.25266 4.34535 6.69005C3.78275 6.12744 3.46667 5.36438 3.46667 4.56873C3.46667 3.77308 3.78275 3.01001 4.34535 2.44741C4.90796 1.8848 5.67103 1.56873 6.46668 1.56873C6.86064 1.56873 7.25075 1.64632 7.61473 1.79709C7.9787 1.94785 8.30942 2.16883 8.58799 2.44741C8.86657 2.72598 9.08755 3.0567 9.23831 3.42068C9.38908 3.78465 9.46667 4.17476 9.46667 4.56873C9.46667 4.96269 9.38908 5.3528 9.23831 5.71678C9.08755 6.08075 8.86657 6.41147 8.58799 6.69005C8.30942 6.96862 7.9787 7.1896 7.61473 7.34036Z" stroke="#DCE0E5" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>
diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs
index f236db4..6397c78 100644
--- a/crates/agent_ui/src/conversation_view/thread_view.rs
+++ b/crates/agent_ui/src/conversation_view/thread_view.rs
@@ -843,16 +843,6 @@ pub struct TurnFields {
     pub reported_activity_generation: Option<u64>,
 }
 
-enum ThreadControlsPosition {
-    /// Below a finalized assistant message.
-    TurnEnd {
-        user_message_index: Option<usize>,
-        is_thread_bottom: bool,
-    },
-    /// Below the last entry when it isn't an assistant message.
-    ThreadBottom,
-}
-
 /// How a tool call is rendered relative to its surroundings.
 ///
 /// `Standalone` draws its own border/margin/location header. `Embedded` is
@@ -8039,27 +8029,10 @@ impl ThreadView {
             .unwrap_or(!is_generating);
 
         let primary = if is_turn_end && !assistant_message_is_blank {
-            let user_message_index = {
-                let thread = thread.read(cx);
-                thread
-                    .entries()
-                    .iter()
-                    .take(entry_ix)
-                    .rposition(|entry| thread.is_user_authored_scroll_target(entry))
-            };
-
             v_flex()
                 .w_full()
                 .child(primary)
-                .child(self.render_thread_controls(
-                    &thread,
-                    entry_ix,
-                    ThreadControlsPosition::TurnEnd {
-                        user_message_index,
-                        is_thread_bottom,
-                    },
-                    cx,
-                ))
+                .child(self.render_thread_controls(is_thread_bottom, cx))
                 .into_any_element()
         } else {
             primary
@@ -8074,12 +8047,7 @@ impl ThreadView {
                 .w_full()
                 .child(primary)
                 .when(!is_assistant, |this| {
-                    this.child(self.render_thread_controls(
-                        &thread,
-                        entry_ix,
-                        ThreadControlsPosition::ThreadBottom,
-                        cx,
-                    ))
+                    this.child(self.render_thread_controls(true, cx))
                 })
                 .when_some(comments_editor, |this, editor| {
                     this.child(Self::render_feedback_feedback_editor(editor, cx))
@@ -8288,68 +8256,13 @@ impl ThreadView {
 
     fn render_thread_controls(
         &self,
-        thread: &Entity<AcpThread>,
-        entry_ix: usize,
-        position: ThreadControlsPosition,
+        is_thread_bottom: bool,
         cx: &Context<Self>,
     ) -> impl IntoElement {
-        let (user_message_index, is_thread_bottom) = match position {
-            ThreadControlsPosition::TurnEnd {
-                user_message_index,
-                is_thread_bottom,
-            } => (user_message_index, is_thread_bottom),
-            ThreadControlsPosition::ThreadBottom => (None, true),
-        };
-
-        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_thread_bottom && (is_generating || needs_confirmation) {
+        if is_thread_bottom && self.is_last_turn_in_progress(cx) {
             return Empty.into_any_element();
         }
 
-        // Not `Some(entry_ix)` for turn ends: a turn that only has thoughts would copy nothing
-        // but still show the copied state. The search stops at `entry_ix` when it has text.
-        let copy_response_button =
-            Self::agent_message_copy_index(thread.read(cx).entries(), entry_ix, cx).map(
-                |response_index| {
-                    let thread = thread.clone();
-                    CopyButton::new(("copy_agent_response", entry_ix), "")
-                        .icon_size(IconSize::Small)
-                        .tooltip_label("Copy This Agent Response")
-                        .custom_on_click(move |_, cx| {
-                            let entries = thread.read(cx).entries();
-                            if let Some(text) =
-                                Self::get_agent_message_content(entries, response_index, cx)
-                            {
-                                cx.write_to_clipboard(ClipboardItem::new_string(text));
-                            }
-                        })
-                },
-            );
-
-        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 = is_thread_bottom.then(|| {
-            IconButton::new(("scroll_to_top", entry_ix), IconName::ArrowUp)
-                .icon_size(IconSize::Small)
-                .icon_color(Color::Muted)
-                .tooltip(Tooltip::text("Scroll to Top"))
-                .on_click(cx.listener(move |this, _, _, cx| {
-                    this.scroll_to_top(cx);
-                }))
-        });
-
         let show_stats = is_thread_bottom && AgentSettings::get_global(cx).show_turn_stats;
 
         let last_turn_clock = show_stats
@@ -8437,6 +8350,18 @@ impl ThreadView {
             })
             .flatten();
 
+        // With the navigation and copy buttons floated, every remaining part is
+        // optional and they can all be absent at once: turn stats off, feedback
+        // unavailable, or simply not the last turn. Rendering
+        // the row regardless leaves an invisible band of padding exactly where
+        // the buttons used to be.
+        if feedback_buttons.is_none()
+            && last_turn_clock.is_none()
+            && last_turn_tokens_label.is_none()
+        {
+            return Empty.into_any_element();
+        }
+
         let separator_dots = || {
             Label::new("•")
                 .size(LabelSize::Small)
@@ -8444,11 +8369,16 @@ impl ThreadView {
                 .alpha(0.5)
         };
 
+        let show_separator = last_turn_tokens_label.is_some() && last_turn_clock.is_some();
+
+        // The stats sit on the left: the floating controls occupy the bottom
+        // right corner of the conversation, which is exactly where a
+        // right-aligned row ends up once the thread is scrolled to its end.
         h_flex()
             .w_full()
             .py_1p5()
             .px_4()
-            .justify_end()
+            .justify_between()
             .opacity(0.4)
             .hover(|s| s.opacity(1.))
             .when(
@@ -8458,22 +8388,255 @@ impl ThreadView {
                         h_flex()
                             .px_1()
                             .gap_1()
-                            .when_some(last_turn_tokens_label, |this, label| {
-                                this.child(label).child(separator_dots())
-                            })
-                            .when_some(last_turn_clock, |this, label| {
-                                this.child(label).child(separator_dots())
-                            }),
+                            .when_some(last_turn_tokens_label, |this, label| this.child(label))
+                            .when(show_separator, |this| this.child(separator_dots()))
+                            .when_some(last_turn_clock, |this, label| this.child(label)),
                     )
                 },
             )
             .when_some(feedback_buttons, |this, buttons| this.child(buttons))
-            .when_some(copy_response_button, |this, button| this.child(button))
-            .child(scroll_to_recent_user_prompt)
-            .when_some(scroll_to_top, |this, button| this.child(button))
             .into_any_element()
     }
 
+    /// The thread's navigation controls, as a fixed overlay on the conversation
+    /// instead of a row inside the last entry.
+    ///
+    /// `render_thread_controls` renders *inside* a list item, so the moment the
+    /// turn it belongs to scrolls out of view its controls go with it --
+    /// precisely when these start being useful. Anchoring them on the
+    /// conversation container keeps them reachable from anywhere in the thread.
+    ///
+    /// Three of them are relative to the scroll position rather than to the
+    /// thread's ends, which is what lets a fixed control say which turn it
+    /// means: copy acts on the turn the overlay sits over, and the two
+    /// user-message buttons walk one message back or forward per click.
+    fn render_floating_thread_navigation(&self, cx: &Context<Self>) -> impl IntoElement {
+        let entries = self.thread.read(cx).entries();
+
+        let previous_user_message =
+            Self::previous_user_message_ix(entries, self.list_state.logical_scroll_top());
+
+        // With the list's end already in view, a scroll to any later message
+        // is clamped to where the viewport already is, so the click would
+        // move nothing.
+        let next_user_message = (!self.is_list_end_in_view())
+            .then(|| Self::next_user_message_ix(entries, self.list_state.logical_scroll_top()))
+            .flatten();
+
+        // A turn still being written is disabled rather than hidden: copying it
+        // would hand over a partial response, and a button that comes and goes
+        // with the agent's status reads as a layout glitch.
+        let copy_target = self.entry_ix_at_viewport_bottom().and_then(|ix| {
+            Self::enabled_copy_target(entries, ix, self.is_last_turn_in_progress(cx))
+        });
+
+        div()
+            .absolute()
+            // Clear of the vertical scrollbar the same container draws.
+            .right_4()
+            .bottom_2()
+            .child(
+                h_flex()
+                    .gap_0p5()
+                    .p_0p5()
+                    .rounded_md()
+                    .border_1()
+                    .border_color(cx.theme().colors().border)
+                    .bg(cx.theme().colors().elevated_surface_background)
+                    .opacity(0.4)
+                    .hover(|s| s.opacity(1.))
+                    .child(
+                        IconButton::new("floating_copy_agent_response", IconName::Copy)
+                            .icon_size(IconSize::Small)
+                            .icon_color(Color::Muted)
+                            .disabled(copy_target.is_none())
+                            .tooltip(Tooltip::text("Copy This Agent Response"))
+                            .on_click(cx.listener(move |this, _, _, cx| {
+                                let Some(target) = copy_target else {
+                                    return;
+                                };
+                                let entries = this.thread.read(cx).entries();
+                                if let Some(text) =
+                                    Self::get_agent_message_content(entries, target, cx)
+                                {
+                                    cx.write_to_clipboard(ClipboardItem::new_string(text));
+                                }
+                            })),
+                    )
+                    .child(
+                        IconButton::new(
+                            "floating_scroll_to_previous_user_message",
+                            IconName::UserArrowUp,
+                        )
+                        .icon_size(IconSize::Small)
+                        .icon_color(Color::Muted)
+                        .disabled(previous_user_message.is_none())
+                        .tooltip(Tooltip::text("Scroll to Previous User Message"))
+                        .on_click(cx.listener(move |this, _, _, cx| {
+                            if let Some(ix) = previous_user_message {
+                                this.scroll_to_user_message_index(Some(ix), cx);
+                            }
+                        })),
+                    )
+                    .child(
+                        IconButton::new(
+                            "floating_scroll_to_next_user_message",
+                            IconName::UserArrowDown,
+                        )
+                        .icon_size(IconSize::Small)
+                        .icon_color(Color::Muted)
+                        .disabled(next_user_message.is_none())
+                        .tooltip(Tooltip::text("Scroll to Next User Message"))
+                        .on_click(cx.listener(move |this, _, _, cx| {
+                            if let Some(ix) = next_user_message {
+                                this.scroll_to_user_message_index(Some(ix), cx);
+                            }
+                        })),
+                    )
+                    .child(
+                        IconButton::new("floating_scroll_to_top", IconName::ArrowUp)
+                            .icon_size(IconSize::Small)
+                            .icon_color(Color::Muted)
+                            .tooltip(Tooltip::text("Scroll to Top"))
+                            .on_click(cx.listener(|this, _, _, cx| {
+                                this.scroll_to_top(cx);
+                            })),
+                    )
+                    .child(
+                        IconButton::new("floating_scroll_to_bottom", IconName::ArrowDown)
+                            .icon_size(IconSize::Small)
+                            .icon_color(Color::Muted)
+                            .tooltip(Tooltip::text("Scroll to Bottom"))
+                            .on_click(cx.listener(|this, _, _, cx| {
+                                this.scroll_to_end(cx);
+                            })),
+                    ),
+            )
+    }
+
+    fn is_last_turn_in_progress(&self, cx: &App) -> bool {
+        let thread = self.thread.read(cx);
+        matches!(thread.status(), ThreadStatus::Generating)
+            || thread.is_waiting_for_confirmation()
+            || self.has_pending_request_elicitation(cx)
+    }
+
+    /// The user message the "previous" control should land on.
+    ///
+    /// It is measured from the scroll position, not from a click counter, so
+    /// repeated clicks walk back one message each -- every landing puts that
+    /// message at the top, and the next search starts above it -- and a manual
+    /// scroll in between is honoured instead of fought. A user message that is
+    /// already partly scrolled past counts as the one to return to.
+    fn previous_user_message_ix(
+        entries: &[AgentThreadEntry],
+        scroll_top: ListOffset,
+    ) -> Option<usize> {
+        let is_user_message =
+            |ix: usize| matches!(entries.get(ix), Some(AgentThreadEntry::UserMessage(_)));
+
+        if scroll_top.offset_in_item > px(0.) && is_user_message(scroll_top.item_ix) {
+            return Some(scroll_top.item_ix);
+        }
+        (0..scroll_top.item_ix.min(entries.len()))
+            .rev()
+            .find(|&ix| is_user_message(ix))
+    }
+
+    /// The user message the "next" control should land on: the first one
+    /// below the scroll top, measured the same way as the previous one.
+    ///
+    /// The entry at the scroll top is never the answer, whether it sits there
+    /// exactly (just landed on it) or is partly scrolled past: either way it is
+    /// the message being read, not the next one.
+    fn next_user_message_ix(entries: &[AgentThreadEntry], scroll_top: ListOffset) -> Option<usize> {
+        (scroll_top.item_ix + 1..entries.len())
+            .find(|&ix| matches!(entries.get(ix), Some(AgentThreadEntry::UserMessage(_))))
+    }
+
+    /// Whether the bottom of the list's last item is inside the viewport,
+    /// i.e. there is nothing left to scroll down to.
+    ///
+    /// Measured from the last item's bounds rather than with
+    /// `ListState::is_scrolled_to_end`, which answers `None` whenever any item
+    /// is still unmeasured -- the normal state of a long thread, since this list
+    /// measures lazily. An unrendered last item has no bounds and reads as "not
+    /// in view", which is the truth: it is below the viewport.
+    fn is_list_end_in_view(&self) -> bool {
+        let Some(last_ix) = self.list_state.item_count().checked_sub(1) else {
+            return true;
+        };
+        let viewport_bottom = self.list_state.viewport_bounds().bottom();
+        self.list_state
+            .bounds_for_item(last_ix)
+            .is_some_and(|bounds| bounds.bottom() <= viewport_bottom + px(1.))
+    }
+
+    /// The entry the floating overlay sits over, i.e. the last one whose top
+    /// is above the viewport's bottom edge.
+    fn entry_ix_at_viewport_bottom(&self) -> Option<usize> {
+        let item_count = self.list_state.item_count();
+        if item_count == 0 {
+            return None;
+        }
+        let viewport_bottom = self.list_state.viewport_bounds().bottom();
+        let mut ix = self
+            .list_state
+            .logical_scroll_top()
+            .item_ix
+            .min(item_count - 1);
+        while ix + 1 < item_count {
+            match self.list_state.bounds_for_item(ix) {
+                Some(bounds) if bounds.bottom() < viewport_bottom => ix += 1,
+                _ => break,
+            }
+        }
+        Some(ix)
+    }
+
+    /// The entry whose turn the copy control acts on, given the entry under
+    /// the overlay, or `None` when that turn has no agent response.
+    ///
+    /// Over a user message the overlay sits right below the end of the
+    /// previous turn, which is the response just read, so that turn is the
+    /// one copied rather than the reply that has not scrolled into view yet.
+    /// The turn Copy acts on from `ix`, or `None` when Copy is disabled: no response in
+    /// that turn, or it is the last turn and still being written -- copying it would hand
+    /// over a partial response. An earlier turn stays copyable while the last generates.
+    fn enabled_copy_target(
+        entries: &[AgentThreadEntry],
+        ix: usize,
+        last_turn_in_progress: bool,
+    ) -> Option<usize> {
+        let target = Self::turn_copy_target(entries, ix)?;
+        let is_last_turn = !entries
+            .iter()
+            .skip(target + 1)
+            .any(|entry| matches!(entry, AgentThreadEntry::UserMessage(_)));
+        (!is_last_turn || !last_turn_in_progress).then_some(target)
+    }
+
+    fn turn_copy_target(entries: &[AgentThreadEntry], ix: usize) -> Option<usize> {
+        let target = match entries.get(ix)? {
+            AgentThreadEntry::UserMessage(_) => ix.checked_sub(1)?,
+            _ => ix,
+        };
+        let is_user_message =
+            |ix: &usize| matches!(entries.get(*ix), Some(AgentThreadEntry::UserMessage(_)));
+        let start = (0..target)
+            .rev()
+            .find(is_user_message)
+            .map_or(0, |ix| ix + 1);
+        let end = (target..entries.len())
+            .find(is_user_message)
+            .unwrap_or(entries.len());
+        entries
+            .get(start..end)?
+            .iter()
+            .any(|entry| matches!(entry, AgentThreadEntry::AssistantMessage(_)))
+            .then_some(target)
+    }
+
     fn is_thread_feedback_enabled(&self, cx: &App) -> bool {
         util::maybe!({
             let project = self.thread.read(cx).project().read(cx);
@@ -9228,30 +9391,6 @@ impl ThreadView {
             .into_any_element()
     }
 
-    fn agent_message_copy_index(
-        entries: &[AgentThreadEntry],
-        entry_index: usize,
-        cx: &App,
-    ) -> Option<usize> {
-        entries
-            .get(..=entry_index)?
-            .iter()
-            .enumerate()
-            .rev()
-            .take_while(|(_, entry)| !matches!(entry, AgentThreadEntry::UserMessage(_)))
-            .find_map(|(index, entry)| match entry {
-                AgentThreadEntry::AssistantMessage(message) => message
-                    .chunks
-                    .iter()
-                    .any(|chunk| match chunk {
-                        AssistantMessageChunk::Message { block, .. } => block.visible_content(cx),
-                        AssistantMessageChunk::Thought { .. } => false,
-                    })
-                    .then_some(index),
-                _ => None,
-            })
-    }
-
     fn get_agent_message_content(
         entries: &[AgentThreadEntry],
         entry_index: usize,
@@ -14153,9 +14292,11 @@ impl Render for ThreadView {
             })
             .map(|this| {
                 if has_messages {
-                    this.flex_1()
+                    this.relative()
+                        .flex_1()
                         .size_full()
                         .child(self.render_entries(cx))
+                        .child(self.render_floating_thread_navigation(cx))
                         .vertical_scrollbar_for(&list_state, window, cx)
                         .into_any()
                 } else {
@@ -14885,9 +15026,7 @@ fn strip_leading_command(text: &str, command_name: &str) -> String {
 #[cfg(test)]
 mod tests {
     use super::*;
-    use acp_thread::MessageContent;
     use gpui::UpdateGlobal;
-    use language::LanguageRegistry;
     use project::{FakeFs, Project};
     use serde_json::json;
     use settings::{SettingsStore, SplicingVec};
@@ -14952,104 +15091,6 @@ mod tests {
         }
     }
 
-    #[gpui::test]
-    fn test_agent_message_copy_index(cx: &mut gpui::TestAppContext) {
-        crate::test_support::init_test(cx);
-
-        cx.update(|cx| {
-            let language_registry =
-                Arc::new(LanguageRegistry::test(cx.background_executor().clone()));
-            let mut assistant_message = |chunks: &[(bool, &str)]| {
-                AgentThreadEntry::AssistantMessage(AssistantMessage {
-                    chunks: chunks
-                        .iter()
-                        .map(|&(is_thought, text)| {
-                            let block = MessageContent::new(
-                                acp_v2::ContentBlock::Text(acp_v2::TextContent::new(text)),
-                                &language_registry,
-                                PathStyle::local(),
-                                cx,
-                            );
-                            let identity = acp_thread::MessageIdentity::Legacy(None);
-                            if is_thought {
-                                AssistantMessageChunk::Thought {
-                                    identity,
-                                    meta: None,
-                                    block,
-                                }
-                            } else {
-                                AssistantMessageChunk::Message {
-                                    identity,
-                                    meta: None,
-                                    block,
-                                }
-                            }
-                        })
-                        .collect(),
-                    indented: false,
-                    is_subagent_output: false,
-                })
-            };
-            let thought = assistant_message(&[(true, "private thought")]);
-            let blank = assistant_message(&[(false, " \n\t")]);
-            let first = assistant_message(&[(true, "private thought"), (false, "first part")]);
-            let last = assistant_message(&[(false, "last part")]);
-            let trailing_thought = assistant_message(&[(true, "another private thought")]);
-            let next_turn_thought = assistant_message(&[(true, "next turn private thought")]);
-            let next_turn_last = assistant_message(&[(false, "next turn response")]);
-            let user = AgentThreadEntry::UserMessage(acp_thread::UserMessage {
-                identity: acp_thread::MessageIdentity::Legacy(None),
-                meta: None,
-                client_id: None,
-                is_optimistic: false,
-                content: MessageContent::new(
-                    acp_v2::ContentBlock::Text(acp_v2::TextContent::new("next prompt")),
-                    &language_registry,
-                    PathStyle::local(),
-                    cx,
-                ),
-                indented: false,
-            });
-            let non_message = AgentThreadEntry::Elicitation(ElicitationEntryId("request".into()));
-
-            let entries = vec![
-                thought,
-                blank,
-                first,
-                non_message,
-                last,
-                trailing_thought,
-                user,
-                next_turn_thought,
-                AgentThreadEntry::Elicitation(ElicitationEntryId("next request".into())),
-                next_turn_last,
-            ];
-
-            for (entries, entry_index, expected_index, expected_text) in [
-                (&entries[..0], 0, None, None),
-                (&entries[..1], 0, None, None),
-                (&entries[1..2], 0, None, None),
-                (&entries[..3], 2, Some(2), Some("first part")),
-                (&entries[..3], 3, None, None),
-                (&entries[..4], 3, Some(2), Some("first part")),
-                (&entries[..6], 5, Some(4), Some("first part\n\nlast part")),
-                (&entries[..7], 6, None, None),
-                (&entries[..9], 8, None, None),
-                (&entries[..], 9, Some(9), Some("next turn response")),
-                (&entries[..], 5, Some(4), Some("first part\n\nlast part")),
-            ] {
-                let copy_index = ThreadView::agent_message_copy_index(entries, entry_index, cx);
-                assert_eq!(copy_index, expected_index);
-                assert_eq!(
-                    copy_index
-                        .and_then(|index| ThreadView::get_agent_message_content(entries, index, cx))
-                        .as_deref(),
-                    expected_text,
-                );
-            }
-        });
-    }
-
     fn native_command(name: &str) -> acp_v2::AvailableCommand {
         acp_v2::AvailableCommand::Command(acp_v2::Command::new(name, "").meta(
             acp_thread::meta_with_command_category(acp_thread::CommandCategory::Native),
@@ -15103,6 +15144,104 @@ mod tests {
         }
     }
 
+    #[gpui::test]
+    fn previous_user_message_walks_back_one_message_per_click(cx: &mut gpui::TestAppContext) {
+        cx.update(|cx| {
+            // [User 0, Agent 1, User 2, Agent 3]
+            let entries = conversation_entries(cx);
+            let at = |item_ix: usize, offset: f32| ListOffset {
+                item_ix,
+                offset_in_item: px(offset),
+            };
+
+            // Reading the last response: the first click goes to the last message.
+            assert_eq!(
+                ThreadView::previous_user_message_ix(&entries, at(3, 40.)),
+                Some(2)
+            );
+            // Landed on it: the next click goes one further back, not to it again.
+            assert_eq!(
+                ThreadView::previous_user_message_ix(&entries, at(2, 0.)),
+                Some(0)
+            );
+            // Partly scrolled past a user message: return to that one first.
+            assert_eq!(
+                ThreadView::previous_user_message_ix(&entries, at(2, 10.)),
+                Some(2)
+            );
+            // Already on the first message: nothing earlier, so the button disables.
+            assert_eq!(
+                ThreadView::previous_user_message_ix(&entries, at(0, 0.)),
+                None
+            );
+            assert_eq!(ThreadView::previous_user_message_ix(&[], at(0, 0.)), None);
+        });
+    }
+
+    #[gpui::test]
+    fn next_user_message_walks_forward_one_message_per_click(cx: &mut gpui::TestAppContext) {
+        cx.update(|cx| {
+            // [User 0, Agent 1, User 2, Agent 3]
+            let entries = conversation_entries(cx);
+            let at = |item_ix: usize, offset: f32| ListOffset {
+                item_ix,
+                offset_in_item: px(offset),
+            };
+
+            // Landed on the first message: the next click goes to the second.
+            assert_eq!(
+                ThreadView::next_user_message_ix(&entries, at(0, 0.)),
+                Some(2)
+            );
+            // Reading the first response: the next message is below it.
+            assert_eq!(
+                ThreadView::next_user_message_ix(&entries, at(1, 40.)),
+                Some(2)
+            );
+            // Partly scrolled past a user message: that one is being read, so
+            // it is skipped rather than returned to.
+            assert_eq!(ThreadView::next_user_message_ix(&entries, at(2, 10.)), None);
+            // Landed on the last message: nothing later, so the button disables.
+            assert_eq!(ThreadView::next_user_message_ix(&entries, at(2, 0.)), None);
+            assert_eq!(ThreadView::next_user_message_ix(&entries, at(3, 40.)), None);
+            assert_eq!(ThreadView::next_user_message_ix(&[], at(0, 0.)), None);
+        });
+    }
+
+    #[gpui::test]
+    fn copy_targets_the_turn_under_the_overlay(cx: &mut gpui::TestAppContext) {
+        cx.update(|cx| {
+            // [User 0, Agent 1, User 2, Agent 3]
+            let entries = conversation_entries(cx);
+
+            assert_eq!(ThreadView::turn_copy_target(&entries, 3), Some(3));
+            assert_eq!(ThreadView::turn_copy_target(&entries, 1), Some(1));
+            // Over a user message, the response just above it is the one read.
+            assert_eq!(ThreadView::turn_copy_target(&entries, 2), Some(1));
+            // Nothing above the first message, so there is no response to copy.
+            assert_eq!(ThreadView::turn_copy_target(&entries, 0), None);
+            assert_eq!(ThreadView::turn_copy_target(&entries, 4), None);
+
+            // An unanswered prompt at the bottom copies the response above it;
+            // a thread with no response at all has nothing to copy.
+            let unanswered = &entries[..3];
+            assert_eq!(ThreadView::turn_copy_target(unanswered, 2), Some(1));
+            assert_eq!(ThreadView::turn_copy_target(&entries[..1], 0), None);
+
+            // 0026: while the last turn is still being written, its Copy is disabled --
+            // and an earlier turn's is not.
+            assert_eq!(ThreadView::enabled_copy_target(&entries, 3, true), None);
+            assert_eq!(ThreadView::enabled_copy_target(&entries, 3, false), Some(3));
+            assert_eq!(ThreadView::enabled_copy_target(&entries, 1, true), Some(1));
+            assert_eq!(ThreadView::enabled_copy_target(&entries, 2, true), Some(1));
+
+            let copied = ThreadView::get_agent_message_content(&entries, 1, cx)
+                .expect("the targeted turn has a response");
+            assert!(copied.contains("Result<Args"), "{copied:?}");
+            assert!(!copied.contains("Documented"), "{copied:?}");
+        });
+    }
+
     #[gpui::test]
     fn copy_yields_the_users_own_message(cx: &mut gpui::TestAppContext) {
         cx.update(|cx| {
diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs
index 9ac47bb..1a3fab0 100644
--- a/crates/icons/src/icons.rs
+++ b/crates/icons/src/icons.rs
@@ -289,6 +289,7 @@ pub enum IconName {
     TriangleRight,
     Undo,
     Unpin,
+    UserArrowDown,
     UserArrowUp,
     UserCheck,
     UserGroup,
