From c42d54af175721913ea2b67ce5b8a268262812ae Mon Sep 17 00:00:00 2001 From: Dino Date: Fri, 31 Oct 2025 15:17:26 +0000 Subject: [PATCH] agent_ui: Autoscroll after inserting selections (#41370) 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 --- crates/agent_ui/src/acp/message_editor.rs | 175 ++++++++++++++++++++++ 1 file changed, 175 insertions(+) diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index 90991182dc77e00c07fb7c7330695f72da9a2f44..856cc4d0d47d1e1d618c0056c771dfabe3c0bda4 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/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::>(); + 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::() + .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::(&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::(&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)); + }) + }); + } }