agent_ui: Don't reset cursor position when editing queued messages (#52210)

Danilo Leal created

## Context

When typing on the read-only queued message editor, we push the content
to the main, editable message editor. However, the cursor position was
being reset when that happened. This PR fixes that by tracking the
cursor offset when doing that, also accounting for the case where there
could be pre-existing content in the message editor.

## How to Review

- Queue a message in the agent panel
- Place your cursor somewhere in the middle of your queued message and
type something
- See how the cursor position is preserved once it goes down to the main
message editor

## Self-Review Checklist

<!-- Check before requesting review: -->
- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- Agent: Fix cursor position being reset when editing queued messages.

Change summary

crates/agent_ui/src/conversation_view.rs             | 25 +++++++++--
crates/agent_ui/src/conversation_view/thread_view.rs | 24 ++++++++--
crates/agent_ui/src/message_editor.rs                | 30 +++++++++++++
3 files changed, 66 insertions(+), 13 deletions(-)

Detailed changes

crates/agent_ui/src/conversation_view.rs 🔗

@@ -1173,12 +1173,19 @@ impl ConversationView {
         &mut self,
         index: usize,
         inserted_text: Option<&str>,
+        cursor_offset: Option<usize>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
         if let Some(active) = self.active_thread() {
             active.update(cx, |active, cx| {
-                active.move_queued_message_to_main_editor(index, inserted_text, window, cx);
+                active.move_queued_message_to_main_editor(
+                    index,
+                    inserted_text,
+                    cursor_offset,
+                    window,
+                    cx,
+                );
             });
         }
     }
@@ -2192,8 +2199,16 @@ impl ConversationView {
                 &editor,
                 window,
                 move |this, _editor, event, window, cx| match event {
-                    MessageEditorEvent::InputAttempted(text) => this
-                        .move_queued_message_to_main_editor(index, Some(text.as_ref()), window, cx),
+                    MessageEditorEvent::InputAttempted {
+                        text,
+                        cursor_offset,
+                    } => this.move_queued_message_to_main_editor(
+                        index,
+                        Some(text.as_ref()),
+                        Some(*cursor_offset),
+                        window,
+                        cx,
+                    ),
                     MessageEditorEvent::LostFocus => {
                         this.save_queued_message_at_index(index, cx);
                     }
@@ -6480,7 +6495,7 @@ pub(crate) mod tests {
             // Main editor must be empty for this path — it is by default, but
             // assert to make the precondition explicit.
             assert!(thread.message_editor.read(cx).is_empty(cx));
-            thread.move_queued_message_to_main_editor(0, None, window, cx);
+            thread.move_queued_message_to_main_editor(0, None, None, window, cx);
         });
 
         cx.run_until_parked();
@@ -6525,7 +6540,7 @@ pub(crate) mod tests {
                 vec![],
                 cx,
             );
-            thread.move_queued_message_to_main_editor(0, None, window, cx);
+            thread.move_queued_message_to_main_editor(0, None, None, window, cx);
         });
 
         cx.run_until_parked();

crates/agent_ui/src/conversation_view/thread_view.rs 🔗

@@ -585,7 +585,7 @@ impl ThreadView {
                 self.cancel_editing(&Default::default(), window, cx);
             }
             MessageEditorEvent::LostFocus => {}
-            MessageEditorEvent::InputAttempted(_) => {}
+            MessageEditorEvent::InputAttempted { .. } => {}
         }
     }
 
@@ -722,7 +722,7 @@ impl ThreadView {
             ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::Cancel) => {
                 self.cancel_editing(&Default::default(), window, cx);
             }
-            ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::InputAttempted(_)) => {}
+            ViewEvent::MessageEditorEvent(_editor, MessageEditorEvent::InputAttempted { .. }) => {}
             ViewEvent::OpenDiffLocation {
                 path,
                 position,
@@ -1440,6 +1440,7 @@ impl ThreadView {
         &mut self,
         index: usize,
         inserted_text: Option<&str>,
+        cursor_offset: Option<usize>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> bool {
@@ -1455,6 +1456,9 @@ impl ThreadView {
         if message_editor.read(cx).is_empty(cx) {
             message_editor.update(cx, |editor, cx| {
                 editor.set_message(queued_content, window, cx);
+                if let Some(offset) = cursor_offset {
+                    editor.set_cursor_offset(offset, window, cx);
+                }
                 if let Some(inserted_text) = inserted_text.as_deref() {
                     editor.insert_text(inserted_text, window, cx);
                 }
@@ -1463,8 +1467,16 @@ impl ThreadView {
             return true;
         }
 
+        // Adjust cursor offset accounting for existing content
+        let existing_len = message_editor.read(cx).text(cx).len();
+        let separator = "\n\n";
+
         message_editor.update(cx, |editor, cx| {
-            editor.append_message(queued_content, Some("\n\n"), window, cx);
+            editor.append_message(queued_content, Some(separator), window, cx);
+            if let Some(offset) = cursor_offset {
+                let adjusted_offset = existing_len + separator.len() + offset;
+                editor.set_cursor_offset(adjusted_offset, window, cx);
+            }
             if let Some(inserted_text) = inserted_text.as_deref() {
                 editor.insert_text(inserted_text, window, cx);
             }
@@ -3038,7 +3050,7 @@ impl ThreadView {
                                             })
                                             .on_click(cx.listener(move |this, _, window, cx| {
                                                 this.move_queued_message_to_main_editor(
-                                                    index, None, window, cx,
+                                                    index, None, None, window, cx,
                                                 );
                                             })),
                                     )
@@ -3112,7 +3124,7 @@ impl ThreadView {
                                             })
                                             .on_click(cx.listener(move |this, _, window, cx| {
                                                 this.move_queued_message_to_main_editor(
-                                                    index, None, window, cx,
+                                                    index, None, None, window, cx,
                                                 );
                                             })),
                                     )
@@ -8169,7 +8181,7 @@ impl Render for ThreadView {
                 cx.notify();
             }))
             .on_action(cx.listener(|this, _: &EditFirstQueuedMessage, window, cx| {
-                this.move_queued_message_to_main_editor(0, None, window, cx);
+                this.move_queued_message_to_main_editor(0, None, None, window, cx);
             }))
             .on_action(cx.listener(|this, _: &ClearMessageQueue, _, cx| {
                 this.local_queued_messages.clear();

crates/agent_ui/src/message_editor.rs 🔗

@@ -151,7 +151,10 @@ pub enum MessageEditorEvent {
     Cancel,
     Focus,
     LostFocus,
-    InputAttempted(Arc<str>),
+    InputAttempted {
+        text: Arc<str>,
+        cursor_offset: usize,
+    },
 }
 
 impl EventEmitter<MessageEditorEvent> for MessageEditor {}
@@ -257,7 +260,15 @@ impl MessageEditor {
                     && editor.read(cx).read_only(cx)
                     && !text.is_empty()
                 {
-                    cx.emit(MessageEditorEvent::InputAttempted(text.clone()));
+                    let editor = editor.read(cx);
+                    let cursor_anchor = editor.selections.newest_anchor().head();
+                    let cursor_offset = cursor_anchor
+                        .to_offset(&editor.buffer().read(cx).snapshot(cx))
+                        .0;
+                    cx.emit(MessageEditorEvent::InputAttempted {
+                        text: text.clone(),
+                        cursor_offset,
+                    });
                 }
 
                 if let EditorEvent::Edited { .. } = event
@@ -1580,6 +1591,21 @@ impl MessageEditor {
         self.editor.read(cx).text(cx)
     }
 
+    pub fn set_cursor_offset(
+        &mut self,
+        offset: usize,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.editor.update(cx, |editor, cx| {
+            let snapshot = editor.buffer().read(cx).snapshot(cx);
+            let offset = snapshot.clip_offset(MultiBufferOffset(offset), text::Bias::Left);
+            editor.change_selections(Default::default(), window, cx, |selections| {
+                selections.select_ranges([offset..offset]);
+            });
+        });
+    }
+
     pub fn insert_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
         if text.is_empty() {
             return;