agent_ui: Autoscroll after inserting selections (#41370)

Dino created

Update the behavior of the `zed_actions::agent::AddSelectionToThread`
action so that, after the selecitons are added to the current thread,
the editor automatically scrolls to the cursor's position, fixing an
issue where the inserted selection's UI component could wrap the cursor
to the next line below, leaving it outside the viewable area.

Closes #39694

Release Notes:

- Improved the `agent: add selection to thread` action so as to
automatically scroll to the cursor's position after selections are
inserted

Change summary

crates/agent_ui/src/acp/message_editor.rs | 175 +++++++++++++++++++++++++
1 file changed, 175 insertions(+)

Detailed changes

crates/agent_ui/src/acp/message_editor.rs 🔗

@@ -16,6 +16,7 @@ use editor::{
     MultiBuffer, ToOffset,
     actions::Paste,
     display_map::{Crease, CreaseId, FoldId},
+    scroll::Autoscroll,
 };
 use futures::{
     FutureExt as _,
@@ -591,6 +592,21 @@ impl MessageEditor {
                 ),
             );
         }
+
+        // Take this explanation with a grain of salt but, with creases being
+        // inserted, GPUI's recomputes the editor layout in the next frames, so
+        // directly calling `editor.request_autoscroll` wouldn't work as
+        // expected. We're leveraging `cx.on_next_frame` to wait 2 frames and
+        // ensure that the layout has been recalculated so that the autoscroll
+        // request actually shows the cursor's new position.
+        let editor = self.editor.clone();
+        cx.on_next_frame(window, move |_, window, cx| {
+            cx.on_next_frame(window, move |_, _, cx| {
+                editor.update(cx, |editor, cx| {
+                    editor.request_autoscroll(Autoscroll::fit(), cx)
+                });
+            });
+        });
     }
 
     fn confirm_mention_for_thread(
@@ -1030,6 +1046,7 @@ impl MessageEditor {
 
         self.editor.update(cx, |message_editor, cx| {
             message_editor.edit([(cursor_anchor..cursor_anchor, completion.new_text)], cx);
+            message_editor.request_autoscroll(Autoscroll::fit(), cx);
         });
         if let Some(confirm) = completion.confirm {
             confirm(CompletionIntent::Complete, window, cx);
@@ -2745,6 +2762,7 @@ mod tests {
             _ => panic!("Expected Text mention for small file"),
         }
     }
+
     #[gpui::test]
     async fn test_insert_thread_summary(cx: &mut TestAppContext) {
         init_test(cx);
@@ -2878,4 +2896,161 @@ mod tests {
             })]
         );
     }
+
+    #[gpui::test]
+    async fn test_autoscroll_after_insert_selections(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let app_state = cx.update(AppState::test);
+
+        cx.update(|cx| {
+            language::init(cx);
+            editor::init(cx);
+            workspace::init(app_state.clone(), cx);
+            Project::init_settings(cx);
+        });
+
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(
+                path!("/dir"),
+                json!({
+                    "test.txt": "line1\nline2\nline3\nline4\nline5\n",
+                }),
+            )
+            .await;
+
+        let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
+        let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+        let workspace = window.root(cx).unwrap();
+
+        let worktree = project.update(cx, |project, cx| {
+            let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
+            assert_eq!(worktrees.len(), 1);
+            worktrees.pop().unwrap()
+        });
+        let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
+
+        let mut cx = VisualTestContext::from_window(*window, cx);
+
+        // Open a regular editor with the created file, and select a portion of
+        // the text that will be used for the selections that are meant to be
+        // inserted in the agent panel.
+        let editor = workspace
+            .update_in(&mut cx, |workspace, window, cx| {
+                workspace.open_path(
+                    ProjectPath {
+                        worktree_id,
+                        path: rel_path("test.txt").into(),
+                    },
+                    None,
+                    false,
+                    window,
+                    cx,
+                )
+            })
+            .await
+            .unwrap()
+            .downcast::<Editor>()
+            .unwrap();
+
+        editor.update_in(&mut cx, |editor, window, cx| {
+            editor.change_selections(Default::default(), window, cx, |selections| {
+                selections.select_ranges([Point::new(0, 0)..Point::new(0, 5)]);
+            });
+        });
+
+        let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
+        let history_store = cx.new(|cx| HistoryStore::new(text_thread_store, cx));
+
+        // Create a new `MessageEditor`. The `EditorMode::full()` has to be used
+        // to ensure we have a fixed viewport, so we can eventually actually
+        // place the cursor outside of the visible area.
+        let message_editor = workspace.update_in(&mut cx, |workspace, window, cx| {
+            let workspace_handle = cx.weak_entity();
+            let message_editor = cx.new(|cx| {
+                MessageEditor::new(
+                    workspace_handle,
+                    project.clone(),
+                    history_store.clone(),
+                    None,
+                    Default::default(),
+                    Default::default(),
+                    "Test Agent".into(),
+                    "Test",
+                    EditorMode::full(),
+                    window,
+                    cx,
+                )
+            });
+            workspace.active_pane().update(cx, |pane, cx| {
+                pane.add_item(
+                    Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
+                    true,
+                    true,
+                    None,
+                    window,
+                    cx,
+                );
+            });
+
+            message_editor
+        });
+
+        message_editor.update_in(&mut cx, |message_editor, window, cx| {
+            message_editor.editor.update(cx, |editor, cx| {
+                // Update the Agent Panel's Message Editor text to have 100
+                // lines, ensuring that the cursor is set at line 90 and that we
+                // then scroll all the way to the top, so the cursor's position
+                // remains off screen.
+                let mut lines = String::new();
+                for _ in 1..=100 {
+                    lines.push_str(&"Another line in the agent panel's message editor\n");
+                }
+                editor.set_text(lines.as_str(), window, cx);
+                editor.change_selections(Default::default(), window, cx, |selections| {
+                    selections.select_ranges([Point::new(90, 0)..Point::new(90, 0)]);
+                });
+                editor.set_scroll_position(gpui::Point::new(0., 0.), window, cx);
+            });
+        });
+
+        cx.run_until_parked();
+
+        // Before proceeding, let's assert that the cursor is indeed off screen,
+        // otherwise the rest of the test doesn't make sense.
+        message_editor.update_in(&mut cx, |message_editor, window, cx| {
+            message_editor.editor.update(cx, |editor, cx| {
+                let snapshot = editor.snapshot(window, cx);
+                let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
+                let scroll_top = snapshot.scroll_position().y as u32;
+                let visible_lines = editor.visible_line_count().unwrap() as u32;
+                let visible_range = scroll_top..(scroll_top + visible_lines);
+
+                assert!(!visible_range.contains(&cursor_row));
+            })
+        });
+
+        // Now let's insert the selection in the Agent Panel's editor and
+        // confirm that, after the insertion, the cursor is now in the visible
+        // range.
+        message_editor.update_in(&mut cx, |message_editor, window, cx| {
+            message_editor.insert_selections(window, cx);
+        });
+
+        cx.run_until_parked();
+
+        message_editor.update_in(&mut cx, |message_editor, window, cx| {
+            message_editor.editor.update(cx, |editor, cx| {
+                let snapshot = editor.snapshot(window, cx);
+                let cursor_row = editor.selections.newest::<Point>(&snapshot).head().row;
+                let scroll_top = snapshot.scroll_position().y as u32;
+                let visible_lines = editor.visible_line_count().unwrap() as u32;
+                let visible_range = scroll_top..(scroll_top + visible_lines);
+
+                assert!(visible_range.contains(&cursor_row));
+            })
+        });
+    }
 }