diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index a5331a4e9..849321f5d 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -928,6 +928,10 @@ impl ChatScreen { return; } self.sidebar_collapsed = collapsed; + if collapsed { + // Its rows leave the window without seeing the pointer leave. + self.sidebar.update(cx, |sidebar, cx| sidebar.end_hover(cx)); + } cx.notify(); } @@ -4017,7 +4021,8 @@ impl ChatScreen { let Some((row, request)) = self.task_step_target(delta, cx) else { return; }; - self.sidebar.read(cx).reveal_row(row); + self.sidebar + .update(cx, |sidebar, cx| sidebar.reveal_row(row, cx)); if request.computer.as_deref() == self.backend.remote_host() { if let Destination::Task(id) = request.destination { self.select_session(&id, cx); @@ -6339,7 +6344,12 @@ impl Render for ChatScreen { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { // Refreshed every frame; activation changes force a redraw, so // this tracks focus closely enough to gate notifications. - self.window_active = window.is_window_active(); + let was_active = std::mem::replace(&mut self.window_active, window.is_window_active()); + if was_active && !self.window_active { + // Another app took over under a still pointer, which reports + // nothing; the sidebar's hover card goes with the window's focus. + self.sidebar.update(cx, |sidebar, cx| sidebar.end_hover(cx)); + } let vim_in_sidebar = self.application_vim_enabled && self.application_vim.region == self::navigation::ChatRegion::Sidebar; self.sidebar diff --git a/apps/maple-agent/app/src/ui/chat/navigation.rs b/apps/maple-agent/app/src/ui/chat/navigation.rs index 8be1af2ee..f0493d22c 100644 --- a/apps/maple-agent/app/src/ui/chat/navigation.rs +++ b/apps/maple-agent/app/src/ui/chat/navigation.rs @@ -895,9 +895,11 @@ impl ChatScreen { self.focus_application_vim(window, cx); } - fn reveal_application_selection(&self, cx: &gpui::App) { + fn reveal_application_selection(&self, cx: &mut gpui::App) { match self.application_vim.region { - ChatRegion::Sidebar => self.sidebar.read(cx).vim_reveal(), + ChatRegion::Sidebar => self + .sidebar + .update(cx, |sidebar, cx| sidebar.vim_reveal(cx)), ChatRegion::Transcript => { if let Some(item_id) = self.selected_transcript_id() && let Some(&(row, _)) = self.timeline_index.get(item_id) diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index c1c4b7da0..029c94c52 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -60,9 +60,10 @@ pub(crate) struct BrowseState { use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::sync::Arc; +use std::time::Duration; use gpui::{ - AnyElement, AppContext, Context, Div, Entity, EventEmitter, Focusable, SharedString, + AnyElement, AppContext, Context, Div, Entity, EventEmitter, Focusable, Pixels, SharedString, Subscription, Task, WeakEntity, Window, div, prelude::*, px, }; use maple_agent::agent::{ @@ -74,7 +75,7 @@ use super::navigation::SidebarTarget; use super::{ChatScreen, section_label}; use crate::backend::AgentBackend; use crate::ui::icons::{icon, spinner_with_id, wordmark}; -use crate::ui::popup::{Menu, MenuItem, Placement, Popup}; +use crate::ui::popup::{self, Menu, MenuItem, Placement, Popup}; use crate::ui::text_input::TextInput; use crate::ui::theme; use crate::ui::titlebar; @@ -169,6 +170,9 @@ pub(super) struct SidebarRow { /// Animation id of the running indicator. pub(super) spinner_id: SharedString, pub(super) pin_id: SharedString, + card_id: SharedString, + card_folder_id: SharedString, + actions_id: SharedString, pub(super) menu_id: SharedString, /// Id of the overflow-menu panel itself. pub(super) menu_panel_id: SharedString, @@ -181,6 +185,7 @@ pub(super) struct SidebarRow { pub(super) title: SharedString, /// Display name of the task's project, shown on every row. pub(super) project_name: SharedString, + path: SharedString, /// Lower-cased title, matched against the sidebar filter. pub(super) search: String, } @@ -196,6 +201,9 @@ impl SidebarRow { step_id: SharedString::from(format!("step-session-{id}")), spinner_id: SharedString::from(format!("spinner-session-{id}")), pin_id: SharedString::from(format!("pin-session-{id}")), + card_id: SharedString::from(format!("card-session-{id}")), + card_folder_id: SharedString::from(format!("card-folder-{id}")), + actions_id: SharedString::from(format!("actions-session-{id}")), menu_id: SharedString::from(format!("menu-session-{id}")), menu_panel_id: SharedString::from(format!("task-menu-{id}")), menu_rename_id: SharedString::from(format!("rename-task-{id}")), @@ -206,6 +214,7 @@ impl SidebarRow { menu_reopen_id: SharedString::from(format!("reopen-task-{id}")), title: SharedString::from(session.title.clone()), project_name: SharedString::from(project_name.to_string()), + path: SharedString::from(session.project_root.clone()), search: session.title.to_lowercase(), } } @@ -214,6 +223,21 @@ impl SidebarRow { /// The switcher's "All projects" row. const ALL_PROJECTS_ID: &str = "switcher-all-projects"; +const CARD_AGE_REFRESH: Duration = Duration::from_secs(60); +// Enough time to cross the gap, shorter than a button's tooltip delay. +const CARD_CLOSE_DELAY: Duration = Duration::from_millis(200); + +struct HoveredTask { + session: Arc, + entry: usize, + age: SharedString, + on_row: bool, + on_card: bool, + // Dropping these tasks cancels the pending close and age refresh. + closing: Option>, + _age_refresh: Task<()>, +} + /// The sidebar's popup menus. One is open at a time; a project's own menu /// opens inside the switcher (see `Sidebar::project_popup`). #[derive(Clone, Debug, PartialEq, Eq)] @@ -412,6 +436,8 @@ pub(super) struct Sidebar { active_expanded: bool, settled_expanded: bool, archived_expanded: bool, + hovered: Option, + on_row_actions: Option>, switcher_roots: Vec, computer_switcher_roots: Vec>, scope_label: SharedString, @@ -434,6 +460,9 @@ pub(super) struct Sidebar { /// Task renames sent to the backend, for tests. #[cfg(test)] renames_sent: Vec<(String, String)>, + /// Folders the sidebar asked the file manager to open, for tests. + #[cfg(test)] + folders_opened: Vec, // Persisted in the app settings. pinned_tasks: Vec, /// Latest owner metadata seen per path, independent of current membership. @@ -584,6 +613,7 @@ impl Sidebar { self.archived_expanded = state.archived_expanded; // A project filter belongs to the previous execution computer. self.project_filter = None; + self.end_hover(cx); if self.pages.enabled && scope_changed { self.refresh_pages(cx); } @@ -623,6 +653,14 @@ impl Sidebar { this.search_changed(&input, cx) }) .detach(); + let list = gpui::ListState::new(0, gpui::ListAlignment::Top, px(200.)); + // Scrolling moves the rows under a still pointer, which reports + // nothing until it moves; the hover ends and its next move claims + // whichever row is under it. + let sidebar = cx.entity().downgrade(); + list.set_scroll_handler(move |_event, _window, cx| { + sidebar.update(cx, |this, cx| this.end_hover(cx)).ok(); + }); Self { pages: SidebarPages::default(), group_pages: ComputerPages::default(), @@ -640,7 +678,7 @@ impl Sidebar { recent_roots: Vec::new(), application_vim_enabled, vim_active: false, - list: gpui::ListState::new(0, gpui::ListAlignment::Top, px(200.)), + list, entries: Vec::new(), rows: Vec::new(), pinned_rows: Vec::new(), @@ -651,6 +689,8 @@ impl Sidebar { active_expanded: true, settled_expanded: true, archived_expanded: false, + hovered: None, + on_row_actions: None, switcher_roots: Vec::new(), computer_switcher_roots: Vec::new(), scope_label: "All projects".into(), @@ -666,6 +706,8 @@ impl Sidebar { rename_focus_out: None, #[cfg(test)] renames_sent: Vec::new(), + #[cfg(test)] + folders_opened: Vec::new(), pinned_tasks: remote_settings .as_ref() .map_or_else(|| settings.pinned_tasks.clone(), |s| s.pinned_tasks.clone()), @@ -983,7 +1025,10 @@ impl Sidebar { Some(rows[target].clone()) } - pub(super) fn reveal_row(&self, row: usize) { + /// Scroll `row` into view. The rows move under a still pointer, so + /// this ends the hover the way a wheel scroll does. + pub(super) fn reveal_row(&mut self, row: usize, cx: &mut Context) { + self.end_hover(cx); self.list.scroll_to_reveal_item(row); } @@ -1034,6 +1079,16 @@ impl Sidebar { } } + #[cfg(test)] + pub(super) fn hovered(&self) -> Option<&str> { + self.hovered.as_ref().map(|hovered| &*hovered.session) + } + + #[cfg(test)] + pub(super) fn card_age(&self) -> Option<&str> { + self.hovered.as_ref().map(|hovered| hovered.age.as_ref()) + } + #[cfg(test)] pub(super) fn project_name_for_test(&self, target: &ProjectTarget) -> String { self.project_name(target) @@ -1104,6 +1159,11 @@ impl Sidebar { &self.renames_sent } + #[cfg(test)] + pub(super) fn folders_opened(&self) -> &[String] { + &self.folders_opened + } + #[cfg(test)] pub(super) fn rename_target(&self) -> Option { self.rename.clone() @@ -1122,7 +1182,22 @@ impl Sidebar { #[cfg(test)] pub(super) fn open_task_menu_for_test(&mut self, session_id: &str, cx: &mut Context) { - self.popup.open(SidebarPopup::Task(session_id.into()), cx); + if !self.popup.is_open(&SidebarPopup::Task(session_id.into())) { + self.toggle_task_menu(session_id.into(), cx); + } + } + + /// Move a task's activity time without the screen's sync, so only the + /// hover card's own refresh can see it. + #[cfg(test)] + pub(super) fn set_updated_ms_for_test(&mut self, session_id: &str, updated_ms: i64) { + if let Some(session) = self + .sessions + .iter_mut() + .find(|session| session.id == session_id) + { + session.updated_ms = updated_ms; + } } #[cfg(test)] @@ -1405,6 +1480,11 @@ impl Sidebar { if self.application_vim_enabled { self.vim_reconcile(); } + // A row that left or moved in the list never reports the pointer + // leaving it; one that stayed may have new activity. + if self.hovered.is_some() { + self.refresh_card_age(now_ms()); + } } /// A row changed its height (a rename field came or went): let the @@ -1436,6 +1516,7 @@ impl Sidebar { && self.rows.iter().zip(&self.sessions).all(|(row, session)| { *row.id == *session.id && row.title.as_ref() == session.title + && row.path.as_ref() == session.project_root && self.root_name_matches(&session.project_root, &row.project_name) }); if fresh { @@ -1575,6 +1656,7 @@ impl Sidebar { /// Open or close the project switcher menu from its header. pub(super) fn toggle_switcher_menu(&mut self, cx: &mut Context) { + self.end_hover(cx); self.project_popup.close(cx); self.popup.toggle(SidebarPopup::Switcher, cx); } @@ -1585,6 +1667,7 @@ impl Sidebar { if open == self.switcher_menu_open() { return; } + self.end_hover(cx); self.project_popup.close(cx); if open { let current = self @@ -1640,6 +1723,7 @@ impl Sidebar { .unwrap_or_default(), RenameTarget::Project(target) => self.project_name(target), }; + self.end_hover(cx); let chat = self.chat.clone(); let sidebar = cx.entity().downgrade(); let escape = sidebar.clone(); @@ -1804,6 +1888,12 @@ impl Sidebar { /// Show the project's folder in the file manager. fn open_folder(&mut self, root: &str, cx: &mut Context) { + // Tests record the request instead of opening a window. + #[cfg(test)] + if cfg!(test) { + self.folders_opened.push(root.to_string()); + return; + } let path = root.to_string(); self.call( async move { @@ -1854,8 +1944,142 @@ impl Sidebar { ); } + // Every move can reclaim a hover ended by scrolling or a menu. + fn claim_hover(&mut self, session_id: &Arc, cx: &mut Context) { + if self.card_suppressed() || self.on_row_actions.as_ref() == Some(session_id) { + return; + } + if let Some(hovered) = &mut self.hovered + && hovered.session == *session_id + { + if !hovered.on_row { + hovered.on_row = true; + self.hover_moved(cx); + } + return; + } + let Some(entry) = self.task_entry(session_id) else { + return; + }; + self.hovered = Some(HoveredTask { + session: Arc::clone(session_id), + entry, + age: SharedString::default(), + on_row: true, + on_card: false, + closing: None, + _age_refresh: cx.spawn(async move |this, cx| { + loop { + cx.background_executor().timer(CARD_AGE_REFRESH).await; + if this + .update(cx, |this, cx| { + if this.refresh_card_age(now_ms()) { + cx.notify(); + } + }) + .is_err() + { + break; + } + } + }), + }); + self.refresh_card_age(now_ms()); + cx.notify(); + } + + /// The pointer left a task row. Rows report in no fixed order, so + /// leaving a row touches only its own hover. + fn release_hover(&mut self, session_id: &Arc, cx: &mut Context) { + if let Some(hovered) = &mut self.hovered + && hovered.session == *session_id + { + hovered.on_row = false; + self.hover_moved(cx); + } + } + + fn set_on_row_actions(&mut self, session_id: &Arc, on: bool, cx: &mut Context) { + if on { + self.on_row_actions = Some(Arc::clone(session_id)); + self.release_hover(session_id, cx); + } else if self.on_row_actions.as_ref() == Some(session_id) { + self.on_row_actions = None; + } + } + + fn set_on_card(&mut self, on: bool, cx: &mut Context) { + if let Some(hovered) = &mut self.hovered { + hovered.on_card = on; + self.hover_moved(cx); + } + } + + fn hover_moved(&mut self, cx: &mut Context) { + let Some(hovered) = &mut self.hovered else { + return; + }; + if hovered.on_row || hovered.on_card { + hovered.closing = None; + } else if hovered.closing.is_none() { + hovered.closing = Some(cx.spawn(async move |this, cx| { + cx.background_executor().timer(CARD_CLOSE_DELAY).await; + this.update(cx, |this, cx| this.end_hover(cx)).ok(); + })); + } + } + + pub(super) fn end_hover(&mut self, cx: &mut Context) { + if self.hovered.take().is_some() { + cx.notify(); + } + } + + fn task_entry(&self, session_id: &str) -> Option { + self.entries.iter().position(|entry| { + matches!(entry, SidebarEntry::Task(task) + if self + .sessions + .get(task.session) + .is_some_and(|session| *session.id == *session_id)) + }) + } + + /// Bring the hover card's age up to `now`, or end the hover when its + /// row left the list or moved in it. Reports whether the card changed. + pub(super) fn refresh_card_age(&mut self, now: i64) -> bool { + let Some(hovered) = &self.hovered else { + return false; + }; + let updated_ms = self + .task_entry(&hovered.session) + .filter(|entry| *entry == hovered.entry) + .and_then(|entry| match &self.entries[entry] { + SidebarEntry::Task(task) => self.sessions.get(task.session), + _ => None, + }) + .map(|session| session.updated_ms); + let Some(updated_ms) = updated_ms else { + self.hovered = None; + return true; + }; + let age = age_label(now, updated_ms); + let hovered = self.hovered.as_mut().unwrap(); + if age == hovered.age { + return false; + } + hovered.age = age; + true + } + + // Project menus are nested inside the switcher. + fn card_suppressed(&self) -> bool { + self.popup.open_key().is_some() || self.rename.is_some() + } + /// Open or close the overflow menu of one task row. fn toggle_task_menu(&mut self, session_id: Arc, cx: &mut Context) { + self.end_hover(cx); self.popup.toggle(SidebarPopup::Task(session_id), cx); } @@ -2165,7 +2389,7 @@ impl Sidebar { let row = self.vim_target_row(&target); self.vim_selected = Some(target); if let Some(row) = row { - self.list.scroll_to_reveal_item(row); + self.reveal_row(row, cx); } cx.notify(); true @@ -2183,7 +2407,7 @@ impl Sidebar { return false; }; self.vim_selected = Some(target); - self.list.scroll_to_reveal_item(row); + self.reveal_row(row, cx); cx.notify(); true } @@ -2195,13 +2419,13 @@ impl Sidebar { } /// Scroll the selected target into view. - pub(super) fn vim_reveal(&self) { + pub(super) fn vim_reveal(&mut self, cx: &mut Context) { if let Some(row) = self .vim_selected .as_ref() .and_then(|target| self.vim_target_row(target)) { - self.list.scroll_to_reveal_item(row); + self.reveal_row(row, cx); } } @@ -2212,7 +2436,7 @@ impl Sidebar { Some(SidebarTarget::Archived) => self.set_archived_expanded(expanded, cx), _ => {} } - self.vim_reveal(); + self.vim_reveal(cx); } fn vim_reconcile(&mut self) { @@ -2754,7 +2978,17 @@ impl Sidebar { let activity = (!archived) .then(|| self.session_activity(&row.id)) .flatten(); + let card = self + .hovered + .as_ref() + .filter(|hovered| hovered.session == row.id) + .map(|hovered| { + // Another computer's folder cannot open on this one. + let local = self.backend.remote_host().is_none(); + task_card(row, &hovered.age, local, cx) + }); let session_id = Arc::clone(&row.id); + let hover_id = Arc::clone(&row.id); let step_id = Arc::clone(&row.id); let rename_id = Arc::clone(&row.id); let rung = TaskRung::of(task); @@ -2764,7 +2998,8 @@ impl Sidebar { let renaming = rename_field.is_some(); div() .relative() - .w_full() + .w_auto() + .mx_2() .id(row.element_id.clone()) .role(gpui::Role::ListBoxOption) .accessibility_id(row.id.to_string()) @@ -2776,9 +3011,9 @@ impl Sidebar { .flex() .items_center() .gap_1p5() - .when(archived, |row| row.pl_6()) - .when(!archived, |row| row.pl_8()) - .pr_6() + .when(archived, |row| row.pl_4()) + .when(!archived, |row| row.pl_6()) + .pr_4() .py_1() .rounded(theme::RADIUS_MD) .text_sm() @@ -2796,6 +3031,18 @@ impl Sidebar { .cursor_pointer() }) }) + .on_mouse_move({ + let hover_id = Arc::clone(&hover_id); + cx.listener(move |this, _event, _window, cx| this.claim_hover(&hover_id, cx)) + }) + .on_hover({ + let hover_id = Arc::clone(&hover_id); + cx.listener(move |this, hovered: &bool, _window, cx| { + if !*hovered { + this.release_hover(&hover_id, cx); + } + }) + }) .on_click(cx.listener(move |_this, _event, _window, cx| { cx.emit(SidebarEvent::Select(session_id.to_string())); })) @@ -2845,6 +3092,12 @@ impl Sidebar { .cursor_pointer() }) .tooltip(widgets::tooltip("Unpin", None)) + .on_hover({ + let hover_id = Arc::clone(&hover_id); + cx.listener(move |this, on: &bool, _window, cx| { + this.set_on_row_actions(&hover_id, *on, cx); + }) + }) .on_click(cx.listener(move |this, _event, _window, cx| { cx.stop_propagation(); this.toggle_task_pin(&pin_id, cx); @@ -2855,6 +3108,7 @@ impl Sidebar { .when(!renaming, |el| { el.child( div() + .id(row.actions_id.clone()) .absolute() .top_0() .bottom_0() @@ -2866,10 +3120,13 @@ impl Sidebar { .items_center() .gap_1p5() .pl_2() - .pr_6() + .pr_4() .group_hover(row.group.clone(), |style| { style.bg(gpui::rgb(theme::bg_sidebar_row_hover())) }) + .on_hover(cx.listener(move |this, on: &bool, _window, cx| { + this.set_on_row_actions(&hover_id, *on, cx); + })) .child(self.task_menu_trigger(row, task, window, cx)) .child(row_action( row.rename_id.clone(), @@ -2893,6 +3150,7 @@ impl Sidebar { )), ) }) + .children(card) } /// The collapse button in the top row. @@ -3108,6 +3366,151 @@ fn activity_indicator(spinner_id: &SharedString, activity: SessionActivity) -> A } } +/// Width of a task row's hover card. +const CARD_WIDTH: Pixels = px(300.); + +/// The current time in Unix milliseconds, as session records stamp it. +fn now_ms() -> i64 { + chrono::Utc::now().timestamp_millis() +} + +/// Time since `then_ms`, short: "now", "5m", "3h", "2d", "3w", "1y". +/// Empty when the time is unknown. +fn age_label(now_ms: i64, then_ms: i64) -> SharedString { + const MINUTE: i64 = 60_000; + const HOUR: i64 = 60 * MINUTE; + const DAY: i64 = 24 * HOUR; + const WEEK: i64 = 7 * DAY; + const YEAR: i64 = 365 * DAY; + if then_ms <= 0 { + return SharedString::default(); + } + let age = now_ms - then_ms; + if age < MINUTE { + return "now".into(); + } + let (count, unit) = if age < HOUR { + (age / MINUTE, 'm') + } else if age < DAY { + (age / HOUR, 'h') + } else if age < WEEK { + (age / DAY, 'd') + } else if age < YEAR { + (age / WEEK, 'w') + } else { + (age / YEAR, 'y') + }; + format!("{count}{unit}").into() +} + +/// The task's title, age, project, and folder in a panel beside its row. +fn task_card( + row: &SidebarRow, + age: &SharedString, + can_open: bool, + cx: &mut Context, +) -> AnyElement { + let path = row.path.clone(); + let folder = div() + .id(row.card_folder_id.clone()) + .debug_selector(|| row.card_folder_id.to_string()) + .flex() + .items_start() + .gap_2() + .px_2() + .py_1p5() + .rounded(theme::RADIUS_SM) + .child(div().flex_none().h_5().flex().items_center().child(icon( + "folder", + px(13.), + theme::text_muted(), + ))) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .child( + div() + .line_clamp(1) + .text_ellipsis() + .text_color(gpui::rgb(theme::text_secondary())) + .child(row.project_name.clone()), + ) + .child( + div() + .font_family(crate::assets::FONT_MONO) + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child(row.path.clone()), + ), + ) + .when(can_open, |folder| { + folder + .role(gpui::Role::Button) + .aria_label("Open folder") + .cursor_pointer() + .hover(|style| style.bg(gpui::rgb(theme::bg_sidebar_row_hover()))) + .on_click(cx.listener(move |this, _event, _window, cx| { + cx.stop_propagation(); + this.open_folder(&path, cx); + })) + }); + let card = div() + .id(row.card_id.clone()) + .debug_selector(|| row.card_id.to_string()) + .occlude() + .role(gpui::Role::Group) + .aria_label(row.title.clone()) + .w(CARD_WIDTH) + .p_1p5() + .rounded(theme::RADIUS_MD) + .bg(gpui::rgb(theme::bg_elevated())) + .border_1() + .border_color(gpui::rgb(theme::border())) + .shadow_md() + .flex() + .flex_col() + .gap_0p5() + .text_sm() + // A selected row's title weight would carry over to the card. + .font_weight(gpui::FontWeight::NORMAL) + .text_color(gpui::rgb(theme::text_primary())) + .on_hover(cx.listener(|this, on: &bool, _window, cx| this.set_on_card(*on, cx))) + .child( + div() + .flex() + .items_start() + .gap_3() + .px_2() + .pt_1p5() + .pb_1() + .child( + div() + .flex_1() + .min_w_0() + .line_clamp(3) + .text_ellipsis() + .child(row.title.clone()), + ) + // The title's first line is 20 px tall: centered in one, + // the age sits level with it. + .child( + div() + .flex_none() + .h_5() + .flex() + .items_center() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child(age.clone()), + ), + ) + .child(folder); + popup::hover_panel(card, Placement::Beside) +} + /// Small icon button that shows only while the pointer is over its row. pub(super) fn row_action( id: SharedString, diff --git a/apps/maple-agent/app/src/ui/chat/sidebar_paging.rs b/apps/maple-agent/app/src/ui/chat/sidebar_paging.rs index 768b1e73b..d2fff1602 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_paging.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_paging.rs @@ -94,6 +94,46 @@ mod tests { assert!(published[0].is_empty()); } + #[gpui::test] + fn paged_hover_tracks_activity_and_ends_when_a_live_task_moves_it( + cx: &mut gpui::TestAppContext, + ) { + let _settings = crate::settings::SETTINGS_IO_LOCK.lock(); + cx.executor().allow_parking(); + let backend = + Arc::new(AgentBackend::for_test("http://127.0.0.1:9".into(), String::new()).unwrap()); + let chat = cx.new(|cx| ChatScreen::new_without_start(backend, "user".into(), cx)); + let sidebar = chat.read_with(cx, |chat, _| chat.sidebar.clone()); + sidebar.update(cx, |sidebar, cx| { + sidebar.reset_persisted_for_test(); + sidebar.pages = SidebarPages { + enabled: true, + ..SidebarPages::default() + }; + let now = now_ms(); + let mut first = page_summary("first"); + first.updated_ms = now - 60_000; + sidebar.apply_task_page(PageKind::Active, vec![first], false); + sidebar.rebuild_sections(); + sidebar.claim_hover(&Arc::from("first"), cx); + assert_eq!(sidebar.card_age(), Some("1m")); + + let mut latest = summary("first", AgentTaskState::Active); + latest.updated_ms = now; + sidebar.apply_live_summary(latest, cx); + assert_eq!(sidebar.card_age(), Some("now")); + + sidebar.apply_task_page(PageKind::Active, vec![page_summary("older")], true); + sidebar.rebuild_sections(); + assert_eq!(sidebar.hovered(), Some("first")); + + let mut newer = summary("newer", AgentTaskState::Active); + newer.updated_ms = now + 1; + sidebar.apply_live_summary(newer, cx); + assert_eq!(sidebar.hovered(), None); + }); + } + #[gpui::test] fn live_task_insert_preserves_the_loaded_cursor_boundary(cx: &mut gpui::TestAppContext) { let _settings = crate::settings::SETTINGS_IO_LOCK.lock(); diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 1dd1eaf02..eb7755f95 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -16,6 +16,12 @@ mod state_tests { RecentProjectRoot, }; + /// A point on a task row's title, left of its buttons (given by the + /// row's menu button), where the pointer shows the row's hover card. + fn on_title(menu_button: gpui::Bounds) -> gpui::Point { + gpui::point(menu_button.left() - px(120.), menu_button.center().y) + } + fn summary(id: &str, title: &str) -> AgentSessionSummary { summary_at(id, title, "/tmp/proj") } @@ -5959,6 +5965,293 @@ mod state_tests { ); } + fn hovered( + sidebar: &Entity, + cx: &mut gpui::VisualTestContext, + ) -> Option { + sidebar.update(cx, |sidebar, _| sidebar.hovered().map(str::to_string)) + } + + fn advance_hover_clock(cx: &mut gpui::VisualTestContext, ms: u64) { + cx.executor() + .advance_clock(std::time::Duration::from_millis(ms)); + cx.run_until_parked(); + } + + #[gpui::test] + fn test_hover_card_follows_the_pointer_and_closes_after_leaving(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![summary("s1", "One"), summary("s2", "Two")]; + this.sync_sidebar(cx); + }); + let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); + let first = on_title(cx.debug_bounds("menu-session-s1").expect("s1")); + let second = on_title(cx.debug_bounds("menu-session-s2").expect("s2")); + let away = gpui::point(px(1100.), px(700.)); + cx.simulate_mouse_move(first, None, Default::default()); + assert!(cx.debug_bounds("card-session-s1").is_some()); + cx.simulate_mouse_move(away, None, Default::default()); + advance_hover_clock(cx, 100); + cx.simulate_mouse_move(second, None, Default::default()); + advance_hover_clock(cx, 250); + assert_eq!(hovered(&sidebar, cx).as_deref(), Some("s2")); + assert!(cx.debug_bounds("card-session-s1").is_none()); + + let card = cx.debug_bounds("card-session-s2").expect("s2's card"); + cx.simulate_mouse_move( + gpui::point(card.left() - px(2.), card.top() + px(10.)), + None, + Default::default(), + ); + advance_hover_clock(cx, 100); + cx.simulate_mouse_move(card.center(), None, Default::default()); + advance_hover_clock(cx, 1_000); + assert_eq!(hovered(&sidebar, cx).as_deref(), Some("s2")); + for at in [second, card.center()] { + cx.simulate_mouse_move(at, None, Default::default()); + advance_hover_clock(cx, 250); + assert_eq!(hovered(&sidebar, cx).as_deref(), Some("s2")); + assert!(cx.debug_bounds("card-session-s2").is_some()); + } + cx.simulate_mouse_move(away, None, Default::default()); + advance_hover_clock(cx, 100); + assert_eq!(hovered(&sidebar, cx).as_deref(), Some("s2")); + advance_hover_clock(cx, 150); + assert_eq!(hovered(&sidebar, cx), None); + assert!(cx.debug_bounds("card-session-s2").is_none()); + } + + #[gpui::test] + fn test_hover_card_opens_the_folder_from_an_inactive_window(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![summary("s1", "One"), summary_at("s2", "Two", "/tmp/other")]; + this.sync_sidebar(cx); + }); + let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); + let at = on_title(cx.debug_bounds("menu-session-s2").expect("the row")); + cx.simulate_mouse_move(at, None, Default::default()); + press(cx, "card-folder-s2"); + assert_eq!( + sidebar.update(cx, |sidebar, _| sidebar.folders_opened().to_vec()), + vec!["/tmp/other".to_string()] + ); + assert_eq!( + chat.update(cx, |this, _| this.selected_session.clone()) + .as_deref(), + Some("s1") + ); + cx.deactivate_window(); + cx.run_until_parked(); + assert_eq!(hovered(&sidebar, cx), None); + assert!(cx.debug_bounds("card-session-s2").is_none()); + cx.update(|window, _| window.activate_window()); + cx.run_until_parked(); + assert_eq!(hovered(&sidebar, cx), None); + + cx.deactivate_window(); + cx.run_until_parked(); + cx.simulate_mouse_move(at + gpui::point(px(1.), px(0.)), None, Default::default()); + let folder = cx.debug_bounds("card-folder-s2").expect("the folder line"); + cx.simulate_mouse_move(folder.center(), None, Default::default()); + cx.simulate_event(gpui::MouseDownEvent { + button: gpui::MouseButton::Left, + position: folder.center(), + click_count: 1, + first_mouse: true, + ..Default::default() + }); + cx.simulate_event(gpui::MouseUpEvent { + button: gpui::MouseButton::Left, + position: folder.center(), + click_count: 1, + ..Default::default() + }); + cx.run_until_parked(); + assert_eq!( + sidebar.update(cx, |sidebar, _| sidebar.folders_opened().to_vec()), + vec!["/tmp/other".to_string(); 2] + ); + assert_eq!( + chat.update(cx, |this, _| this.selected_session.clone()) + .as_deref(), + Some("s1"), + "the click opens the folder, not the task" + ); + } + + #[gpui::test] + fn test_hover_card_wraps_long_local_and_remote_paths(cx: &mut TestAppContext) { + let component = "a".repeat(200); + let (_chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![ + summary_at("s0", "Task", &format!("/tmp/{component}/nested/project")), + summary_at( + "s1", + "Task", + &format!(r"C:\Users\Alex\{component}\nested\project"), + ), + ]; + this.sync_sidebar(cx); + }); + for (row_id, folder_id) in [ + ("menu-session-s0", "card-folder-s0"), + ("menu-session-s1", "card-folder-s1"), + ] { + let row = cx.debug_bounds(row_id).expect("row"); + cx.simulate_mouse_move(on_title(row), None, Default::default()); + assert!( + cx.debug_bounds(folder_id).expect("folder").size.height > px(100.), + "long components wrap past the old two-line clamp" + ); + } + } + + #[gpui::test] + fn test_hover_card_gives_way_to_buttons_menus_and_rename(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![summary("s1", "One")]; + this.sync_sidebar(cx); + }); + let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); + let at = on_title(cx.debug_bounds("menu-session-s1").expect("the row")); + let nudge = |cx: &mut gpui::VisualTestContext| { + cx.simulate_mouse_move(at + gpui::point(px(1.), px(0.)), None, Default::default()); + }; + nudge(cx); + let rename = cx.debug_bounds("rename-session-s1").expect("rename"); + cx.simulate_mouse_move(rename.center(), None, Default::default()); + advance_hover_clock(cx, 250); + assert_eq!(hovered(&sidebar, cx), None); + nudge(cx); + assert_eq!(hovered(&sidebar, cx).as_deref(), Some("s1")); + + sidebar.update(cx, |sidebar, cx| sidebar.open_task_menu_for_test("s1", cx)); + cx.run_until_parked(); + nudge(cx); + assert_eq!(hovered(&sidebar, cx), None, "menus suppress the card"); + sidebar.update(cx, |sidebar, cx| { + sidebar.close_popups(cx); + sidebar.begin_rename(RenameTarget::Task("s1".to_string()), cx); + }); + cx.run_until_parked(); + nudge(cx); + assert_eq!(hovered(&sidebar, cx), None, "rename suppresses the card"); + sidebar.update(cx, |sidebar, cx| sidebar.cancel_rename(cx)); + cx.run_until_parked(); + nudge(cx); + assert_eq!(hovered(&sidebar, cx).as_deref(), Some("s1")); + } + + #[gpui::test] + fn test_hover_card_refreshes_only_while_visible(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![summary("s1", "One")]; + this.sync_sidebar(cx); + }); + let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); + let notified = std::rc::Rc::new(std::cell::Cell::new(0)); + cx.update(|_, cx| { + let notified = notified.clone(); + cx.observe(&sidebar, move |_, _| notified.set(notified.get() + 1)) + .detach(); + }); + let at = on_title(cx.debug_bounds("menu-session-s1").expect("the row")); + cx.simulate_mouse_move(at, None, Default::default()); + let before = notified.get(); + advance_hover_clock(cx, 60_000); + assert_eq!(notified.get(), before, "unchanged age does not repaint"); + let age = sidebar.update(cx, |sidebar, _| sidebar.card_age().unwrap().to_string()); + sidebar.update(cx, |sidebar, _| sidebar.set_updated_ms_for_test("s1", 1)); + advance_hover_clock(cx, 60_000); + assert!( + notified.get() > before, + "the timer refreshes changed activity" + ); + assert_ne!( + sidebar.update(cx, |sidebar, _| sidebar.card_age().unwrap().to_string()), + age, + "the timer updates the card's age" + ); + sidebar.update(cx, |sidebar, cx| sidebar.end_hover(cx)); + cx.run_until_parked(); + let before = notified.get(); + sidebar.update(cx, |sidebar, _| sidebar.set_updated_ms_for_test("s1", 0)); + advance_hover_clock(cx, 120_000); + assert_eq!(notified.get(), before, "ending hover cancels its timer"); + } + + #[gpui::test] + fn test_hover_card_ends_when_rows_move_or_keyboard_navigation_scrolls(cx: &mut TestAppContext) { + let now = chrono::Utc::now().timestamp_millis(); + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = (0..40) + .map(|ix| { + let mut task = summary(&format!("s{ix}"), &format!("Task {ix}")); + task.updated_ms = now - (ix + 1) * 60_000; + task + }) + .collect(); + this.selected_session = Some("s38".to_string()); + this.sync_sidebar(cx); + }); + let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); + let at = on_title(cx.debug_bounds("menu-session-s0").expect("the first row")); + let nudge = |cx: &mut gpui::VisualTestContext, dx: f32| { + cx.simulate_mouse_move(at + gpui::point(px(dx), px(0.)), None, Default::default()); + }; + nudge(cx, 0.); + chat.update(cx, |this, cx| { + this.sessions[1].updated_ms = now; + this.sync_sidebar(cx); + }); + cx.run_until_parked(); + assert_eq!(hovered(&sidebar, cx), None); + nudge(cx, 1.); + assert_eq!(hovered(&sidebar, cx).as_deref(), Some("s1")); + chat.update(cx, |this, cx| { + this.sessions.retain(|task| task.id != "s1"); + this.sync_sidebar(cx); + }); + cx.run_until_parked(); + assert_eq!(hovered(&sidebar, cx), None); + assert!(cx.debug_bounds("card-session-s1").is_none()); + nudge(cx, 0.); + assert_eq!(hovered(&sidebar, cx).as_deref(), Some("s0")); + cx.simulate_event(gpui::ScrollWheelEvent { + position: at, + delta: gpui::ScrollDelta::Pixels(gpui::point(px(0.), px(-600.))), + ..Default::default() + }); + cx.run_until_parked(); + assert_eq!(hovered(&sidebar, cx), None); + nudge(cx, 0.); + assert!(hovered(&sidebar, cx).is_some()); + chat.update(cx, |this, cx| this.step_task(1, cx)); + cx.run_until_parked(); + assert_eq!(hovered(&sidebar, cx), None); + assert!(sidebar.update(cx, |sidebar, _| sidebar.list().logical_scroll_top().item_ix) > 0); + } + + #[gpui::test] + fn test_hover_card_ends_when_the_sidebar_collapses(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![summary("s1", "One")]; + this.sync_sidebar(cx); + }); + let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); + let at = on_title(cx.debug_bounds("menu-session-s1").expect("the row")); + cx.simulate_mouse_move(at, None, Default::default()); + assert_eq!(hovered(&sidebar, cx).as_deref(), Some("s1")); + chat.update(cx, |this, cx| this.toggle_sidebar_visibility(cx)); + cx.run_until_parked(); + assert_eq!(hovered(&sidebar, cx), None); + chat.update(cx, |this, cx| this.toggle_sidebar_visibility(cx)); + cx.run_until_parked(); + assert!(cx.debug_bounds("card-session-s1").is_none()); + cx.simulate_mouse_move(at + gpui::point(px(1.), px(0.)), None, Default::default()); + assert!(cx.debug_bounds("card-session-s1").is_some()); + } + /// The header's project menu closes on a press outside it, like every /// other menu. It used to stay open. #[gpui::test] diff --git a/apps/maple-agent/app/src/ui/popup.rs b/apps/maple-agent/app/src/ui/popup.rs index fb69e291f..098ef1317 100644 --- a/apps/maple-agent/app/src/ui/popup.rs +++ b/apps/maple-agent/app/src/ui/popup.rs @@ -31,6 +31,9 @@ //! - Assistive technology sees a button that reports whether its popup is //! expanded, a named menu of named items, and the highlighted item as the //! focused one. +//! +//! [`hover_panel`] shares menu placement and occlusion without taking focus. +//! Its owner manages the hover lifecycle. use std::cell::Cell; use std::rc::Rc; @@ -67,6 +70,8 @@ gpui::actions!( /// Space kept between a trigger and the menu it opens. const GAP: Pixels = px(4.); +/// Space kept between a task row and its hover card. +const HOVER_GAP: Pixels = px(6.); /// Closest a menu comes to the window edge. const WINDOW_MARGIN: Pixels = px(8.); @@ -597,6 +602,7 @@ impl Popup { Float { panel: Some(motion::fade_in(panel, "popup-reveal").into_any_element()), placement, + gap: GAP, priority, } .into_any_element() @@ -642,6 +648,8 @@ pub(crate) enum Placement { AboveStart, /// At a point in window coordinates, for a right-click. At(Point), + /// Right of the trigger, sharing its top edge; flips left when needed. + Beside, } impl Placement { @@ -655,12 +663,14 @@ impl Placement { size: Size, window: Size, margin: Pixels, + gap: Pixels, ) -> Point { let (anchor, prefer_below, prefer_start, gap) = match self { - Placement::BelowStart => (trigger, true, true, GAP), - Placement::BelowEnd => (trigger, true, false, GAP), - Placement::AboveStart => (trigger, false, true, GAP), + Placement::BelowStart => (trigger, true, true, gap), + Placement::BelowEnd => (trigger, true, false, gap), + Placement::AboveStart => (trigger, false, true, gap), Placement::At(position) => (Bounds::new(position, Size::default()), true, true, px(0.)), + Placement::Beside => return beside(trigger, size, window, margin, gap), }; let room_below = window.height - margin - anchor.bottom() - gap; let room_above = anchor.top() - gap - margin; @@ -689,6 +699,29 @@ impl Placement { } } +/// Top-aligned beside the trigger, flipping left and clamping when needed. +fn beside( + trigger: Bounds, + size: Size, + window: Size, + margin: Pixels, + gap: Pixels, +) -> Point { + let room_right = window.width - margin - trigger.right() - gap; + let room_left = trigger.left() - gap - margin; + let x = if first_side(true, size.width, room_right, room_left) { + trigger.right() + gap + } else { + trigger.left() - gap - size.width + }; + let x = x.min(window.width - margin - size.width).max(margin); + let y = trigger + .top() + .min(window.height - margin - size.height) + .max(margin); + point(x.round(), y.round()) +} + /// Whether a panel `length` long goes on the first of two sides, which /// leave `first` and `second` room: the preferred side when it fits there, /// the other when only that one fits, and the roomier one when neither @@ -703,6 +736,17 @@ fn first_side(prefer_first: bool, length: Pixels, first: Pixels, second: Pixels) stays == prefer_first } +/// Place an occluding panel inside a `relative` row without taking focus. +pub(crate) fn hover_panel(panel: impl IntoElement, placement: Placement) -> AnyElement { + Float { + panel: Some(panel.into_any_element()), + placement, + gap: HOVER_GAP, + priority: 1, + } + .into_any_element() +} + /// Draws a menu panel above everything, where [`Placement::origin`] puts /// it. Beside a trigger, this element covers the `relative` box around the /// trigger and takes that box's bounds. The panel is measured before it is @@ -710,6 +754,7 @@ fn first_side(prefer_first: bool, length: Pixels, first: Pixels, second: Pixels) struct Float { panel: Option, placement: Placement, + gap: Pixels, priority: usize, } @@ -766,7 +811,7 @@ impl Element for Float { let margin = window_margin(window); let origin = self .placement - .origin(bounds, size, window.viewport_size(), margin); + .origin(bounds, size, window.viewport_size(), margin, self.gap); window.defer_draw(panel, origin, self.priority, None); } @@ -1629,8 +1674,9 @@ mod tests { let window = size(px(800.), px(600.)); let panel = size(px(200.), px(300.)); let trigger = |x: f32, y: f32| Bounds::new(point(px(x), px(y)), size(px(80.), px(20.))); - let origin = - |placement: Placement, trigger| placement.origin(trigger, panel, window, WINDOW_MARGIN); + let origin = |placement: Placement, trigger| { + placement.origin(trigger, panel, window, WINDOW_MARGIN, GAP) + }; // Room below: below, 4 px down, left edges shared. assert_eq!( origin(Placement::BelowStart, trigger(100., 100.)), @@ -1657,7 +1703,8 @@ mod tests { trigger(100., 250.), size(px(200.), px(560.)), window, - WINDOW_MARGIN + WINDOW_MARGIN, + GAP ), point(px(100.), px(32.)) );