diff --git a/apps/maple-agent/app/src/keymap.rs b/apps/maple-agent/app/src/keymap.rs index 3735ce503..466a73e8a 100644 --- a/apps/maple-agent/app/src/keymap.rs +++ b/apps/maple-agent/app/src/keymap.rs @@ -379,7 +379,7 @@ fn catalog_for(os: HostOs) -> Vec { "Focus task search", ShortcutCategory::Chat, Some("Chat"), - "secondary-k", + "secondary-f", SlotAction::FocusSearch, ), slot( diff --git a/apps/maple-agent/app/src/ui/chat/commands.rs b/apps/maple-agent/app/src/ui/chat/commands.rs index 903740d85..7ed177ae7 100644 --- a/apps/maple-agent/app/src/ui/chat/commands.rs +++ b/apps/maple-agent/app/src/ui/chat/commands.rs @@ -65,6 +65,17 @@ impl ChatScreen { } ChatCommand::ChooseProject => self.toggle_root_menu(cx), ChatCommand::CopySelection => self.copy_selected_text(cx), + ChatCommand::Escape + if self.sidebar.read(cx).search_input_focused(window, cx) + && self + .sidebar + .update(cx, |sidebar, cx| sidebar.hide_empty_search(cx)) => + { + if let Some(composer) = &self.composer { + use gpui::Focusable; + window.focus(&composer.read(cx).focus_handle(cx), cx); + } + } ChatCommand::Escape if self.application_vim_enabled => { self.application_escape(window, cx) } diff --git a/apps/maple-agent/app/src/ui/chat/composer.rs b/apps/maple-agent/app/src/ui/chat/composer.rs index 2c52ee0bf..eee36e85d 100644 --- a/apps/maple-agent/app/src/ui/chat/composer.rs +++ b/apps/maple-agent/app/src/ui/chat/composer.rs @@ -157,6 +157,14 @@ impl ChatScreen { ChatCommand::NewTask, 4, ), + ( + "header-search", + "search", + "Search", + &super::FocusSearch, + ChatCommand::FocusSearch, + 5, + ), ] .into_iter() .filter(|(_, _, _, _, command, _)| { diff --git a/apps/maple-agent/app/src/ui/chat/navigation.rs b/apps/maple-agent/app/src/ui/chat/navigation.rs index bf3030f27..f8dda9b7d 100644 --- a/apps/maple-agent/app/src/ui/chat/navigation.rs +++ b/apps/maple-agent/app/src/ui/chat/navigation.rs @@ -31,6 +31,7 @@ pub(super) enum ChatRegion { pub(super) enum SidebarTarget { Computer(super::computers::Navigate), NewTask, + Search, Projects, Task(String), Archived, @@ -560,6 +561,7 @@ impl ChatScreen { match self.application_vim.region { ChatRegion::Sidebar => match self.sidebar.read(cx).vim_selected().cloned() { Some(SidebarTarget::NewTask) => self.new_session(cx), + Some(SidebarTarget::Search) => self.focus_sidebar_search(window, cx), Some(SidebarTarget::Projects) => self.sidebar.update(cx, |sidebar, cx| { let open = sidebar.switcher_menu_open(); sidebar.set_switcher_menu_open(!open, cx); diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index 4990bc75b..5dcdf178f 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -80,6 +80,8 @@ use crate::ui::theme; use crate::ui::titlebar; use crate::ui::widgets; +const SEARCH_ROW_RADIUS: gpui::Pixels = px(6.); + /// Geometry shared by the expanded wordmark row and the collapsed mark. pub(super) const WORDMARK_LEFT: gpui::Pixels = px(16.); pub(super) const WORDMARK_HEIGHT: gpui::Pixels = px(16.); @@ -245,6 +247,7 @@ pub(super) enum SidebarEntry { ComputerMore(usize), LoadMore(PageKind), NewTask, + Search, /// The project switcher row above the sections. ProjectsHeader, /// A section label, shown only when the section has rows. @@ -436,6 +439,8 @@ pub(super) struct Sidebar { // Search and rename. filter: String, search_input: Entity, + search_visible: bool, + search_focus_out: Option, rename: Option, rename_input: Option>, rename_focus_pending: bool, @@ -591,6 +596,7 @@ impl Sidebar { || self.project_filter.is_some(); self.search_input .update(cx, |input, cx| input.set_text(&state.search, cx)); + self.search_visible = !state.search.is_empty(); self.filter = filter; self.archived_expanded = state.archived_expanded; // A project filter belongs to the previous execution computer. @@ -671,6 +677,8 @@ impl Sidebar { menu_trust: None, filter: String::new(), search_input, + search_visible: false, + search_focus_out: None, rename: None, rename_input: None, rename_focus_pending: false, @@ -1190,11 +1198,37 @@ impl Sidebar { } pub(super) fn focus_search(&mut self, window: &mut Window, cx: &mut Context) { + self.search_visible = true; + self.reveal_row(1); + self.list.remeasure_items(1..2); + let handle = self.search_input.read(cx).focus_handle(cx); + handle.focus(window, cx); + self.search_focus_out = Some(cx.on_focus_out(&handle, window, |_this, _, window, cx| { + cx.defer_in(window, |this, window, cx| { + if !this.search_input.read(cx).menu_open() && !this.search_input_focused(window, cx) + { + this.hide_empty_search(cx); + } + }); + })); + cx.notify(); + } + + pub(super) fn search_input_focused(&self, window: &Window, cx: &gpui::App) -> bool { self.search_input .read(cx) .focus_handle(cx) - .focus(window, cx); + .is_focused(window) + } + + pub(super) fn hide_empty_search(&mut self, cx: &mut Context) -> bool { + if !self.search_visible || !self.search_input.read(cx).text_ref().is_empty() { + return false; + } + self.search_visible = false; + self.list.remeasure_items(1..2); cx.notify(); + true } /// Rebuild the sidebar sections: pinned tasks first, then the active @@ -1309,7 +1343,11 @@ impl Sidebar { /// span that changed, so the scroll position and the measured /// heights of untouched rows survive every rebuild. fn rebuild_entries(&mut self) { - let mut entries = vec![SidebarEntry::NewTask, SidebarEntry::ProjectsHeader]; + let mut entries = vec![ + SidebarEntry::NewTask, + SidebarEntry::Search, + SidebarEntry::ProjectsHeader, + ]; let tasks = |entries: &mut Vec, section: SidebarSection, rows: &[usize], @@ -2101,6 +2139,7 @@ impl Sidebar { | SidebarEntry::Computer(_) | SidebarEntry::ComputerMore(_)) => self.computer_target(entry), SidebarEntry::NewTask => Some(SidebarTarget::NewTask), + SidebarEntry::Search => Some(SidebarTarget::Search), SidebarEntry::ProjectsHeader => Some(SidebarTarget::Projects), SidebarEntry::SectionLabel(_) | SidebarEntry::Empty(_) => None, SidebarEntry::LoadMore(kind) => { @@ -2251,6 +2290,70 @@ impl Sidebar { // ---- Render ------------------------------------------------------------ + fn render_search(&self, cx: &mut Context) -> AnyElement { + let active = !self.filter.is_empty(); + div() + .debug_selector(|| "sidebar-search".to_string()) + // Most of the sidebar takes no focus, so a press anywhere else + // must end the search itself: drop the cursor, and return to + // the Search row when nothing was typed. + .on_mouse_down_out(cx.listener(|this, _event, window, cx| { + if this.search_input.read(cx).menu_open() { + return; + } + if this.search_input_focused(window, cx) { + window.blur(); + } + this.hide_empty_search(cx); + })) + .flex() + .items_center() + .gap_2() + .w_full() + .my(px(1.)) + .mb_3() + .px_2() + .h_8() + .rounded(SEARCH_ROW_RADIUS) + .bg(gpui::rgb(if active { + theme::bg_sidebar_row_selected() + } else { + theme::bg_sidebar_row_hover() + })) + .text_sm() + .child(icon("search", px(16.), theme::text_secondary())) + .child(div().flex_1().min_w_0().child(self.search_input.clone())) + .when(active, |row| { + row.child( + div() + .id("search-clear") + .debug_selector(|| "search-clear".to_string()) + .size_5() + .flex() + .items_center() + .justify_center() + .rounded(theme::RADIUS_SM) + .hover(|style| { + style + .bg(gpui::rgb(theme::bg_sidebar_row_selected())) + .cursor_pointer() + }) + .tooltip(widgets::tooltip_for_action( + "Clear search", + &super::ChatEscape, + )) + .on_click(cx.listener(|this, _event, window, cx| { + this.clear_search(cx); + if !this.search_input_focused(window, cx) { + this.hide_empty_search(cx); + } + })) + .child(icon("x", px(12.), theme::text_secondary())), + ) + }) + .into_any_element() + } + /// One row of the sidebar list. fn render_entry( &mut self, @@ -2268,40 +2371,68 @@ impl Sidebar { | SidebarEntry::ComputerMore(_)), ) => self.render_computer_entry(entry, self.vim_selects_row(ix), cx), Some(SidebarEntry::LoadMore(kind)) => self.render_load_more(kind, cx), - Some(SidebarEntry::NewTask) => div() - .id("new-task") - .role(gpui::Role::Button) - .aria_label("New task") - .w_full() - .mb_3() - .px_4() - .py_1p5() - .rounded(theme::RADIUS_SM) - .flex() - .items_center() - .gap_2() - .text_sm() - .text_color(gpui::rgb(theme::accent())) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() - }) - .active(|style| style.bg(gpui::rgb(theme::bg_sidebar_row_selected()))) - .tooltip(widgets::tooltip_for_action( - "Start a new task", - &super::NewTask, - )) - .on_click(move |_event, window, cx| { - if let Some(chat) = chat.upgrade() { - chat.update(cx, |chat, cx| { - chat.execute_command(ChatCommand::NewTask, window, cx); - }); - } - }) - .child(icon("square-pen", px(16.), theme::accent())) - .child("New Task") - .into_any_element(), + Some(SidebarEntry::Search) if self.search_visible => self.render_search(cx), + Some(entry @ (SidebarEntry::NewTask | SidebarEntry::Search)) => { + let search = entry == SidebarEntry::Search; + let (id, name, label, command, action, color): (_, _, _, _, &dyn gpui::Action, _) = + if search { + ( + "sidebar-search-row", + "search", + "Search", + ChatCommand::FocusSearch, + &super::FocusSearch, + theme::text_secondary(), + ) + } else { + ( + "new-task", + "square-pen", + "New Task", + ChatCommand::NewTask, + &super::NewTask, + theme::accent(), + ) + }; + div() + .id(id) + .debug_selector(move || id.to_string()) + .role(gpui::Role::Button) + .aria_label(label) + .w_full() + .my(px(1.)) + .when(search, |row| { + row.mb_3() + .tab_index(2) + .focus_visible(widgets::focus_ring) + .on_key_down(ChatScreen::sidebar_button_key_down) + }) + .px_2() + .h_8() + .rounded(SEARCH_ROW_RADIUS) + .flex() + .items_center() + .gap_2() + .text_sm() + .text_color(gpui::rgb(color)) + .hover(|style| { + style + .bg(gpui::rgb(theme::bg_sidebar_row_hover())) + .cursor_pointer() + }) + .active(|style| style.bg(gpui::rgb(theme::bg_sidebar_row_selected()))) + .tooltip(widgets::tooltip_for_action(label, action)) + .on_click(move |_event, window, cx| { + if let Some(chat) = chat.upgrade() { + chat.update(cx, |chat, cx| { + chat.execute_command(command, window, cx); + }); + } + }) + .child(icon(name, px(16.), color)) + .child(label) + .into_any_element() + } Some(SidebarEntry::ProjectsHeader) => self.render_projects_header(window, cx), Some(SidebarEntry::Task(task)) => { let application_selected = self.vim_selects_row(ix); @@ -2449,6 +2580,14 @@ impl Sidebar { } None => div().into_any_element(), }; + let entry = if matches!( + self.entries.get(ix), + Some(SidebarEntry::NewTask | SidebarEntry::Search) + ) { + div().w_full().px_2().child(entry).into_any_element() + } else { + entry + }; if !self.application_vim_enabled { return entry; } @@ -2983,7 +3122,6 @@ impl Render for Sidebar { sidebar.update(cx, |sidebar, cx| sidebar.render_entry(ix, window, cx)) }) .size_full(); - let active = !self.filter.is_empty(); div() .size_full() .flex() @@ -3013,51 +3151,6 @@ impl Render for Sidebar { ), ), )) - .child( - div() - .flex() - .items_center() - .gap_2() - .mx_4() - .mt_3() - .px_2() - .py_1() - .rounded(theme::RADIUS_SM) - .bg(gpui::rgb(theme::bg_sidebar_pill())) - .border_1() - .border_color(gpui::rgb(if active { - theme::accent() - } else { - theme::border_subtle() - })) - .text_sm() - .child(icon("search", px(14.), theme::text_muted())) - .child(div().flex_1().min_w_0().child(self.search_input.clone())) - .when(active, |row| { - row.child( - div() - .id("search-clear") - .size_5() - .flex() - .items_center() - .justify_center() - .rounded(theme::RADIUS_SM) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_selected())) - .cursor_pointer() - }) - .tooltip(widgets::tooltip_for_action( - "Clear search", - &super::ChatEscape, - )) - .on_click(cx.listener(|this, _event, _window, cx| { - this.clear_search(cx); - })) - .child(icon("x", px(12.), theme::text_secondary())), - ) - }), - ) .child( div() .id("session-list") diff --git a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs index eeaead3f9..26e7c5cb0 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs @@ -833,7 +833,11 @@ impl Sidebar { self.other_tasks.clear(); self.group_pages.more.clear(); self.computer_archived_count = 0; - let mut entries = vec![SidebarEntry::NewTask, SidebarEntry::ProjectsHeader]; + let mut entries = vec![ + SidebarEntry::NewTask, + SidebarEntry::Search, + SidebarEntry::ProjectsHeader, + ]; let mut archived_entries = Vec::new(); // These small clones let rendering append row projections without // borrowing the per-host paging cache for the whole rebuild. diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index e4e486fed..973fc9cb6 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -5981,7 +5981,6 @@ mod state_tests { ); assert_eq!(toggle.left(), SIDEBAR_RAIL_WIDTH + SIDEBAR_COLLAPSED_INSET); assert!(toggle.right() < new_task.left()); - assert_eq!(new_task.right() + px(25.), title.left()); assert!(title.right() < project.left()); assert_eq!(toggle.center().y, title.center().y); @@ -6059,6 +6058,184 @@ mod state_tests { assert!(cx.update(|_, app| chat.read(app).sidebar_collapsed)); } + #[gpui::test] + fn sidebar_search_button_and_shortcut(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = (0..80) + .map(|index| summary(&format!("s{index}"), "Task")) + .collect(); + this.sync_sidebar(cx); + }); + let focus = cx.update(|_, app| chat.read(app).sidebar.read(app).search_focus_handle(app)); + let composer = cx.update(|_, app| chat.read(app).composer.clone().unwrap()); + let composer_focus = cx.update(|_, app| composer.read(app).focus_handle(app)); + for (trigger, collapsed) in [ + ("sidebar-search-row", false), + ("header-search", true), + ("shortcut", false), + ("shortcut", true), + ] { + chat.update(cx, |this, cx| this.set_sidebar_collapsed(collapsed, cx)); + cx.run_until_parked(); + if trigger == "shortcut" { + chat.update(cx, |this, cx| { + this.sidebar.update(cx, |sidebar, cx| { + sidebar.list().scroll_to_reveal_item(70); + cx.notify(); + }) + }); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + assert!(cx.debug_bounds("sidebar-search-row").is_none()); + cx.simulate_keystrokes(if cfg!(target_os = "macos") { + "cmd-f" + } else { + "ctrl-f" + }); + cx.run_until_parked(); + } else { + press(cx, trigger); + } + assert!(!cx.update(|_, app| chat.read(app).sidebar_collapsed)); + assert!(cx.debug_bounds("sidebar-search").is_some()); + assert!(cx.update(|window, _| focus.is_focused(window))); + cx.simulate_keystrokes("escape"); + cx.run_until_parked(); + assert!(cx.debug_bounds("sidebar-search-row").is_some()); + assert!(!cx.update(|window, _| focus.is_focused(window))); + assert!(cx.update(|window, _| composer_focus.is_focused(window))); + } + } + + #[gpui::test] + fn sidebar_search_empty_escape_with_application_vim(cx: &mut TestAppContext) { + use crate::ui::chat::navigation::{ChatRegion, SidebarTarget}; + let (chat, cx) = chat_window(cx, |this, cx| { + this.set_application_vim_enabled(true, cx); + this.application_vim.region = ChatRegion::Sidebar; + this.sidebar.update(cx, |sidebar, cx| { + sidebar.vim_select(SidebarTarget::Search, cx) + }); + }); + cx.simulate_keystrokes("enter"); + cx.run_until_parked(); + assert!(cx.debug_bounds("sidebar-search").is_some()); + cx.simulate_keystrokes("escape"); + cx.run_until_parked(); + assert!(cx.debug_bounds("sidebar-search").is_none()); + assert!(cx.update(|window, app| { + chat.read(app) + .application_focus + .as_ref() + .unwrap() + .is_focused(window) + })); + } + + #[gpui::test] + fn sidebar_search_survives_its_fields_menu(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![summary("s1", "Alpha"), summary("s2", "Beta")]; + this.sync_sidebar(cx); + }); + let input = cx.update(|_, app| chat.read(app).sidebar.read(app).inputs()[0].clone()); + let focus = cx.update(|_, app| input.read(app).focus_handle(app)); + for text in ["", "alpha"] { + press(cx, "sidebar-search-row"); + cx.update(|_, app| { + app.write_to_clipboard(gpui::ClipboardItem::new_string(text.to_string())); + }); + let field = cx.debug_bounds("sidebar-search").expect("search field"); + cx.simulate_mouse_down( + field.center(), + gpui::MouseButton::Right, + gpui::Modifiers::default(), + ); + cx.simulate_mouse_up( + field.center(), + gpui::MouseButton::Right, + gpui::Modifiers::default(), + ); + cx.run_until_parked(); + assert!(input.read_with(cx, |input, _| input.menu_open())); + assert!(!cx.update(|window, _| focus.is_focused(window))); + assert!(cx.debug_bounds("sidebar-search").is_some()); + press(cx, "text-input-paste"); + assert!(!input.read_with(cx, |input, _| input.menu_open())); + assert_eq!(input.read_with(cx, |input, _| input.text()), text); + assert!(cx.debug_bounds("sidebar-search").is_some()); + assert!(cx.update(|window, _| focus.is_focused(window))); + assert_eq!( + chat.update(cx, |this, cx| this.sidebar_active(cx)), + if text.is_empty() { vec![0, 1] } else { vec![0] } + ); + cx.simulate_click(gpui::point(px(40.), px(700.)), gpui::Modifiers::default()); + cx.run_until_parked(); + assert!(!cx.update(|window, _| focus.is_focused(window))); + assert_eq!( + cx.debug_bounds("sidebar-search").is_some(), + !text.is_empty() + ); + } + press(cx, "search-clear"); + assert!(cx.debug_bounds("sidebar-search").is_none()); + assert_eq!( + chat.update(cx, |this, cx| this.sidebar_active(cx)), + vec![0, 1] + ); + } + + #[gpui::test] + fn sidebar_search_button_keyboard(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |_, _| {}); + let composer = cx.update(|_, app| chat.read(app).composer.clone().unwrap()); + let composer_focus = cx.update(|_, app| composer.read(app).focus_handle(app)); + let search_focus = + cx.update(|_, app| chat.read(app).sidebar.read(app).search_focus_handle(app)); + for collapsed in [false, true] { + for key in ["enter", "space"] { + chat.update(cx, |this, cx| this.set_sidebar_collapsed(collapsed, cx)); + composer.update(cx, |input, cx| input.set_text("Unsent draft", cx)); + cx.update(|window, app| window.focus(&composer_focus, app)); + cx.run_until_parked(); + cx.simulate_keystrokes(if collapsed { "tab tab tab tab" } else { "tab" }); + cx.simulate_keystrokes(key); + assert_eq!(composer.update(cx, |input, _| input.text()), "Unsent draft"); + cx.update(|window, app| { + window.dispatch_event( + gpui::PlatformInput::KeyUp(gpui::KeyUpEvent { + keystroke: gpui::Keystroke::parse(key).unwrap(), + }), + app, + ); + }); + cx.run_until_parked(); + assert!(!cx.update(|_, app| chat.read(app).sidebar_collapsed)); + assert!(cx.update(|window, _| search_focus.is_focused(window))); + cx.simulate_input("filter"); + cx.simulate_keystrokes("escape"); + cx.run_until_parked(); + assert!(cx.debug_bounds("sidebar-search").is_some()); + cx.simulate_keystrokes("escape"); + cx.run_until_parked(); + assert!(cx.debug_bounds("sidebar-search").is_none()); + } + } + press(cx, "sidebar-search-row"); + cx.simulate_input("filter"); + cx.update(|window, app| window.focus(&composer_focus, app)); + cx.run_until_parked(); + assert!(cx.debug_bounds("sidebar-search").is_some()); + cx.update(|window, app| window.focus(&search_focus, app)); + cx.simulate_keystrokes("escape"); + cx.run_until_parked(); + assert!(cx.debug_bounds("sidebar-search").is_some()); + cx.update(|window, app| window.focus(&composer_focus, app)); + cx.run_until_parked(); + assert!(cx.debug_bounds("sidebar-search").is_none()); + assert!(cx.debug_bounds("sidebar-search-row").is_some()); + } + /// The header's New task button starts the same draft as the sidebar. #[gpui::test] fn collapsed_sidebar_header_starts_a_draft(cx: &mut TestAppContext) { @@ -7518,7 +7695,7 @@ mod state_tests { this.sync_sidebar(cx); // The screen fixture selects s1: one step moves to s2, and // back stops at the top. - assert_eq!(this.task_step_target(1, cx), Some((4, local("s2")))); + assert_eq!(this.task_step_target(1, cx), Some((5, local("s2")))); assert_eq!( this.task_step_target(-1, cx), None, @@ -7526,13 +7703,13 @@ mod state_tests { ); this.selected_session = Some("s2".to_string()); this.sync_sidebar_selection(cx); - assert_eq!(this.task_step_target(1, cx), Some((5, local("s3")))); - assert_eq!(this.task_step_target(-1, cx), Some((3, local("s1")))); + assert_eq!(this.task_step_target(1, cx), Some((6, local("s3")))); + assert_eq!(this.task_step_target(-1, cx), Some((4, local("s1")))); // With nothing selected, each direction enters from its end. this.selected_session = None; this.sync_sidebar_selection(cx); - assert_eq!(this.task_step_target(1, cx), Some((3, local("s1")))); - assert_eq!(this.task_step_target(-1, cx), Some((5, local("s3")))); + assert_eq!(this.task_step_target(1, cx), Some((4, local("s1")))); + assert_eq!(this.task_step_target(-1, cx), Some((6, local("s3")))); }); }