diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs index b2d7d91a2..ada46dcbc 100644 --- a/src/client/shell/mouse.rs +++ b/src/client/shell/mouse.rs @@ -676,6 +676,10 @@ impl ClientShellState { } } + /// Routes mouse input through overlays, shell controls, and pane interactions. + /// + /// Hit-test order determines which overlapping control receives the event; + /// the sidebar toggle takes precedence over the agent scrollbar beneath it. pub(super) fn handle_mouse(&mut self, mouse: MouseEvent, outcome: &mut ClientShellInput) { self.update_link_hover(mouse, outcome); let point = (mouse.column, mouse.row); @@ -1935,9 +1939,17 @@ impl ClientShellState { self.workspace_press = None; self.tab_press = None; self.chrome_drag = None; - if super::contains(self.hits.sidebar_divider, point) - && !super::contains(self.hits.sidebar_toggle, point) - { + // The toggle is painted over the agent scrollbar's last cell. + if super::contains(self.hits.sidebar_toggle, point) { + self.sidebar_collapsed = !self.sidebar_collapsed; + self.sidebar_collapsed_manual = true; + self.invalidate_pane_surface(); + outcome.repaint = true; + outcome.resize = true; + self.persist_chrome_preferences(outcome); + return; + } + if super::contains(self.hits.sidebar_divider, point) { let now = std::time::Instant::now(); let double_click = self.last_sidebar_divider_click.is_some_and(|last| { now.duration_since(last) <= std::time::Duration::from_millis(350) @@ -2075,15 +2087,6 @@ impl ClientShellState { outcome.repaint = true; return; } - if super::contains(self.hits.sidebar_toggle, point) { - self.sidebar_collapsed = !self.sidebar_collapsed; - self.sidebar_collapsed_manual = true; - self.invalidate_pane_surface(); - outcome.repaint = true; - outcome.resize = true; - self.persist_chrome_preferences(outcome); - return; - } let group_toggle = self.hits.workspaces.iter().find_map(|hit| { let (rect, key) = hit.group_toggle.as_ref()?; super::contains(*rect, point).then(|| (hit.endpoint_id.clone(), key.clone())) diff --git a/src/client/shell/tests/endpoints.rs b/src/client/shell/tests/endpoints.rs index 9cc70a900..03b95b97e 100644 --- a/src/client/shell/tests/endpoints.rs +++ b/src/client/shell/tests/endpoints.rs @@ -247,6 +247,54 @@ fn state_with_scrollable_agents() -> (ClientShellState, ClientEndpointId) { (state, remote) } +/// Checks that the visible toggle wins overlapping scrollbar clicks and can reopen +/// the sidebar, for both endpoint layouts and both ends of the overflowing list. +#[test] +fn sidebar_toggle_remains_clickable_with_overflowing_agents() { + for saved_machine in [false, true] { + for scroll_to_bottom in [false, true] { + let (mut state, _) = state_with_scrollable_agents(); + if !saved_machine { + state.set_endpoint_catalog(&[]); + } + state.agent_scroll = if scroll_to_bottom { usize::MAX } else { 0 }; + let frame = state.compose(100, 28).expect("overflowing agent panel"); + assert!(!state.hits.agent_scrollbar.is_empty()); + let scroll = state.agent_scroll; + + let toggle = state.hits.sidebar_toggle; + let buffer = frame.to_ratatui_buffer().expect("sidebar buffer"); + assert_eq!(buffer[(toggle.x, toggle.y)].symbol(), "«"); + let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: toggle.x, + row: toggle.y, + modifiers: KeyModifiers::NONE, + })]); + assert!( + state.sidebar_collapsed, + "collapse with saved_machine={saved_machine}, scroll_to_bottom={scroll_to_bottom}" + ); + assert!(state.sidebar_collapsed_manual); + assert!(outcome.repaint && outcome.resize); + assert_eq!(state.agent_scroll, scroll); + assert!(state.chrome_drag.is_none()); + + state.compose(100, 28).expect("collapsed sidebar"); + let toggle = state.hits.sidebar_toggle; + let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: toggle.x, + row: toggle.y, + modifiers: KeyModifiers::NONE, + })]); + assert!(!state.sidebar_collapsed); + assert!(outcome.repaint && outcome.resize); + assert!(state.chrome_drag.is_none()); + } + } +} + #[test] fn agent_navigation_reveals_offscreen_targets() { use crate::input::KeybindAction;