From a29d65f2de9607ef6aae1e948f98a44b5c0e6cbf Mon Sep 17 00:00:00 2001 From: Michael Atwood <70112949+doowta@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:30:16 -0500 Subject: [PATCH 1/3] Agent: one menu for every task row action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task rows showed three hover buttons: the menu, Rename, and a step button whose meaning changed by rung (Settle, Archive, Delete), so rows were crowded and the right-most button did something different in each section. There was no right-click menu, and with a task menu open the hover highlight and buttons stayed on another row or followed the pointer as the list scrolled. A row now shows only its "…" button on hover; a pinned row keeps its pin. A right-click opens the same menu at the pointer. The menu groups Rename, Pin, and Mark as unread; Open folder (this computer's tasks only); then Settle, Archive, and Delete, with Reopen and Unarchive in their places on settled and archived tasks. Each item shows a letter that picks it while the menu is open, and Delete shows in the error color; popup items gain a shortcut and a destructive style. Mark as unread sets the completed-unread marker through a sidebar event, and Mark as read clears it as opening the task does. While any sidebar menu is open, rows show no hover state, and scrolling the list, by the wheel or by stepping to another task, closes the menu. Co-Authored-By: Claude Opus 5.5 --- apps/maple-agent/app/assets/icons/eye.svg | 1 + apps/maple-agent/app/src/assets.rs | 1 + apps/maple-agent/app/src/ui/chat/mod.rs | 21 +- apps/maple-agent/app/src/ui/chat/sidebar.rs | 438 ++++++++++-------- .../app/src/ui/chat/sidebar_computers.rs | 8 +- apps/maple-agent/app/src/ui/chat/tests.rs | 285 ++++++++++-- apps/maple-agent/app/src/ui/popup.rs | 61 ++- 7 files changed, 548 insertions(+), 267 deletions(-) create mode 100644 apps/maple-agent/app/assets/icons/eye.svg diff --git a/apps/maple-agent/app/assets/icons/eye.svg b/apps/maple-agent/app/assets/icons/eye.svg new file mode 100644 index 000000000..8944504b6 --- /dev/null +++ b/apps/maple-agent/app/assets/icons/eye.svg @@ -0,0 +1 @@ + diff --git a/apps/maple-agent/app/src/assets.rs b/apps/maple-agent/app/src/assets.rs index 0885906a8..dc1000137 100644 --- a/apps/maple-agent/app/src/assets.rs +++ b/apps/maple-agent/app/src/assets.rs @@ -29,6 +29,7 @@ assets!( "icons/cua-mark-black.svg", "icons/cua-mark-white.svg", "icons/ellipsis.svg", + "icons/eye.svg", "icons/folder-open.svg", "icons/folder-plus.svg", "icons/folder.svg", diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index a5331a4e9..f1b3db22c 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -887,6 +887,9 @@ impl ChatScreen { self.sessions = self.sidebar.read(cx).navigation_sessions(); self.request_delete_task(&id, cx); } + SidebarEvent::SetUnread { session_id, unread } => { + self.set_unread(&session_id, unread, cx) + } SidebarEvent::RemoveRoot(root) => self.request_remove_root(&root, cx), SidebarEvent::SetTrust { path, trusted } => self.set_project_trust(path, trusted, cx), SidebarEvent::ChooseProject => self.choose_root_dialog(cx), @@ -916,6 +919,18 @@ impl ChatScreen { cx.emit(NavigationChanged); } + /// The task menu reads or sets the marker a completed run leaves. + fn set_unread(&mut self, session_id: &str, unread: bool, cx: &mut Context) { + let changed = if unread { + self.completed_unread_sessions.insert(session_id.to_string()) + } else { + self.completed_unread_sessions.remove(session_id) + }; + if changed { + self.sync_sidebar(cx); + } + } + fn sync_sidebar_selection(&mut self, cx: &mut Context) { let selected = self.selected_session.clone(); self.sidebar @@ -4017,7 +4032,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); @@ -6220,7 +6236,8 @@ impl ChatScreen { self.active_runs.remove(session_id); // The marker follows the latest terminal: only a // successful completion off screen sets it. Opening the - // task (`set_active_session`) is the only other writer. + // task (`set_active_session`) and the task menu (`set_unread`) + // are the only other writers. self.completed_unread_sessions.remove(session_id); if matches!(terminal, maple_agent::agent::AgentRunTerminal::Completed) && !(self.visible && self.is_selected(session_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..2f37d55af 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -128,6 +128,12 @@ pub(super) enum SidebarEvent { }, /// Delete a task for good; the screen confirms first. DeleteTask(String), + /// Set or clear a task's unread marker, as if a run had just ended + /// unseen or the task had been opened. + SetUnread { + session_id: String, + unread: bool, + }, /// Remove a project; the screen confirms first. RemoveRoot(String), /// Trust or untrust a project. @@ -160,12 +166,8 @@ pub(super) enum RenameTarget { pub(super) struct SidebarRow { pub(super) id: Arc, pub(super) element_id: SharedString, - /// Hover group that reveals the row's action buttons. + /// Hover group that reveals the row's menu button. pub(super) group: SharedString, - pub(super) rename_id: SharedString, - /// The hover button that moves the task one rung along - /// active, settled, archived, deleted. - pub(super) step_id: SharedString, /// Animation id of the running indicator. pub(super) spinner_id: SharedString, pub(super) pin_id: SharedString, @@ -174,10 +176,14 @@ pub(super) struct SidebarRow { pub(super) menu_panel_id: SharedString, pub(super) menu_rename_id: SharedString, pub(super) menu_pin_id: SharedString, + pub(super) menu_unread_id: SharedString, + pub(super) menu_folder_id: SharedString, pub(super) menu_settle_id: SharedString, pub(super) menu_archive_id: SharedString, pub(super) menu_delete_id: SharedString, pub(super) menu_reopen_id: SharedString, + menu_actions: Vec, + project_root: Arc, pub(super) title: SharedString, /// Display name of the task's project, shown on every row. pub(super) project_name: SharedString, @@ -192,18 +198,20 @@ impl SidebarRow { id: Arc::from(id.as_str()), element_id: SharedString::from(format!("session-{id}")), group: SharedString::from(format!("task-row-{id}")), - rename_id: SharedString::from(format!("rename-session-{id}")), - step_id: SharedString::from(format!("step-session-{id}")), spinner_id: SharedString::from(format!("spinner-session-{id}")), pin_id: SharedString::from(format!("pin-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}")), menu_pin_id: SharedString::from(format!("pin-task-{id}")), + menu_unread_id: SharedString::from(format!("unread-task-{id}")), + menu_folder_id: SharedString::from(format!("open-folder-task-{id}")), menu_settle_id: SharedString::from(format!("settle-task-{id}")), menu_archive_id: SharedString::from(format!("archive-task-{id}")), menu_delete_id: SharedString::from(format!("delete-task-{id}")), menu_reopen_id: SharedString::from(format!("reopen-task-{id}")), + menu_actions: Vec::new(), + project_root: Arc::from(session.project_root.as_str()), title: SharedString::from(session.title.clone()), project_name: SharedString::from(project_name.to_string()), search: session.title.to_lowercase(), @@ -211,6 +219,16 @@ impl SidebarRow { } } +#[derive(Clone, Copy)] +enum TaskMenuAction { + Rename, + Pin, + Unread, + Folder, + Move(TaskMove), + Separator, +} + /// The switcher's "All projects" row. const ALL_PROJECTS_ID: &str = "switcher-all-projects"; @@ -219,7 +237,8 @@ const ALL_PROJECTS_ID: &str = "switcher-all-projects"; #[derive(Clone, Debug, PartialEq, Eq)] enum SidebarPopup { Switcher, - /// A task row's overflow menu, by the row's shared id. + /// A task row's menu, by the row's shared id. It opens under the row's + /// "…" button, or at the pointer on a right-click. Task(Arc), } @@ -255,9 +274,8 @@ pub(super) struct SidebarTaskEntry { } /// Where a task sits on the ladder active, settled, archived, deleted. -/// The hover button steps one rung forward; the overflow menu reaches -/// every later rung and comes back only to active. The pinned section -/// is a view of active tasks, not a rung of its own. +/// The task menu reaches every later rung and comes back only to active. +/// The pinned section is a view of active tasks, not a rung of its own. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum TaskRung { Active, @@ -265,7 +283,7 @@ pub(super) enum TaskRung { Archived, } -/// Where a menu item or the hover button sends a task. +/// Where a menu item sends a task. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum TaskMove { Active, @@ -274,13 +292,6 @@ pub(super) enum TaskMove { Deleted, } -/// The one-rung-forward move of a task: the hover button's icon and -/// short label. -pub(super) struct TaskStep { - pub(super) icon: &'static str, - pub(super) row_label: &'static str, -} - impl TaskRung { pub(super) fn of(task: SidebarTaskEntry) -> Self { if task.archived { @@ -291,32 +302,6 @@ impl TaskRung { Self::Active } } - - /// The rung after this one. - pub(super) fn next(self) -> TaskMove { - match self { - Self::Active => TaskMove::Settled, - Self::Settled => TaskMove::Archived, - Self::Archived => TaskMove::Deleted, - } - } - - pub(super) fn step(self) -> TaskStep { - match self { - Self::Active => TaskStep { - icon: "check", - row_label: "Settle", - }, - Self::Settled => TaskStep { - icon: "archive", - row_label: "Archive", - }, - Self::Archived => TaskStep { - icon: "trash-2", - row_label: "Delete", - }, - } - } } /// Why the task list has no rows to show. @@ -357,12 +342,16 @@ impl SidebarSection { type MenuAction = Box)>; -/// One action of a sidebar overflow menu, as the task and project menus -/// list them. +/// One action of a sidebar menu, as the task and project menus list +/// them. struct SidebarMenuItem { id: SharedString, icon: &'static str, label: &'static str, + /// The letter that picks the item while its menu is open. + shortcut: Option<&'static str>, + /// Shown in the error color (Delete). + destructive: bool, on_click: MenuAction, } @@ -418,6 +407,9 @@ pub(super) struct Sidebar { project_filter: Option, // Menus. popup: Popup, + /// Where a right-click opened the task menu; `None` hangs it under the + /// row's "…" button. + task_menu_at: Option>, /// A project's overflow menu, keyed by execution computer and path. project_popup: Popup, /// Trust status of the project whose menu is open, once it loads. @@ -623,6 +615,17 @@ impl Sidebar { this.search_changed(&input, cx) }) .detach(); + let list = gpui::ListState::new(0, gpui::ListAlignment::Top, px(200.)); + // A menu hangs off its row: scrolling the list closes it, so it + // never drifts from its row or leaves hover state behind. + let sidebar = cx.weak_entity(); + list.set_scroll_handler(move |_, _, cx| { + sidebar + .update(cx, |sidebar, cx| { + sidebar.close_popups(cx); + }) + .ok(); + }); Self { pages: SidebarPages::default(), group_pages: ComputerPages::default(), @@ -640,7 +643,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(), @@ -656,6 +659,7 @@ impl Sidebar { scope_label: "All projects".into(), project_filter: None, popup: Popup::new(|this| &mut this.popup, cx), + task_menu_at: None, project_popup: Popup::new(|this| &mut this.project_popup, cx), menu_trust: None, filter: String::new(), @@ -983,7 +987,10 @@ impl Sidebar { Some(rows[target].clone()) } - pub(super) fn reveal_row(&self, row: usize) { + /// Scroll `row` into view. A menu hangs off its row, so moving the + /// list closes one that is open, as a wheel scroll does. + pub(super) fn reveal_row(&mut self, row: usize, cx: &mut Context) { + self.close_popups(cx); self.list.scroll_to_reveal_item(row); } @@ -1122,7 +1129,7 @@ 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); + self.open_task_menu(session_id.into(), None, cx); } #[cfg(test)] @@ -1376,6 +1383,34 @@ impl Sidebar { if self.computers.len() > 1 && self.project_filter.is_none() { entries = self.computer_entries(); } + for entry in &entries { + if let SidebarEntry::Task(task) = entry { + let rung = TaskRung::of(*task); + let mut actions = vec![TaskMenuAction::Rename]; + if rung == TaskRung::Active { + actions.push(TaskMenuAction::Pin); + } + if rung != TaskRung::Archived { + actions.push(TaskMenuAction::Unread); + } + if self.backend.remote_host().is_none() { + actions.extend([TaskMenuAction::Separator, TaskMenuAction::Folder]); + } + actions.extend([ + TaskMenuAction::Separator, + TaskMenuAction::Move(if rung == TaskRung::Active { + TaskMove::Settled + } else { + TaskMove::Active + }), + ]); + if rung != TaskRung::Archived { + actions.push(TaskMenuAction::Move(TaskMove::Archived)); + } + actions.push(TaskMenuAction::Move(TaskMove::Deleted)); + self.rows[task.session].menu_actions = actions; + } + } let old = std::mem::replace(&mut self.entries, entries); if self.application_vim_enabled { self.rebuild_vim_targets(); @@ -1435,6 +1470,7 @@ impl Sidebar { let fresh = self.rows.len() == self.sessions.len() && self.rows.iter().zip(&self.sessions).all(|(row, session)| { *row.id == *session.id + && *row.project_root == *session.project_root && row.title.as_ref() == session.title && self.root_name_matches(&session.project_root, &row.project_name) }); @@ -1479,6 +1515,11 @@ impl Sidebar { true } + /// Whether any of the sidebar's menus is open. + fn menu_open(&self) -> bool { + self.popup.open_key().is_some() || self.project_popup.open_key().is_some() + } + /// Close whichever popup menu is open. Reports whether one was. pub(super) fn close_popups(&mut self, cx: &mut Context) -> bool { let project = self.project_popup.close(cx); @@ -1854,104 +1895,91 @@ impl Sidebar { ); } - /// Open or close the overflow menu of one task row. + /// Open or close a task row's menu from its "…" button. fn toggle_task_menu(&mut self, session_id: Arc, cx: &mut Context) { + self.task_menu_at = None; self.popup.toggle(SidebarPopup::Task(session_id), cx); } - /// The overflow menu of a task row: rename, then where the task can - /// go. Active tasks pin, settle, archive, or delete; settled tasks - /// reopen, archive, or delete; archived tasks reopen or delete. - fn task_menu_items(&self, task: SidebarTaskEntry) -> Vec { - let Some(row) = self.rows.get(task.session) else { - return Vec::new(); + /// Open a task row's menu, at `at` where the row was right-clicked or + /// under its "…" button. + fn open_task_menu( + &mut self, + session_id: Arc, + at: Option>, + cx: &mut Context, + ) { + self.task_menu_at = at; + self.popup.open(SidebarPopup::Task(session_id), cx); + } + + /// Build an item from the cached action order, keeping stateful labels live. + fn task_menu_item( + &self, + task: SidebarTaskEntry, + action: TaskMenuAction, + ) -> Option { + let row = self.rows.get(task.session)?; + let mark_unread = !self.unread.contains(&*row.id); + let (id, icon, label, key) = match action { + TaskMenuAction::Rename => (&row.menu_rename_id, "pencil", "Rename", "R"), + TaskMenuAction::Pin => ( + &row.menu_pin_id, + "pin", + if task.pinned { "Unpin" } else { "Pin" }, + "P", + ), + TaskMenuAction::Unread => ( + &row.menu_unread_id, + "eye", + if mark_unread { + "Mark as unread" + } else { + "Mark as read" + }, + "U", + ), + TaskMenuAction::Folder => (&row.menu_folder_id, "folder-open", "Open folder", "O"), + TaskMenuAction::Move(to) => match to { + TaskMove::Settled => (&row.menu_settle_id, "check", "Settle", "S"), + TaskMove::Active if TaskRung::of(task) == TaskRung::Archived => { + (&row.menu_reopen_id, "archive-restore", "Unarchive", "A") + } + TaskMove::Active => (&row.menu_reopen_id, "undo-2", "Reopen", "S"), + TaskMove::Archived => (&row.menu_archive_id, "archive", "Archive", "A"), + TaskMove::Deleted => (&row.menu_delete_id, "trash-2", "Delete", "D"), + }, + TaskMenuAction::Separator => return None, }; - let rung = TaskRung::of(task); - let rename_id = row.id.to_string(); - let mut items = vec![SidebarMenuItem { - id: row.menu_rename_id.clone(), - icon: "pencil", - label: "Rename task", - on_click: Box::new(move |this, cx| { - this.begin_rename(RenameTarget::Task(rename_id.clone()), cx) + let session_id = row.id.clone(); + let on_click: MenuAction = match action { + TaskMenuAction::Rename => Box::new(move |this, cx| { + this.begin_rename(RenameTarget::Task(session_id.to_string()), cx) }), - }]; - if rung == TaskRung::Active { - let pinned_id = row.id.to_string(); - let pinned = task.pinned; - items.push(SidebarMenuItem { - id: row.menu_pin_id.clone(), - icon: "pin", - label: if pinned { "Unpin task" } else { "Pin task" }, - on_click: Box::new(move |this, cx| this.toggle_task_pin(&pinned_id, cx)), - }); - } - let moves: &[(TaskMove, &SharedString, &'static str, &'static str)] = match rung { - TaskRung::Active => &[ - ( - TaskMove::Settled, - &row.menu_settle_id, - "check", - "Settle task", - ), - ( - TaskMove::Archived, - &row.menu_archive_id, - "archive", - "Archive task", - ), - ( - TaskMove::Deleted, - &row.menu_delete_id, - "trash-2", - "Delete task", - ), - ], - TaskRung::Settled => &[ - ( - TaskMove::Active, - &row.menu_reopen_id, - "undo-2", - "Reopen task", - ), - ( - TaskMove::Archived, - &row.menu_archive_id, - "archive", - "Archive task", - ), - ( - TaskMove::Deleted, - &row.menu_delete_id, - "trash-2", - "Delete task", - ), - ], - TaskRung::Archived => &[ - ( - TaskMove::Active, - &row.menu_reopen_id, - "undo-2", - "Reopen task", - ), - ( - TaskMove::Deleted, - &row.menu_delete_id, - "trash-2", - "Delete task", - ), - ], + TaskMenuAction::Pin => Box::new(move |this, cx| this.toggle_task_pin(&session_id, cx)), + TaskMenuAction::Unread => Box::new(move |_, cx| { + cx.emit(SidebarEvent::SetUnread { + session_id: session_id.to_string(), + unread: mark_unread, + }); + }), + TaskMenuAction::Folder => { + let root = row.project_root.clone(); + Box::new(move |this, cx| this.open_folder(&root, cx)) + } + TaskMenuAction::Move(to) => { + Box::new(move |this, cx| this.move_task(&session_id, to, cx)) + } + TaskMenuAction::Separator => return None, }; - for &(to, id, icon, label) in moves { - let session_id = row.id.to_string(); - items.push(SidebarMenuItem { - id: id.clone(), - icon, - label, - on_click: Box::new(move |this, cx| this.move_task(&session_id, to, cx)), - }); - } - items + Some(SidebarMenuItem { + id: id.clone(), + icon, + label, + shortcut: Some(key), + destructive: matches!(action, TaskMenuAction::Move(TaskMove::Deleted)), + on_click, + }) } /// Send a task to a rung. The runtime owns the state, so every move @@ -1992,20 +2020,16 @@ impl Sidebar { pub(super) fn task_menu_labels_for_test(&self, session_id: &str) -> Vec<&'static str> { self.task_menu_entry(session_id) .map(|task| { - self.task_menu_items(task) - .into_iter() + self.rows[task.session] + .menu_actions + .iter() + .filter_map(|&action| self.task_menu_item(task, action)) .map(|item| item.label) .collect() }) .unwrap_or_default() } - #[cfg(test)] - pub(super) fn row_step_label_for_test(&self, session_id: &str) -> Option<&'static str> { - self.task_menu_entry(session_id) - .map(|task| TaskRung::of(task).step().row_label) - } - /// The overflow menu of one switcher project row. fn project_menu_items(&self, target: &ProjectTarget) -> Vec { let rename_target = target.clone(); @@ -2014,6 +2038,8 @@ impl Sidebar { id: target.element_id("rename-project"), icon: "pencil", label: "Rename project", + shortcut: None, + destructive: false, on_click: Box::new(move |this, cx| { this.begin_rename(RenameTarget::Project(rename_target.clone()), cx) }), @@ -2029,6 +2055,8 @@ impl Sidebar { id: target.element_id("open-project"), icon: "folder-open", label: "Open folder", + shortcut: None, + destructive: false, on_click: Box::new(move |this, cx| this.open_folder(&open_root, cx)), }); if let Some(status) = self @@ -2046,6 +2074,8 @@ impl Sidebar { } else { "Trust project" }, + shortcut: None, + destructive: false, on_click: Box::new(move |_, cx| { cx.emit(SidebarEvent::SetTrust { path: path.clone(), @@ -2058,6 +2088,8 @@ impl Sidebar { id: SharedString::from(format!("remove-project-{root}")), icon: "trash-2", label: "Remove project", + shortcut: None, + destructive: false, on_click: Box::new(move |_, cx| { cx.emit(SidebarEvent::RemoveRoot(remove_root.clone())); }), @@ -2658,29 +2690,34 @@ impl Sidebar { .children(menu) } - /// A task row's overflow button, with the task's menu under it while - /// open: rename, then where the task can go. + /// A task row's "…" button, with the task's menu while open: under + /// the button, or where a right-click on the row opened it. fn task_menu_trigger( &self, row: &SidebarRow, task: SidebarTaskEntry, + open: bool, window: &mut Window, cx: &mut Context, ) -> Div { - let key = SidebarPopup::Task(row.id.clone()); - let open = self.popup.is_open(&key); let menu = open.then(|| { - let menu = Menu::new(row.menu_panel_id.clone(), px(180.)) + let mut menu = Menu::new(row.menu_panel_id.clone(), px(200.)) .label("Task options") - .application_vim(self.application_vim_enabled) - .items(self.task_menu_items(task).into_iter().map(menu_item)); - self.popup.render(menu, Placement::BelowEnd, window, cx) + .application_vim(self.application_vim_enabled); + for &action in &row.menu_actions { + menu = match self.task_menu_item(task, action) { + Some(item) => menu.item(menu_item(item)), + None => menu.separator(), + }; + } + let placement = self.task_menu_at.map_or(Placement::BelowEnd, Placement::At); + self.popup.render(menu, placement, window, cx) }); let session = row.id.clone(); div() .relative() .child(self.popup.trigger( - key, + SidebarPopup::Task(row.id.clone()), row_menu_button(row.menu_id.clone(), &row.group, "More", open), cx, move |this, _, cx| this.toggle_task_menu(session.clone(), cx), @@ -2732,9 +2769,7 @@ impl Sidebar { ) } - /// One task row: title, its project, and hover actions. The last - /// hover button moves the task one rung forward; the overflow menu - /// reaches every later rung and the way back to active. + /// A task row with its menu and inline rename field. fn render_task_row( &self, task: SidebarTaskEntry, @@ -2755,16 +2790,18 @@ impl Sidebar { .then(|| self.session_activity(&row.id)) .flatten(); let session_id = Arc::clone(&row.id); - let step_id = Arc::clone(&row.id); - let rename_id = Arc::clone(&row.id); - let rung = TaskRung::of(task); - let step = rung.step(); + let menu_id = Arc::clone(&row.id); let pin_id = row.pin_id.clone(); let rename_field = self.task_rename_field(&row.id, cx); let renaming = rename_field.is_some(); + let menu_open = + matches!(self.popup.open_key(), Some(SidebarPopup::Task(open)) if *open == row.id); + // The menu owns hover until it closes. + let hover = !renaming && !self.menu_open(); div() .relative() .w_full() + .debug_selector(|| row.element_id.to_string()) .id(row.element_id.clone()) .role(gpui::Role::ListBoxOption) .accessibility_id(row.id.to_string()) @@ -2786,10 +2823,13 @@ impl Sidebar { row.bg(gpui::rgb(theme::bg_sidebar_row_selected())) .font_weight(gpui::FontWeight::MEDIUM) }) + .when(menu_open, |row| { + row.bg(gpui::rgb(theme::bg_sidebar_row_hover())) + }) .text_color(gpui::rgb(theme::text_primary())) // A row being renamed is a text field: no hover highlight and // no hover actions over it, so the edit is plain to see. - .when(!renaming, |el| { + .when(hover, |el| { el.hover(|style| { style .bg(gpui::rgb(theme::bg_sidebar_row_hover())) @@ -2799,6 +2839,15 @@ impl Sidebar { .on_click(cx.listener(move |_this, _event, _window, cx| { cx.emit(SidebarEvent::Select(session_id.to_string())); })) + // A field's right-click is its own (Paste, Cut). + .when(!renaming, |el| { + el.on_mouse_down( + gpui::MouseButton::Right, + cx.listener(move |this, event: &gpui::MouseDownEvent, _window, cx| { + this.open_task_menu(menu_id.clone(), Some(event.position), cx); + }), + ) + }) // The rename field takes the title's line; the project line // stays under it. .child( @@ -2852,7 +2901,7 @@ impl Sidebar { .child(icon("pin", px(13.), theme::accent())), ) }) - .when(!renaming, |el| { + .when(hover || menu_open, |el| { el.child( div() .absolute() @@ -2870,27 +2919,10 @@ impl Sidebar { .group_hover(row.group.clone(), |style| { style.bg(gpui::rgb(theme::bg_sidebar_row_hover())) }) - .child(self.task_menu_trigger(row, task, window, cx)) - .child(row_action( - row.rename_id.clone(), - &row.group, - "pencil", - "Rename", - cx.listener(move |this, _event, _window, cx| { - cx.stop_propagation(); - this.begin_rename(RenameTarget::Task(rename_id.to_string()), cx); - }), - )) - .child(row_action( - row.step_id.clone(), - &row.group, - step.icon, - step.row_label, - cx.listener(move |this, _event, _window, cx| { - cx.stop_propagation(); - this.move_task(&step_id, rung.next(), cx); - }), - )), + .when(menu_open, |overlay| { + overlay.bg(gpui::rgb(theme::bg_sidebar_row_hover())) + }) + .child(self.task_menu_trigger(row, task, menu_open, window, cx)), ) }) } @@ -3108,20 +3140,7 @@ fn activity_indicator(spinner_id: &SharedString, activity: SessionActivity) -> A } } -/// Small icon button that shows only while the pointer is over its row. -pub(super) fn row_action( - id: SharedString, - group: &SharedString, - icon_name: &'static str, - label: &'static str, - on_click: impl Fn(&gpui::ClickEvent, &mut Window, &mut gpui::App) + 'static, -) -> gpui::Stateful
{ - row_button(id, group, icon_name, label) - .tooltip(widgets::tooltip(label, None)) - .on_click(on_click) -} - -/// A row's overflow button: [`row_action`]'s look, but it stays visible +/// A row's overflow button: [`row_button`]'s look, but it stays visible /// and pressed while its menu is open, and shows no tooltip over the menu. fn row_menu_button( id: SharedString, @@ -3175,14 +3194,25 @@ fn menu_item(item: SidebarMenuItem) -> MenuItem { id, icon, label, + shortcut, + destructive, on_click, } = item; - MenuItem::new( + let item = MenuItem::new( id, label, move |this: &mut Sidebar, _: &mut Window, cx: &mut Context| on_click(this, cx), ) - .icon(icon) + .icon(icon); + let item = match shortcut { + Some(key) => item.shortcut(key), + None => item, + }; + if destructive { + item.destructive() + } else { + item + } } /// Field-wise equality for session rows; the summary type has no 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..b14e0979f 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs @@ -1271,9 +1271,11 @@ impl Sidebar { row.aria_active_descendant() .bg(gpui::rgb(theme::bg_sidebar_row_hover())) }) - .hover(|s| { - s.bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() + .when(!self.menu_open(), |row| { + row.hover(|s| { + s.bg(gpui::rgb(theme::bg_sidebar_row_hover())) + .cursor_pointer() + }) }) .on_click(cx.listener(move |_, _, _, cx| { cx.emit(SidebarEvent::Navigate(request.clone())) diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 1dd1eaf02..1f8c3c09a 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -5315,10 +5315,7 @@ mod state_tests { }); } - /// The hover button steps a task one rung forward along active, - /// settled, archived, deleted. The overflow menu reaches every later - /// rung and comes back only to active: no settle or unsettle on an - /// archived task, no pin on a settled one. + /// Each rung offers only its available actions. #[gpui::test] fn test_task_ladder_actions_follow_the_rung(cx: &mut TestAppContext) { cx.executor().allow_parking(); @@ -5336,28 +5333,32 @@ mod state_tests { .update(cx, |sidebar, cx| sidebar.set_archived_expanded(true, cx)); let sidebar = this.sidebar.read(cx); - assert_eq!(sidebar.row_step_label_for_test("s1"), Some("Settle")); assert_eq!( sidebar.task_menu_labels_for_test("s1"), - vec![ - "Rename task", - "Pin task", - "Settle task", - "Archive task", - "Delete task" + [ + "Rename", + "Pin", + "Mark as unread", + "Open folder", + "Settle", + "Archive", + "Delete", ] ); - - assert_eq!(sidebar.row_step_label_for_test("s2"), Some("Archive")); assert_eq!( sidebar.task_menu_labels_for_test("s2"), - vec!["Rename task", "Reopen task", "Archive task", "Delete task"] + [ + "Rename", + "Mark as unread", + "Open folder", + "Reopen", + "Archive", + "Delete", + ] ); - - assert_eq!(sidebar.row_step_label_for_test("s3"), Some("Delete")); assert_eq!( sidebar.task_menu_labels_for_test("s3"), - vec!["Rename task", "Reopen task", "Delete task"] + ["Rename", "Open folder", "Unarchive", "Delete"] ); }); } @@ -5543,10 +5544,6 @@ mod state_tests { this.sidebar .update(cx, |sidebar, cx| sidebar.toggle_task_pin("s1", cx)); assert_eq!(this.sidebar_pinned(cx), vec![0]); - assert_eq!( - this.sidebar.read(cx).row_step_label_for_test("s1"), - Some("Settle") - ); // The pin drops at once; the row moves when the runtime's // record lands. @@ -5558,10 +5555,6 @@ mod state_tests { this.settle_task("s1", cx); assert_eq!(this.sidebar_settled(cx), vec![0]); assert_eq!(this.sidebar_active(cx), vec![1]); - assert_eq!( - this.sidebar.read(cx).row_step_label_for_test("s1"), - Some("Archive") - ); this.unsettle_task("s1", cx); assert!(this.sidebar_pinned(cx).is_empty()); @@ -5712,16 +5705,192 @@ mod state_tests { cx.simulate_keystrokes(keys); assert!(sidebar.update(cx, |sidebar, _| sidebar.task_menu().is_none())); }; - // Rows: Rename, Pin, Settle, Archive, Delete. - pick(cx, "down down down enter"); - // Rows: Rename, Reopen, Archive, Delete. + pick(cx, "down down down down down enter"); chat.update(cx, |this, cx| this.settle_task("s1", cx)); - pick(cx, "down down enter"); + pick(cx, "down down down down enter"); chat.update(cx, |this, cx| this.unsettle_task("s1", cx)); pick(cx, "down down enter"); assert_eq!(chat.update(cx, |this, cx| this.sidebar_pinned(cx)), vec![0]); } + /// Right-click opens at the pointer, survives release, and switches tasks. + #[gpui::test] + fn test_right_clicking_a_task_row_opens_its_menu(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![summary("s1", "One"), summary("s2", "Two")]; + this.sync_sidebar(cx); + }); + for (row, menu, open) in [ + ("session-s2", "task-menu-s2", "Task(s2)"), + ("session-s1", "task-menu-s1", "Task(s1)"), + ] { + let bounds = on_screen(cx, row); + let at = gpui::point(bounds.left() + px(20.), bounds.center().y); + cx.simulate_mouse_down(at, gpui::MouseButton::Right, gpui::Modifiers::default()); + cx.executor() + .advance_clock(std::time::Duration::from_millis(16)); + cx.run_until_parked(); + let panel = on_screen(cx, menu); + assert!( + (panel.origin.x - at.x).abs() <= px(1.) && (panel.origin.y - at.y).abs() <= px(1.) + ); + let released = at + gpui::point(px(1.), px(-1.)); + cx.simulate_mouse_move( + released, + Some(gpui::MouseButton::Right), + gpui::Modifiers::default(), + ); + cx.simulate_mouse_up( + released, + gpui::MouseButton::Right, + gpui::Modifiers::default(), + ); + cx.executor() + .advance_clock(std::time::Duration::from_millis(160)); + cx.run_until_parked(); + assert_eq!(open_menu(&chat, cx), open); + } + cx.simulate_keystrokes("escape"); + assert_eq!(open_menu(&chat, cx), ""); + press(cx, "menu-session-s1"); + assert_eq!(open_menu(&chat, cx), "Task(s1)"); + cx.simulate_click(gpui::point(px(1100.), px(700.)), gpui::Modifiers::default()); + cx.run_until_parked(); + assert_eq!(open_menu(&chat, cx), ""); + right_click(cx, "session-s1"); + assert_eq!(open_menu(&chat, cx), "Task(s1)"); + } + + /// Menu letters dispatch the corresponding task actions. + #[gpui::test] + fn test_task_menu_letters_pick_their_items(cx: &mut TestAppContext) { + let events = std::rc::Rc::new(std::cell::RefCell::new(Vec::::new())); + let sink = events.clone(); + let (chat, cx) = chat_window(cx, move |this, cx| { + cx.subscribe(&this.sidebar, move |_, _, event: &SidebarEvent, _| { + let event = match event { + SidebarEvent::SetState { session_id, state } => { + format!("{session_id} {state:?}") + } + SidebarEvent::DeleteTask(session_id) => format!("{session_id} deleted"), + SidebarEvent::SetUnread { session_id, unread } => { + format!("{session_id} unread {unread}") + } + _ => return, + }; + sink.borrow_mut().push(event); + }) + .detach(); + this.sessions = vec![summary("s1", "One"), summary("s2", "Archived")]; + this.sessions[1].state = AgentTaskState::Archived; + this.sync_sidebar(cx); + this.sidebar + .update(cx, |sidebar, cx| sidebar.set_archived_expanded(true, cx)); + }); + let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); + let pick = |cx: &mut gpui::VisualTestContext, id: &str, key: &str| { + sidebar.update(cx, |sidebar, cx| sidebar.open_task_menu_for_test(id, cx)); + cx.run_until_parked(); + cx.simulate_keystrokes(key); + assert!( + sidebar.update(cx, |sidebar, _| sidebar.task_menu().is_none()), + "{key} closes the menu" + ); + cx.run_until_parked(); + std::mem::take(&mut *events.borrow_mut()) + }; + + // R hands the keyboard to the rename field: menu letters typed + // into it are text, not actions. + assert!(pick(cx, "s1", "r").is_empty()); + assert!(matches!( + sidebar.update(cx, |sidebar, _| sidebar.rename_target()), + Some(RenameTarget::Task(ref target)) if target == "s1" + )); + cx.simulate_input("Pause and drop"); + cx.run_until_parked(); + let field = sidebar.update(cx, |sidebar, _| sidebar.inputs().last().cloned()); + assert_eq!( + field.map(|field| field.read_with(cx, |field, _| field.text())), + Some("Pause and drop".to_string()) + ); + assert!(events.borrow().is_empty(), "{:?}", events.borrow()); + assert!( + chat.update(cx, |this, cx| this.sidebar_pinned(cx)) + .is_empty() + ); + chat.update(cx, |this, cx| this.cancel_rename(cx)); + + assert!(pick(cx, "s1", "p").is_empty()); + assert_eq!(chat.update(cx, |this, cx| this.sidebar_pinned(cx)), vec![0]); + assert!(pick(cx, "s1", "p").is_empty()); + assert!( + chat.update(cx, |this, cx| this.sidebar_pinned(cx)) + .is_empty() + ); + for (unread, label, event) in [ + (true, "Mark as read", "s1 unread true"), + (false, "Mark as unread", "s1 unread false"), + ] { + assert_eq!(pick(cx, "s1", "u"), [event]); + assert_eq!( + chat.update(cx, |this, _| this.completed_unread_sessions.contains("s1")), + unread + ); + assert!(sidebar.update(cx, |sidebar, _| { + sidebar.task_menu_labels_for_test("s1").contains(&label) + })); + } + assert_eq!(pick(cx, "s1", "s"), ["s1 Settled"]); + chat.update(cx, |this, cx| this.settle_task("s1", cx)); + assert_eq!(pick(cx, "s1", "s"), ["s1 Active"]); + assert_eq!(pick(cx, "s1", "a"), ["s1 Archived"]); + assert_eq!(pick(cx, "s1", "d"), ["s1 deleted"]); + chat.update(cx, |this, _| { + assert_eq!( + this.confirm, + Some(dialogs::ConfirmDialog::DeleteTask("s1".into())) + ); + assert_eq!(this.sessions.len(), 2); + this.confirm = None; + }); + assert_eq!(pick(cx, "s2", "a"), ["s2 Active"]); + + // A press picks the same items. + sidebar.update(cx, |sidebar, cx| sidebar.open_task_menu_for_test("s2", cx)); + press(cx, "delete-task-s2"); + cx.run_until_parked(); + assert_eq!(*events.borrow(), ["s2 deleted"]); + } + + /// Reordering keeps the same task's menu; removing the task closes it. + #[gpui::test] + fn test_task_menu_follows_its_task(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![summary("s1", "One"), summary("s2", "Two")]; + this.sync_sidebar(cx); + }); + press(cx, "menu-session-s2"); + let row_before = on_screen(cx, "session-s2"); + let panel_before = on_screen(cx, "task-menu-s2"); + chat.update(cx, |this, cx| { + this.sessions[1].updated_ms = 10; + this.sync_sidebar(cx); + assert_eq!(this.sidebar_active(cx), vec![1, 0]); + }); + let row_after = on_screen(cx, "session-s2"); + let panel_after = on_screen(cx, "task-menu-s2"); + let row_movement = row_after.top() - row_before.top(); + let panel_movement = panel_after.top() - panel_before.top(); + assert!(row_movement < px(0.)); + assert!((panel_movement - row_movement).abs() <= px(1.)); + assert_eq!(open_menu(&chat, cx), "Task(s2)"); + chat.update(cx, |this, cx| this.remove_session("s2", cx)); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + assert_eq!(open_menu(&chat, cx), ""); + } + /// Opened from the keyboard, the switcher starts on the current scope; /// Application Vim's j reaches a project and Enter scopes the sidebar /// to it. @@ -5776,14 +5945,34 @@ mod state_tests { /// those bounds back; one extra frame covers a park that finished /// before that paint. fn press(cx: &mut gpui::VisualTestContext, selector: &'static str) { - let bounds = (0..2) + let at = on_screen(cx, selector).center(); + cx.simulate_click(at, gpui::Modifiers::default()); + } + + /// Right-click the middle of the element with `selector`. + fn right_click(cx: &mut gpui::VisualTestContext, selector: &'static str) { + let at = on_screen(cx, selector).center(); + right_click_at(cx, at); + } + + fn right_click_at(cx: &mut gpui::VisualTestContext, at: gpui::Point) { + cx.simulate_mouse_down(at, gpui::MouseButton::Right, gpui::Modifiers::default()); + cx.simulate_mouse_up(at, gpui::MouseButton::Right, gpui::Modifiers::default()); + } + + /// The bounds of the element with `selector`, once a refreshed frame + /// has drawn it (see [`press`]). + fn on_screen( + cx: &mut gpui::VisualTestContext, + selector: &'static str, + ) -> gpui::Bounds { + (0..2) .find_map(|_| { cx.update(|window, _| window.refresh()); cx.run_until_parked(); cx.debug_bounds(selector) }) - .unwrap_or_else(|| panic!("{selector} is not on screen")); - cx.simulate_click(bounds.center(), gpui::Modifiers::default()); + .unwrap_or_else(|| panic!("{selector} is not on screen")) } #[gpui::test] @@ -5928,8 +6117,7 @@ mod state_tests { } /// A task menu reaches over the rows below it. A press on one of its - /// items lands on the item, not on the overflow button of the row - /// underneath. + /// items lands on the item, not on the row underneath. #[gpui::test] fn test_a_press_on_a_menu_item_reaches_the_item(cx: &mut TestAppContext) { let (chat, cx) = chat_window(cx, |this, cx| { @@ -5941,14 +6129,19 @@ mod state_tests { this.sync_sidebar(cx); }); press(cx, "menu-session-s1"); - let beneath = cx.debug_bounds("menu-session-s2").expect("s2's button"); + let beneath = cx.debug_bounds("session-s2").expect("s2's row"); let rename = cx.debug_bounds("rename-task-s1").expect("s1's Rename item"); assert!( - rename.contains(&beneath.center()), - "the fixture puts s2's button under s1's Rename item" + beneath.contains(&rename.center()), + "the fixture puts s1's Rename item over s2's row" + ); + cx.simulate_click(rename.center(), gpui::Modifiers::default()); + assert_eq!(open_menu(&chat, cx), ""); + assert_ne!( + chat.update(cx, |this, _| this.selected_session.clone()), + Some("s2".to_string()), + "the press did not select s2" ); - cx.simulate_click(beneath.center(), gpui::Modifiers::default()); - assert_eq!(open_menu(&chat, cx), "", "s2's menu did not open"); let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); assert!( matches!( @@ -6483,8 +6676,8 @@ mod state_tests { rename(cx, "Pressed"); cx.update(|window, _| window.refresh()); cx.run_until_parked(); - let other_row = cx.debug_bounds("menu-session-s2").expect("s2's row"); - let on_row = other_row.center() - gpui::point(gpui::px(80.), gpui::px(0.)); + let other_row = cx.debug_bounds("session-s2").expect("s2's row"); + let on_row = other_row.center(); cx.simulate_mouse_down(on_row, gpui::MouseButton::Left, gpui::Modifiers::default()); cx.simulate_mouse_up(on_row, gpui::MouseButton::Left, gpui::Modifiers::default()); cx.run_until_parked(); @@ -6566,13 +6759,13 @@ mod state_tests { this.sync_sidebar(cx); }); let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); - let rename_button = |cx: &mut gpui::VisualTestContext| { + let menu_button = |cx: &mut gpui::VisualTestContext| { cx.update(|window, _| window.refresh()); cx.run_until_parked(); - cx.debug_bounds("rename-session-s1") + cx.debug_bounds("menu-session-s1") }; assert!( - rename_button(cx).is_some(), + menu_button(cx).is_some(), "a listed row has its hover actions" ); @@ -6580,7 +6773,7 @@ mod state_tests { sidebar.begin_rename(RenameTarget::Task("s1".into()), cx) }); cx.run_until_parked(); - assert!(rename_button(cx).is_none(), "a row being renamed has none"); + assert!(menu_button(cx).is_none(), "a row being renamed has none"); cx.simulate_input("Fresh"); let field = sidebar.update(cx, |sidebar, _| sidebar.inputs().last().cloned()); assert_eq!( @@ -6590,7 +6783,7 @@ mod state_tests { ); chat.update(cx, |this, cx| this.cancel_rename(cx)); - assert!(rename_button(cx).is_some(), "the actions come back after"); + assert!(menu_button(cx).is_some(), "the actions come back after"); } /// A project's rename field sits in the switcher. Escape ends the diff --git a/apps/maple-agent/app/src/ui/popup.rs b/apps/maple-agent/app/src/ui/popup.rs index fb69e291f..87114874c 100644 --- a/apps/maple-agent/app/src/ui/popup.rs +++ b/apps/maple-agent/app/src/ui/popup.rs @@ -16,9 +16,10 @@ //! right-click menu) counts as inside. //! - The open popup holds keyboard focus. Up and Down (`j`/`k` under //! Application Vim) move the highlight, Home and End (`g g`/`G`) jump, -//! Enter or Space picks, Escape closes, and a typed letter jumps to the -//! next row that starts with it. The pointer moves the same highlight. A -//! text field inside the menu keeps its own keys. +//! Enter or Space picks, Escape closes, and a typed letter picks the row +//! with that shortcut, or else jumps to the next row that starts with +//! it. The pointer moves the same highlight. A text field inside the +//! menu keeps its own keys. //! - One popup per window. Whatever takes focus next (another popup, a //! dialog, a text field) closes the open one. When a popup that holds //! focus closes, focus returns to where it was before it opened, also @@ -462,6 +463,7 @@ impl Popup { enabled: item.enabled, keep_open: item.keep_open, initial: initial(&item.label), + shortcut: item.shortcut.and_then(initial), child: children.len(), }); children.push(item_row(item, highlighted, get, cx)); @@ -890,6 +892,10 @@ pub(crate) struct MenuItem { state: Option, enabled: bool, keep_open: bool, + /// The key that picks the row while the menu is open, as shown. + shortcut: Option<&'static str>, + /// Show enabled labels and icons in the error color. + destructive: bool, trailing: Option, style: Option, on_select: OnSelect, @@ -917,6 +923,8 @@ impl MenuItem { state: None, enabled: true, keep_open: false, + shortcut: None, + destructive: false, trailing: None, style: None, on_select: Rc::new(on_select), @@ -965,6 +973,18 @@ impl MenuItem { self } + /// Show a letter shortcut that takes precedence over type-ahead. + pub(crate) fn shortcut(mut self, key: &'static str) -> Self { + self.shortcut = Some(key); + self + } + + /// Show the row as destructive, in the error color. + pub(crate) fn destructive(mut self) -> Self { + self.destructive = true; + self + } + /// A control of the row's own at its end, such as a "…" button. A /// press on it is the control's alone. pub(crate) fn trailing(mut self, element: impl IntoElement) -> Self { @@ -988,6 +1008,7 @@ struct Pick { enabled: bool, keep_open: bool, initial: Option, + shortcut: Option, child: usize, } @@ -1012,6 +1033,8 @@ fn item_row( state, enabled, keep_open, + shortcut, + destructive, trailing, style, on_select, @@ -1021,10 +1044,10 @@ fn item_row( Some(ItemState::Current(_)) => Role::MenuItemRadio, Some(ItemState::Switch(_)) => Role::MenuItemCheckBox, }; - let text = if enabled { - theme::text_primary() - } else { - theme::text_muted() + let (text, icon_color) = match (enabled, destructive) { + (true, true) => (theme::status_error(), theme::status_error()), + (true, false) => (theme::text_primary(), theme::text_secondary()), + (false, _) => (theme::text_muted(), theme::text_secondary()), }; let hovered_id = id.clone(); let row_element = div() @@ -1072,7 +1095,7 @@ fn item_row( on_select(view, window, cx); })) }) - .children(icon_name.map(|name| icon(name, widgets::ROW_ICON, theme::text_secondary()))) + .children(icon_name.map(|name| icon(name, widgets::ROW_ICON, icon_color))) .map(|row| match content { Some(content) => row.child(div().flex_1().min_w_0().child(content)), None => row.child( @@ -1102,6 +1125,12 @@ fn item_row( })), ), }) + .children(shortcut.map(|key| { + div() + .flex_none() + .text_color(gpui::rgb(theme::text_muted())) + .child(key) + })) .children(trailing.map(|trailing| { // The row's own control, such as a "…" button. A press on it is // the control's alone: the row neither shows as pressed nor @@ -1190,10 +1219,11 @@ fn pick( (item.on_select)(view, window, cx); } -/// A letter typed into the menu highlights the next item that starts with -/// it. Other typing stops here too: it must not reach a text field under -/// the menu. Only while the menu itself has focus, so a field inside the -/// menu still takes its typing. +/// A letter typed into the menu picks the item with that shortcut, or +/// else highlights the next item that starts with it. Other typing stops +/// here too: it must not reach a text field under the menu. Only while +/// the menu itself has focus, so a field inside the menu still takes its +/// typing. fn type_ahead( view: &mut V, get: fn(&mut V) -> &mut Popup, @@ -1215,6 +1245,13 @@ fn type_ahead( }; cx.stop_propagation(); let typed = initial(typed); + if let Some(row) = picks + .iter() + .position(|item| item.shortcut.is_some() && item.shortcut == typed) + { + pick(view, get, picks, row, window, cx); + return; + } let popup = get(view); let rows = picks.len(); let start = popup.highlighted_row(picks).map_or(0, |row| row + 1); From 17e0e027a7affb55561629265dea64fd8752d883 Mon Sep 17 00:00:00 2001 From: Michael Atwood <70112949+doowta@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:13:13 -0500 Subject: [PATCH 2/3] Agent: move a pinned row's pin into its hover strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pinned row's pin vanished under the hover strip, which reached past it, so hovering showed only the "…" button with an "Unpin" tooltip over nothing. A press there passed the pin's element id to the pin toggle instead of the task's id, so it pinned a stray id and the task stayed pinned. The Pinned section already names a pinned row, so the row drops its resting pin. The hover strip is one trailing row of buttons at the row's end: "…", then on a pinned row a pin that unpins the task by its id. The strip no longer needs an offset to clear the pin. Co-Authored-By: Claude Opus 5.5 --- apps/maple-agent/app/src/ui/chat/mod.rs | 3 +- apps/maple-agent/app/src/ui/chat/sidebar.rs | 87 +++++++++------------ apps/maple-agent/app/src/ui/chat/tests.rs | 26 ++++++ 3 files changed, 66 insertions(+), 50 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index f1b3db22c..7483e1120 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -922,7 +922,8 @@ impl ChatScreen { /// The task menu reads or sets the marker a completed run leaves. fn set_unread(&mut self, session_id: &str, unread: bool, cx: &mut Context) { let changed = if unread { - self.completed_unread_sessions.insert(session_id.to_string()) + self.completed_unread_sessions + .insert(session_id.to_string()) } else { self.completed_unread_sessions.remove(session_id) }; diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index 2f37d55af..bd25c8f31 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -2676,17 +2676,22 @@ impl Sidebar { let toggle = root.target.clone(); div() .relative() - .child(self.project_popup.trigger( - root.target.clone(), - row_menu_button( - root.menu_id.clone(), - &root.row_group, - "Project options", - open, + .child( + self.project_popup.trigger( + root.target.clone(), + row_menu_button( + root.menu_id.clone(), + &root.row_group, + "Project options", + open, + ) + .when(!open, |button| { + button.tooltip(widgets::tooltip("Project options", None)) + }), + cx, + move |this, _, cx| this.toggle_project_menu(&toggle, cx), ), - cx, - move |this, _, cx| this.toggle_project_menu(&toggle, cx), - )) + ) .children(menu) } @@ -2718,7 +2723,7 @@ impl Sidebar { .relative() .child(self.popup.trigger( SidebarPopup::Task(row.id.clone()), - row_menu_button(row.menu_id.clone(), &row.group, "More", open), + row_menu_button(row.menu_id.clone(), &row.group, "Task options", open), cx, move |this, _, cx| this.toggle_task_menu(session.clone(), cx), )) @@ -2791,7 +2796,7 @@ impl Sidebar { .flatten(); let session_id = Arc::clone(&row.id); let menu_id = Arc::clone(&row.id); - let pin_id = row.pin_id.clone(); + let unpin_id = Arc::clone(&row.id); let rename_field = self.task_rename_field(&row.id, cx); let renaming = rename_field.is_some(); let menu_open = @@ -2879,50 +2884,38 @@ impl Sidebar { .when_some(activity, |row_element, activity| { row_element.child(activity_indicator(&row.spinner_id, activity)) }) - .when(is_pinned, |row| { - row.child( - div() - .id(pin_id.clone()) - .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("Unpin", None)) - .on_click(cx.listener(move |this, _event, _window, cx| { - cx.stop_propagation(); - this.toggle_task_pin(&pin_id, cx); - })) - .child(icon("pin", px(13.), theme::accent())), - ) - }) .when(hover || menu_open, |el| { el.child( div() .absolute() .top_0() .bottom_0() - .when(is_pinned, |overlay| overlay.right(px(26.))) - .when(!is_pinned, |overlay| { - overlay.right_0().rounded_r(theme::RADIUS_MD) - }) + .right_0() .flex() .items_center() .gap_1p5() .pl_2() .pr_6() + .rounded_r(theme::RADIUS_MD) .group_hover(row.group.clone(), |style| { style.bg(gpui::rgb(theme::bg_sidebar_row_hover())) }) .when(menu_open, |overlay| { overlay.bg(gpui::rgb(theme::bg_sidebar_row_hover())) }) - .child(self.task_menu_trigger(row, task, menu_open, window, cx)), + .child(self.task_menu_trigger(row, task, menu_open, window, cx)) + // The Pinned section names a pinned row at rest; on + // hover the pin unpins it. + .when(is_pinned, |overlay| { + overlay.child( + row_button(row.pin_id.clone(), &row.group, "pin", "Unpin") + .tooltip(widgets::tooltip("Unpin", None)) + .on_click(cx.listener(move |this, _event, _window, cx| { + cx.stop_propagation(); + this.toggle_task_pin(&unpin_id, cx); + })), + ) + }), ) }) } @@ -3141,22 +3134,18 @@ fn activity_indicator(spinner_id: &SharedString, activity: SessionActivity) -> A } /// A row's overflow button: [`row_button`]'s look, but it stays visible -/// and pressed while its menu is open, and shows no tooltip over the menu. +/// and pressed while its menu is open. fn row_menu_button( id: SharedString, group: &SharedString, label: &'static str, open: bool, ) -> gpui::Stateful
{ - row_button(id, group, "ellipsis", label) - .when(open, |button| { - button - .opacity(1.) - .bg(gpui::rgb(theme::bg_sidebar_row_selected())) - }) - .when(!open, |button| { - button.tooltip(widgets::tooltip(label, None)) - }) + row_button(id, group, "ellipsis", label).when(open, |button| { + button + .opacity(1.) + .bg(gpui::rgb(theme::bg_sidebar_row_selected())) + }) } /// The small icon button a sidebar row shows while it is hovered. diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 1f8c3c09a..3396ab124 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -5863,6 +5863,32 @@ mod state_tests { assert_eq!(*events.borrow(), ["s2 deleted"]); } + /// Pressing a pinned row's Unpin button removes its pin without selecting it. + #[gpui::test] + fn test_a_pinned_rows_strip_unpins_it(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![summary("s1", "One")]; + this.sync_sidebar(cx); + this.sidebar + .update(cx, |sidebar, cx| sidebar.toggle_task_pin("s1", cx)); + }); + assert_eq!(chat.update(cx, |this, cx| this.sidebar_pinned(cx)), vec![0]); + let selected = chat.update(cx, |this, _| this.selected_session.clone()); + let row = on_screen(cx, "session-s1"); + cx.simulate_mouse_move(row.center(), None, gpui::Modifiers::default()); + press(cx, "pin-session-s1"); + cx.run_until_parked(); + assert!( + chat.update(cx, |this, cx| this.sidebar_pinned(cx)) + .is_empty() + ); + assert_eq!( + chat.update(cx, |this, _| this.selected_session.clone()), + selected + ); + assert_eq!(open_menu(&chat, cx), ""); + } + /// Reordering keeps the same task's menu; removing the task closes it. #[gpui::test] fn test_task_menu_follows_its_task(cx: &mut TestAppContext) { From 69aa2f8648291469808686ee5abfd4f2b9c5deff Mon Sep 17 00:00:00 2001 From: Michael Atwood <70112949+doowta@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:51:28 -0500 Subject: [PATCH 3/3] Agent: keep a menu open through a zero-distance scroll A secondary click can arrive with a stationary wheel event. GPUI calls the list scroll handler even when its position does not change, so the sidebar immediately dismissed a task menu that had just opened. Remember the list position when a menu opens and compare both the item and pixel offset after scrolling. Defer the read until ListState releases its mutable borrow. Keep reveal_row's explicit dismissal and remove the temporary popup tracing now that the real-device close path is known. Cover zero, horizontal, and clamped wheel events, a one-pixel move, and reopening after a real scroll. Retain the full right-click sequence test. Co-Authored-By: Claude Opus 5.5 --- apps/maple-agent/app/src/ui/chat/sidebar.rs | 33 ++++++++++--- apps/maple-agent/app/src/ui/chat/tests.rs | 51 +++++++++++++++++++++ 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index bd25c8f31..f2799eaa1 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -407,6 +407,8 @@ pub(super) struct Sidebar { project_filter: Option, // Menus. popup: Popup, + /// The list position when a menu opened. + menu_scroll_top: gpui::ListOffset, /// Where a right-click opened the task menu; `None` hangs it under the /// row's "…" button. task_menu_at: Option>, @@ -616,15 +618,26 @@ impl Sidebar { }) .detach(); let list = gpui::ListState::new(0, gpui::ListAlignment::Top, px(200.)); - // A menu hangs off its row: scrolling the list closes it, so it - // never drifts from its row or leaves hover state behind. + // A menu hangs off its row: close it only when the list moves, + // not for a zero-distance wheel event accompanying a right-click. let sidebar = cx.weak_entity(); list.set_scroll_handler(move |_, _, cx| { - sidebar - .update(cx, |sidebar, cx| { - sidebar.close_popups(cx); - }) - .ok(); + let sidebar = sidebar.clone(); + // ListState is still borrowed while its scroll handler runs. + cx.defer(move |cx| { + sidebar + .update(cx, |sidebar, cx| { + if sidebar.menu_open() { + let top = sidebar.list.logical_scroll_top(); + if top.item_ix != sidebar.menu_scroll_top.item_ix + || top.offset_in_item != sidebar.menu_scroll_top.offset_in_item + { + sidebar.close_popups(cx); + } + } + }) + .ok(); + }); }); Self { pages: SidebarPages::default(), @@ -659,6 +672,7 @@ impl Sidebar { scope_label: "All projects".into(), project_filter: None, popup: Popup::new(|this| &mut this.popup, cx), + menu_scroll_top: gpui::ListOffset::default(), task_menu_at: None, project_popup: Popup::new(|this| &mut this.project_popup, cx), menu_trust: None, @@ -1617,6 +1631,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.project_popup.close(cx); + self.menu_scroll_top = self.list.logical_scroll_top(); self.popup.toggle(SidebarPopup::Switcher, cx); } @@ -1628,6 +1643,7 @@ impl Sidebar { } self.project_popup.close(cx); if open { + self.menu_scroll_top = self.list.logical_scroll_top(); let current = self .project_filter .as_deref() @@ -1870,6 +1886,7 @@ impl Sidebar { /// Open or close a project's overflow menu. Its trust row appears once /// the project's trust status loads. fn toggle_project_menu(&mut self, root: &ProjectTarget, cx: &mut Context) { + self.menu_scroll_top = self.list.logical_scroll_top(); if !self.project_popup.toggle(root.clone(), cx) { return; } @@ -1898,6 +1915,7 @@ impl Sidebar { /// Open or close a task row's menu from its "…" button. fn toggle_task_menu(&mut self, session_id: Arc, cx: &mut Context) { self.task_menu_at = None; + self.menu_scroll_top = self.list.logical_scroll_top(); self.popup.toggle(SidebarPopup::Task(session_id), cx); } @@ -1910,6 +1928,7 @@ impl Sidebar { cx: &mut Context, ) { self.task_menu_at = at; + self.menu_scroll_top = self.list.logical_scroll_top(); self.popup.open(SidebarPopup::Task(session_id), cx); } diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 3396ab124..c2c134729 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -5917,6 +5917,57 @@ mod state_tests { assert_eq!(open_menu(&chat, cx), ""); } + /// A wheel event can accompany a secondary click without moving the + /// list. Only a change in position dismisses the task menu. + #[gpui::test] + fn test_task_menu_stays_open_when_a_scroll_does_not_move_the_list(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = (1..=40) + .map(|ix| summary(&format!("s{ix}"), &format!("Task {ix}"))) + .collect(); + this.sync_sidebar(cx); + }); + let list = chat.update(cx, |this, cx| this.sidebar.read(cx).list().clone()); + right_click(cx, "session-s2"); + let row = on_screen(cx, "session-s2"); + let beside = gpui::point(row.left() + px(8.), row.center().y + px(120.)); + let scroll = |cx: &mut gpui::VisualTestContext, x, y| { + cx.simulate_event(gpui::ScrollWheelEvent { + position: beside, + delta: gpui::ScrollDelta::Pixels(gpui::point(px(x), px(y))), + ..Default::default() + }); + cx.run_until_parked(); + }; + let offset = || { + let top = list.logical_scroll_top(); + (top.item_ix, top.offset_in_item) + }; + let before = offset(); + for (x, y) in [(0., 0.), (0., 20.), (20., 0.)] { + scroll(cx, x, y); + assert_eq!(offset(), before, "the list did not move"); + assert_eq!(open_menu(&chat, cx), "Task(s2)"); + on_screen(cx, "task-menu-s2"); + } + + // A single pixel within the same first item still counts as a scroll. + scroll(cx, 0., -1.); + assert_eq!(offset().0, before.0); + assert_ne!(offset().1, before.1); + assert_eq!(open_menu(&chat, cx), ""); + + right_click(cx, "session-s2"); + let before = offset(); + scroll(cx, 0., 0.); + assert_eq!(offset(), before); + assert_eq!(open_menu(&chat, cx), "Task(s2)"); + on_screen(cx, "task-menu-s2"); + chat.update(cx, |this, cx| this.step_task(1, cx)); + cx.run_until_parked(); + assert_eq!(open_menu(&chat, cx), "", "revealing a row closes the menu"); + } + /// Opened from the keyboard, the switcher starts on the current scope; /// Application Vim's j reaches a project and Enter scopes the sidebar /// to it.