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)); + }) + }); + } }