From 9cee179878c3c2b68092cc1f89065226caf61449 Mon Sep 17 00:00:00 2001 From: Michael Atwood <70112949+doowta@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:14:00 -0500 Subject: [PATCH 01/29] Agent: group the sidebar under project folders Keep each project's tasks together so the sidebar can show all projects without repeating a project line on every task. Cache folder sections and folds, preserve pin-aware activity ranking, and put project actions and inline rename on the folder headers. Keep the paired switcher and task ladder behavior while making the single-computer Projects header fold the list and reveal Add project on hover. Co-Authored-By: Codex GPT-6.1-Sol --- apps/maple-agent/app/src/ui/chat/mod.rs | 3 +- .../maple-agent/app/src/ui/chat/navigation.rs | 14 +- apps/maple-agent/app/src/ui/chat/sidebar.rs | 1099 ++++++++++++----- .../app/src/ui/chat/sidebar_computers.rs | 11 +- .../app/src/ui/chat/sidebar_paging.rs | 362 +++++- apps/maple-agent/app/src/ui/chat/tests.rs | 788 ++++++++++-- 6 files changed, 1896 insertions(+), 381 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index da5e5eba7..4badcb5ce 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -4106,7 +4106,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); diff --git a/apps/maple-agent/app/src/ui/chat/navigation.rs b/apps/maple-agent/app/src/ui/chat/navigation.rs index 7a33b4071..c633465f3 100644 --- a/apps/maple-agent/app/src/ui/chat/navigation.rs +++ b/apps/maple-agent/app/src/ui/chat/navigation.rs @@ -33,6 +33,8 @@ pub(super) enum SidebarTarget { NewTask, Search, Projects, + /// A folder group's header, by project root. + Folder(String), Task(String), Archived, LoadMore(String), @@ -564,8 +566,10 @@ impl ChatScreen { Some(SidebarTarget::NewTask) => self.new_session(cx), Some(SidebarTarget::Search) => self.focus_sidebar_search(window, cx), Some(SidebarTarget::Projects) => self.sidebar.update(cx, |sidebar, cx| { - let open = sidebar.switcher_menu_open(); - sidebar.set_switcher_menu_open(!open, cx); + sidebar.activate_projects(cx); + }), + Some(SidebarTarget::Folder(root)) => self.sidebar.update(cx, |sidebar, cx| { + sidebar.set_folder_expanded(&root, None, cx); }), Some(SidebarTarget::Computer(request)) => cx.emit(request), Some(SidebarTarget::Task(task_id)) => self.select_session(&task_id, cx), @@ -984,9 +988,11 @@ impl ChatScreen { self.focus_application_vim(window, cx); } - fn reveal_application_selection(&self, cx: &gpui::App) { + fn reveal_application_selection(&self, cx: &mut Context) { 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 2e620052a..aa779c4af 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -198,7 +198,7 @@ pub(super) struct SidebarRow { pub(super) menu_delete_id: SharedString, pub(super) menu_reopen_id: SharedString, pub(super) title: SharedString, - /// Display name of the task's project, shown on every row. + /// Display name of the task's project, for search and accessibility. pub(super) project_name: SharedString, /// Lower-cased title, matched against the sidebar filter. pub(super) search: String, @@ -230,6 +230,11 @@ impl SidebarRow { } } +// Folder tasks start at the header's name, after its icon and gap. +const FOLDER_HEADER_PADDING: gpui::Rems = gpui::rems(1.); +const FOLDER_HEADER_GAP: gpui::Rems = gpui::rems(0.5); +const FOLDER_ICON_SIZE: gpui::Pixels = px(16.); + /// The switcher's "All projects" row. const ALL_PROJECTS_ID: &str = "switcher-all-projects"; @@ -259,6 +264,8 @@ pub(super) enum SidebarEntry { /// A section label, shown only when the section has rows. SectionLabel(SidebarSection), Task(SidebarTaskEntry), + /// An expanded folder whose non-archived task coverage is complete and empty. + FolderEmpty(usize), ArchivedHeader, /// Placeholder row when no task row is listed. Empty(SidebarEmpty), @@ -272,6 +279,8 @@ pub(super) struct SidebarTaskEntry { pub(super) pinned: bool, pub(super) settled: bool, pub(super) archived: bool, + /// Space after the last visible task in a section. + pub(super) gap_after: bool, } /// Where a task sits on the ladder active, settled, archived, deleted. @@ -348,13 +357,22 @@ pub(super) enum SidebarEmpty { NoMatches, } -/// A named section of the inbox: pinned tasks, active work, and the -/// settled rest. +/// A named section of the task list: pinned tasks, then project folders. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum SidebarSection { Pinned, Active, Settled, + /// A project's tasks, by index into the switcher roots. + Folder(usize), +} + +/// Hover follows section membership across independently rendered list rows. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SidebarHoverSection { + Projects, + Section(SidebarSection), + Archived, } impl SidebarSection { @@ -363,6 +381,7 @@ impl SidebarSection { Self::Pinned => "Pinned", Self::Active => "Active", Self::Settled => "Settled", + Self::Folder(_) => "Tasks", } } @@ -371,6 +390,7 @@ impl SidebarSection { Self::Pinned => "section-toggle-pinned", Self::Active => "section-toggle-active", Self::Settled => "section-toggle-settled", + Self::Folder(_) => "section-toggle-tasks", } } } @@ -386,16 +406,21 @@ struct SidebarMenuItem { on_click: MenuAction, } -/// One project the switcher menu lists: the root path, its display name, -/// and the element ids its row and menus use. Built when the sections -/// rebuild, never per frame. +/// One project the switcher menu lists or a folder group heads: the root +/// path, its display name, and the element ids its rows and menus use. +/// Built when the sections rebuild, never per frame. pub(super) struct SwitcherRoot { target: ProjectTarget, root: String, name: SharedString, row_id: SharedString, + /// Hover group of the switcher row or the folder header. row_group: SharedString, menu_id: SharedString, + folder_id: SharedString, + new_task_id: SharedString, + new_task_label: SharedString, + empty_id: SharedString, } pub(super) struct Sidebar { @@ -424,14 +449,22 @@ pub(super) struct Sidebar { list: gpui::ListState, entries: Vec, heading_gaps: Vec, + entry_sections: Vec>, + hovered_section: Option, + hovered_entry: Option, + hover_scroll_top: gpui::ListOffset, rows: Vec, pinned_rows: Vec, - active_rows: Vec, - settled_rows: Vec, + /// The grouping's sections under the Projects header, in display + /// order. + sections: Vec<(SidebarSection, Vec)>, archived_rows: Vec, pinned_expanded: bool, active_expanded: bool, settled_expanded: bool, + collapsed_roots: HashSet, + /// Whether the list under the Projects header shows. + projects_expanded: bool, archived_expanded: bool, switcher_roots: Vec, computer_switcher_roots: Vec>, @@ -441,6 +474,7 @@ pub(super) struct Sidebar { popup: Popup, /// A project's overflow menu, keyed by execution computer and path. project_popup: Popup, + project_menu_at: Option>, /// Trust status of the project whose menu is open, once it loads. menu_trust: Option, // Search and rename. @@ -496,6 +530,10 @@ impl SwitcherRoot { row_id: target.element_id("switcher"), row_group: target.element_id("switcher-project-row"), menu_id: target.element_id("menu-project"), + folder_id: target.element_id("folder"), + new_task_id: target.element_id("new-task-in"), + new_task_label: format!("New task in {name}").into(), + empty_id: target.element_id("folder-empty"), target, name, } @@ -669,6 +707,24 @@ impl Sidebar { this.search_changed(&input, cx) }) .detach(); + let list = gpui::ListState::new(0, gpui::ListAlignment::Top, px(200.)); + let sidebar = cx.weak_entity(); + list.set_scroll_handler(move |_, _, cx| { + let sidebar = sidebar.clone(); + // ListState is still borrowed while its scroll handler runs. + cx.defer(move |cx| { + sidebar + .update(cx, |this, cx| { + let top = this.list.logical_scroll_top(); + if top.item_ix != this.hover_scroll_top.item_ix + || top.offset_in_item != this.hover_scroll_top.offset_in_item + { + this.set_hovered_entry(None, cx); + } + }) + .ok(); + }); + }); Self { pages: SidebarPages::default(), group_pages: ComputerPages::default(), @@ -686,17 +742,22 @@ 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(), heading_gaps: Vec::new(), + entry_sections: Vec::new(), + hovered_section: None, + hovered_entry: None, + hover_scroll_top: gpui::ListOffset::default(), rows: Vec::new(), pinned_rows: Vec::new(), - active_rows: Vec::new(), - settled_rows: Vec::new(), + sections: Vec::new(), archived_rows: Vec::new(), pinned_expanded: true, active_expanded: true, settled_expanded: true, + collapsed_roots: HashSet::new(), + projects_expanded: true, archived_expanded: false, switcher_roots: Vec::new(), computer_switcher_roots: Vec::new(), @@ -704,6 +765,7 @@ impl Sidebar { project_filter: None, popup: Popup::new(|this| &mut this.popup, cx), project_popup: Popup::new(|this| &mut this.project_popup, cx), + project_menu_at: None, menu_trust: None, filter: String::new(), search_input, @@ -1032,7 +1094,8 @@ impl Sidebar { Some(rows[target].clone()) } - pub(super) fn reveal_row(&self, row: usize) { + pub(super) fn reveal_row(&mut self, row: usize, cx: &mut Context) { + self.set_hovered_entry(None, cx); self.list.scroll_to_reveal_item(row); } @@ -1041,19 +1104,56 @@ impl Sidebar { &self.entries } + #[cfg(test)] + pub(super) fn section_rows(&self, section: SidebarSection) -> &[usize] { + if section == SidebarSection::Pinned { + return &self.pinned_rows; + } + self.sections + .iter() + .find(|(candidate, _)| *candidate == section) + .map_or(&[], |(_, rows)| rows) + } + #[cfg(test)] pub(super) fn pinned_rows(&self) -> &[usize] { - &self.pinned_rows + self.section_rows(SidebarSection::Pinned) + } + + #[cfg(test)] + pub(super) fn active_rows(&self) -> Vec { + self.entries + .iter() + .filter_map(|entry| match entry { + SidebarEntry::Task(task) if !task.pinned && !task.settled && !task.archived => { + Some(task.session) + } + _ => None, + }) + .collect() } #[cfg(test)] - pub(super) fn active_rows(&self) -> &[usize] { - &self.active_rows + pub(super) fn settled_rows(&self) -> Vec { + self.entries + .iter() + .filter_map(|entry| match entry { + SidebarEntry::Task(task) if task.settled => Some(task.session), + _ => None, + }) + .collect() } + /// The folder groups' project names, in display order. #[cfg(test)] - pub(super) fn settled_rows(&self) -> &[usize] { - &self.settled_rows + pub(super) fn folder_names(&self) -> Vec<&str> { + self.sections + .iter() + .filter_map(|(section, _)| match section { + SidebarSection::Folder(index) => Some(self.switcher_roots[*index].name.as_ref()), + _ => None, + }) + .collect() } #[cfg(test)] @@ -1071,7 +1171,11 @@ impl Sidebar { self.project_filter.as_deref() } - pub(super) fn switcher_menu_open(&self) -> bool { + fn sidebar_menu_open(&self) -> bool { + self.popup.open_key().is_some() || self.project_popup.open_key().is_some() + } + + pub(super) fn header_menu_open(&self) -> bool { self.popup.is_open(&SidebarPopup::Switcher) } @@ -1193,6 +1297,11 @@ impl Sidebar { .collect() } + #[cfg(test)] + pub(super) fn vim_targets_for_test(&self) -> Vec { + self.vim_order.clone() + } + #[cfg(test)] pub(super) fn vim_projection_is_empty(&self) -> bool { self.vim_selected.is_none() && self.vim_by_row.is_empty() && self.vim_order.is_empty() @@ -1229,7 +1338,7 @@ impl Sidebar { pub(super) fn focus_search(&mut self, window: &mut Window, cx: &mut Context) { self.search_visible = true; - self.reveal_row(1); + self.reveal_row(1, cx); self.list.remeasure_items(1..2); let handle = self.search_input.read(cx).focus_handle(cx); handle.focus(window, cx); @@ -1261,12 +1370,9 @@ impl Sidebar { true } - /// Rebuild the sidebar sections: pinned tasks first, then the active - /// inbox, then the settled rest. Rows are one flat list across - /// projects and each row names its project. The order is derived, - /// never written back, and changes only when activity or the user - /// changes it. Called when sessions, roots, names, or the filter - /// change, not per render. + /// Rebuild pinned tasks and project folders, each newest activity + /// first. Called when sessions, roots, names, or the filter change, + /// not per render. fn rebuild_sections(&mut self) { self.sync_rows(); let filter = self.filter.as_str(); @@ -1274,8 +1380,7 @@ impl Sidebar { let scoped = self.project_filter.as_deref(); let pinned_ids: HashSet<&str> = self.pinned_tasks.iter().map(String::as_str).collect(); let mut pinned = Vec::new(); - let mut active = Vec::new(); - let mut settled = Vec::new(); + let mut listed = Vec::new(); let mut archived = Vec::new(); let mut session_roots: Vec<&str> = Vec::new(); let mut seen_roots: HashSet<&str> = HashSet::new(); @@ -1316,27 +1421,10 @@ impl Sidebar { archived.push(index); } else if pinned_ids.contains(session.id.as_str()) { pinned.push(index); - } else if self.session_is_active(session) { - active.push(index); } else { - settled.push(index); + listed.push(index); } } - let by_update = |ix: &usize| { - std::cmp::Reverse( - self.sessions - .get(*ix) - .map(|session| session.updated_ms) - .unwrap_or(0), - ) - }; - pinned.sort_by_cached_key(by_update); - active.sort_by_cached_key(by_update); - settled.sort_by_cached_key(by_update); - self.pinned_rows = pinned; - self.active_rows = active; - self.settled_rows = settled; - self.archived_rows = archived; self.scope_label = self .project_filter .as_deref() @@ -1347,17 +1435,73 @@ impl Sidebar { let mut fresh_roots: Vec<&str> = session_roots .iter() .copied() + .chain(self.project_metadata.keys().map(String::as_str)) .filter(|root| !recent_roots.contains(root)) .collect(); fresh_roots.sort_unstable(); + fresh_roots.dedup(); self.switcher_roots = self .recent_roots .iter() .map(String::as_str) .chain(fresh_roots.iter().copied()) - .filter(|root| seen_roots.contains(root) || recent_roots.contains(root)) + .filter(|root| !self.removed_projects.contains(*root)) .map(|root| SwitcherRoot::new(self.project_target(root), self.root_name(root).into())) .collect(); + let order = |&ix: &usize| std::cmp::Reverse(self.sessions[ix].updated_ms); + pinned.sort_by_key(order); + listed.sort_by_key(order); + if self.computers.len() <= 1 { + archived.sort_by_key(order); + } + if self.computers.len() > 1 { + let (active, settled) = listed + .into_iter() + .partition(|&ix| self.session_is_active(&self.sessions[ix])); + self.sections = vec![ + (SidebarSection::Active, active), + (SidebarSection::Settled, settled), + ]; + } else { + let folder_of: HashMap<&str, usize> = self + .switcher_roots + .iter() + .enumerate() + .map(|(index, root)| (root.root.as_str(), index)) + .collect(); + // Each root's newest activity, pinned tasks included, and + // its unpinned rows. + let mut folders = vec![(None, Vec::new()); self.switcher_roots.len()]; + for (position, &ix) in pinned.iter().chain(&listed).enumerate() { + let session = &self.sessions[ix]; + if let Some(&folder) = folder_of.get(session.project_root.as_str()) { + let (latest, rows) = &mut folders[folder]; + *latest = (*latest).max(Some(session.updated_ms)); + if position >= pinned.len() { + rows.push(ix); + } + } + } + let mut folders: Vec<_> = folders + .into_iter() + .enumerate() + .filter(|(index, (_, rows))| { + let root = &self.switcher_roots[*index]; + scoped.is_none_or(|scoped| scoped == root.root) + && (!rows.is_empty() + || !filtering + || root.name.to_lowercase().contains(filter)) + }) + .collect(); + // The folder with the latest activity leads. + folders.sort_by_key(|(_, (latest, _))| std::cmp::Reverse(*latest)); + self.sections = folders + .into_iter() + .map(|(index, (_, rows))| (SidebarSection::Folder(index), rows)) + .collect(); + } + self.pinned_rows = pinned; + self.archived_rows = archived; self.rebuild_computer_switcher_roots(); self.rebuild_entries(); } @@ -1373,48 +1517,73 @@ impl Sidebar { /// span that changed, so the scroll position and the measured /// heights of untouched rows survive every rebuild. fn rebuild_entries(&mut self) { - let mut entries = vec![ - SidebarEntry::NewTask, - SidebarEntry::Search, - SidebarEntry::ProjectsHeader, - ]; - let tasks = |entries: &mut Vec, - section: SidebarSection, - rows: &[usize], - expanded: bool| { - if rows.is_empty() { + self.hovered_entry = None; + self.hovered_section = None; + let mut entries = vec![SidebarEntry::NewTask, SidebarEntry::Search]; + let empty_known = self.filter.is_empty() && self.pages.non_archived_complete(); + let pinned_roots: HashSet<&str> = self + .pinned_rows + .iter() + .map(|&ix| self.sessions[ix].project_root.as_str()) + .collect(); + let tasks = |entries: &mut Vec, section: SidebarSection, rows: &[usize]| { + if rows.is_empty() && !matches!(section, SidebarSection::Folder(_)) { return; } entries.push(SidebarEntry::SectionLabel(section)); - if expanded { + if self.section_expanded(section) { + if rows.is_empty() + && empty_known + && let SidebarSection::Folder(index) = section + && !pinned_roots.contains(self.switcher_roots[index].root.as_str()) + { + entries.push(SidebarEntry::FolderEmpty(index)); + } entries.extend(rows.iter().map(|&session| { SidebarEntry::Task(SidebarTaskEntry { session, pinned: section == SidebarSection::Pinned, - settled: section == SidebarSection::Settled, + settled: section != SidebarSection::Pinned + && !self.session_is_active(&self.sessions[session]), archived: false, + gap_after: false, }) })); } }; - tasks( - &mut entries, - SidebarSection::Pinned, - &self.pinned_rows, - self.section_expanded(SidebarSection::Pinned), - ); - tasks( - &mut entries, - SidebarSection::Active, - &self.active_rows, - self.section_expanded(SidebarSection::Active), - ); - tasks( - &mut entries, - SidebarSection::Settled, - &self.settled_rows, - self.section_expanded(SidebarSection::Settled), - ); + if self.computers.len() > 1 { + entries.push(SidebarEntry::ProjectsHeader); + tasks(&mut entries, SidebarSection::Pinned, &self.pinned_rows); + for (section, rows) in &self.sections { + tasks(&mut entries, *section, rows); + } + } else { + // Pinned work sits above the Projects header, which folds the rest. + tasks(&mut entries, SidebarSection::Pinned, &self.pinned_rows); + if self.section_expanded(SidebarSection::Pinned) + && self.pages.has_more(PageKind::Pinned) + { + entries.push(SidebarEntry::LoadMore(PageKind::Pinned)); + } + entries.push(SidebarEntry::ProjectsHeader); + if self.projects_expanded { + for (section, rows) in &self.sections { + tasks(&mut entries, *section, rows); + } + if self.pages.enabled { + for kind in [ + PageKind::Active, + PageKind::Settled, + PageKind::Search, + PageKind::Projects, + ] { + if self.pages.has_more(kind) { + entries.push(SidebarEntry::LoadMore(kind)); + } + } + } + } + } if !self.archived_rows.is_empty() || (self.pages.enabled && self.filter.is_empty()) { entries.push(SidebarEntry::ArchivedHeader); if self.archived_expanded { @@ -1424,11 +1593,12 @@ impl Sidebar { archived: true, pinned: false, settled: false, + gap_after: false, }) })); } } - if self.pages.enabled { + if self.computers.len() > 1 && self.pages.enabled { for (kind, expanded) in [ (PageKind::Pinned, self.pinned_expanded), (PageKind::Active, self.active_expanded), @@ -1440,12 +1610,19 @@ impl Sidebar { entries.push(SidebarEntry::LoadMore(kind)); } } - } - if !entries.iter().any(|entry| { - matches!(entry, SidebarEntry::Task(_)) - || (!self.pages.enabled && matches!(entry, SidebarEntry::ArchivedHeader)) - }) && !self.pages.any_loading() - { + } else if self.archived_expanded && self.pages.has_more(PageKind::Archived) { + entries.push(SidebarEntry::LoadMore(PageKind::Archived)); + } + // Folded sections still hold tasks; only an empty list says so. + let empty = if self.computers.len() > 1 { + !entries.iter().any(|entry| { + matches!(entry, SidebarEntry::Task(_)) + || (!self.pages.enabled && matches!(entry, SidebarEntry::ArchivedHeader)) + }) + } else { + self.pinned_rows.is_empty() && self.archived_rows.is_empty() && self.sections.is_empty() + }; + if empty && !self.pages.any_loading() { entries.push(SidebarEntry::Empty(if self.filter.is_empty() { SidebarEmpty::NoTasks } else { @@ -1455,6 +1632,29 @@ impl Sidebar { if self.computers.len() > 1 && self.project_filter.is_none() { entries = self.computer_entries(); } + for ix in 0..entries.len() { + let last = + !matches!(entries.get(ix + 1), Some(SidebarEntry::Task(task)) if !task.archived); + if let SidebarEntry::Task(task) = &mut entries[ix] { + task.gap_after = !task.archived && last; + } + } + let mut section = None; + self.entry_sections = entries + .iter() + .map(|entry| { + match entry { + SidebarEntry::ProjectsHeader => section = Some(SidebarHoverSection::Projects), + SidebarEntry::SectionLabel(label) => { + section = Some(SidebarHoverSection::Section(*label)) + } + SidebarEntry::ArchivedHeader => section = Some(SidebarHoverSection::Archived), + SidebarEntry::Task(_) | SidebarEntry::OtherTask(_) => {} + _ => section = None, + } + section + }) + .collect(); let heading_gaps = entries .iter() .enumerate() @@ -1520,6 +1720,12 @@ impl Sidebar { .get(task.session) .is_some_and(|session| session.id == *id)) }), + RenameTarget::Project(target) if self.computers.len() <= 1 => { + self.entries.iter().position(|entry| { + matches!(entry, SidebarEntry::SectionLabel(SidebarSection::Folder(index)) + if self.switcher_roots.get(*index).is_some_and(|root| root.target == *target)) + }) + } RenameTarget::Project(_) => self .entries .iter() @@ -1658,6 +1864,10 @@ impl Sidebar { /// Whether a collapsible section shows its rows. fn section_expanded(&self, section: SidebarSection) -> bool { match section { + SidebarSection::Folder(index) => self + .switcher_roots + .get(index) + .is_none_or(|root| !self.collapsed_roots.contains(&root.root)), SidebarSection::Pinned => self.pinned_expanded, SidebarSection::Active => self.active_expanded, SidebarSection::Settled => self.settled_expanded, @@ -1665,25 +1875,75 @@ impl Sidebar { } fn toggle_section_expanded(&mut self, section: SidebarSection, cx: &mut Context) { - match section { - SidebarSection::Pinned => self.pinned_expanded = !self.pinned_expanded, - SidebarSection::Active => self.active_expanded = !self.active_expanded, - SidebarSection::Settled => self.settled_expanded = !self.settled_expanded, + self.set_section_expanded(section, !self.section_expanded(section), cx); + } + + fn set_section_expanded( + &mut self, + section: SidebarSection, + expanded: bool, + cx: &mut Context, + ) { + if let SidebarSection::Folder(index) = section { + let Some(root) = self.switcher_roots.get(index).map(|root| root.root.clone()) else { + return; + }; + if expanded { + self.collapsed_roots.remove(&root); + } else { + self.collapsed_roots.insert(root); + } + } else { + match section { + SidebarSection::Pinned => self.pinned_expanded = expanded, + SidebarSection::Active => self.active_expanded = expanded, + SidebarSection::Settled => self.settled_expanded = expanded, + SidebarSection::Folder(_) => unreachable!(), + } } self.rebuild_entries(); cx.notify(); } - /// Open or close the project switcher menu from its header. - pub(super) fn toggle_switcher_menu(&mut self, cx: &mut Context) { + /// Show or fold one folder group; `None` flips it. + pub(super) fn set_folder_expanded( + &mut self, + root: &str, + expanded: Option, + cx: &mut Context, + ) { + let Some(index) = self + .switcher_roots + .iter() + .position(|item| item.root == root) + else { + return; + }; + let section = SidebarSection::Folder(index); + let expanded = expanded.unwrap_or(!self.section_expanded(section)); + self.set_section_expanded(section, expanded, cx); + } + + /// Show or fold the whole list under the Projects header. + pub(super) fn set_projects_expanded(&mut self, expanded: bool, cx: &mut Context) { + if self.projects_expanded != expanded { + self.projects_expanded = expanded; + self.rebuild_entries(); + cx.notify(); + } + } + + /// Open or close the Projects header's menu from its button. + pub(super) fn toggle_header_menu(&mut self, cx: &mut Context) { self.project_popup.close(cx); self.popup.toggle(SidebarPopup::Switcher, cx); } - /// Open or close the project switcher from the keyboard. Opening - /// highlights the current scope, like a native popup button. - pub(super) fn set_switcher_menu_open(&mut self, open: bool, cx: &mut Context) { - if open == self.switcher_menu_open() { + /// Open or close the Projects header's menu from the keyboard. + /// Opening the switcher highlights the current scope, like a native + /// popup button. + pub(super) fn set_header_menu_open(&mut self, open: bool, cx: &mut Context) { + if open == self.header_menu_open() { return; } self.project_popup.close(cx); @@ -1703,11 +1963,21 @@ impl Sidebar { } } + /// Activate the Projects header: paired computers open the switcher; + /// a single computer folds its project list. + pub(super) fn activate_projects(&mut self, cx: &mut Context) { + if self.computers.len() > 1 { + self.set_header_menu_open(!self.header_menu_open(), cx); + } else { + self.set_projects_expanded(!self.projects_expanded, cx); + } + } + /// Scope the sidebar to one project, or show every project again. pub(super) fn set_project_filter(&mut self, root: Option, cx: &mut Context) { self.project_filter = root.filter(|root| !root.is_empty()); self.project_popup.close(cx); - if self.switcher_menu_open() { + if self.popup.is_open(&SidebarPopup::Switcher) { self.popup.close(cx); } if self.pages.enabled { @@ -1744,9 +2014,8 @@ impl Sidebar { let chat = self.chat.clone(); let sidebar = cx.entity().downgrade(); let escape = sidebar.clone(); - // A project's field sits in the switcher, which keeps the keyboard - // when the rename ends. - let in_switcher = matches!(target, RenameTarget::Project(_)); + // Switcher renames keep the menu's keyboard focus. + let in_switcher = self.renames_in_switcher(&target); let application_vim_enabled = self.application_vim_enabled; let input = cx.new(move |cx| { let mut input = TextInput::new("Name", cx) @@ -1799,6 +2068,13 @@ impl Sidebar { cx.notify(); } + /// Whether a rename's field sits in the switcher menu: a project's, + /// while paired computers list their projects there. A folder + /// header's field ends the way a task's does. + fn renames_in_switcher(&self, target: &RenameTarget) -> bool { + matches!(target, RenameTarget::Project(_)) && self.computers.len() > 1 + } + /// Drop the rename field. Reports whether one was open. pub(super) fn cancel_rename(&mut self, cx: &mut Context) -> bool { let Some(target) = self.rename.take() else { @@ -1808,19 +2084,18 @@ impl Sidebar { true } - /// Put things back once a rename is over: the row's height, and the - /// switcher's focus, which a project's field had. + /// Restore the row's height and, for switcher renames, menu focus. fn end_rename(&mut self, target: &RenameTarget, cx: &mut Context) { self.rename_input = None; self.rename_focus_out = None; self.remeasure(target); - if matches!(target, RenameTarget::Project(_)) { + if self.renames_in_switcher(target) { self.popup.refocus(cx); } cx.notify(); } - /// The pointer or focus left a task's rename field: commit it, unless + /// The pointer or focus left an inline rename field: commit it, unless /// the field's own right-click menu took them (Paste, Cut). fn leave_rename(&mut self, cx: &mut Context) { let menu_open = self @@ -1930,9 +2205,26 @@ 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) { - if !self.project_popup.toggle(root.clone(), cx) { - return; + self.project_menu_at = None; + if self.project_popup.toggle(root.clone(), cx) { + self.load_menu_trust(root, cx); } + } + + /// Open a folder header's project menu at the pointer. + fn open_project_menu_at( + &mut self, + root: &ProjectTarget, + position: gpui::Point, + cx: &mut Context, + ) { + self.hover_scroll_top = self.list.logical_scroll_top(); + self.project_menu_at = Some(position); + self.project_popup.open(root.clone(), cx); + self.load_menu_trust(root, cx); + } + + fn load_menu_trust(&mut self, root: &ProjectTarget, cx: &mut Context) { self.menu_trust = None; if root.computer.as_deref() != self.backend.remote_host() { return; @@ -2107,7 +2399,7 @@ impl Sidebar { .map(|task| TaskRung::of(task).step().row_label) } - /// The overflow menu of one switcher project row. + /// The menu of one project, on a switcher row or a folder header. fn project_menu_items(&self, target: &ProjectTarget) -> Vec { let rename_target = target.clone(); let root = &target.path; @@ -2193,7 +2485,13 @@ impl Sidebar { SidebarEntry::NewTask => Some(SidebarTarget::NewTask), SidebarEntry::Search => Some(SidebarTarget::Search), SidebarEntry::ProjectsHeader => Some(SidebarTarget::Projects), - SidebarEntry::SectionLabel(_) | SidebarEntry::Empty(_) => None, + SidebarEntry::SectionLabel(SidebarSection::Folder(index)) => self + .switcher_roots + .get(index) + .map(|root| SidebarTarget::Folder(root.root.clone())), + SidebarEntry::SectionLabel(_) + | SidebarEntry::Empty(_) + | SidebarEntry::FolderEmpty(_) => None, SidebarEntry::LoadMore(kind) => { Some(SidebarTarget::LoadMore(format!("page:{}", kind.label()))) } @@ -2267,7 +2565,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 @@ -2285,7 +2583,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 } @@ -2297,24 +2595,30 @@ 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); } } /// Fold or unfold the selected target where that means something. pub(super) fn vim_set_expanded(&mut self, expanded: bool, cx: &mut Context) { match self.vim_selected.clone() { - Some(SidebarTarget::Projects) => self.set_switcher_menu_open(expanded, cx), + Some(SidebarTarget::Projects) if self.computers.len() > 1 => { + self.set_header_menu_open(expanded, cx) + } + Some(SidebarTarget::Projects) => self.set_projects_expanded(expanded, cx), + Some(SidebarTarget::Folder(root)) => { + self.set_folder_expanded(&root, Some(expanded), cx) + } Some(SidebarTarget::Archived) => self.set_archived_expanded(expanded, cx), _ => {} } - self.vim_reveal(); + self.vim_reveal(cx); } fn vim_reconcile(&mut self) { @@ -2415,6 +2719,7 @@ impl Sidebar { ) -> AnyElement { let selected = self.selected.as_deref(); let chat = self.chat.clone(); + // Adjacent folded headings keep only their pill margins. let heading_gap = self.heading_gaps.get(ix).copied().unwrap_or(false); let entry = match self.entries.get(ix).copied() { Some( @@ -2487,23 +2792,38 @@ impl Sidebar { .into_any_element() } Some(SidebarEntry::ProjectsHeader) => self.render_projects_header(window, cx), + Some(SidebarEntry::FolderEmpty(index)) => { + let id = self.switcher_roots[index].empty_id.clone(); + return div() + .id(id.clone()) + .w_full() + .pl( + (FOLDER_HEADER_PADDING + FOLDER_HEADER_GAP).to_pixels(window.rem_size()) + + FOLDER_ICON_SIZE, + ) + .pr_6() + .py_1() + .text_sm() + .text_color(gpui::rgb(theme::text_muted())) + .child(div().child("No tasks")) + .into_any_element(); + } Some(SidebarEntry::Task(task)) => { let application_selected = self.vim_selects_row(ix); let row = self.render_task_row(task, selected, application_selected, window, cx); - let last_of_section = !task.archived - && !matches!( - self.entries.get(ix + 1), - Some(SidebarEntry::Task(task)) if !task.archived - ); div() - .when(last_of_section, |row| row.mb_2()) + .when(task.gap_after, |row| row.mb_2()) .child(row) .into_any_element() } + Some(SidebarEntry::SectionLabel(SidebarSection::Folder(index))) => { + self.render_folder_header(index, window, cx) + } Some(SidebarEntry::SectionLabel(section)) => { let expanded = self.section_expanded(section); div() .id(gpui::SharedString::from(section.element_id())) + .debug_selector(|| section.element_id().to_string()) .w_auto() .mx_2() .my(px(1.)) @@ -2521,16 +2841,13 @@ impl Sidebar { }) .on_click(cx.listener(move |this, _event, _window, cx| { this.toggle_section_expanded(section, cx); + this.hover_heading(SidebarEntry::SectionLabel(section), cx); })) .child(section_label(section.label())) - .child(icon( - if expanded { - "chevron-down" - } else { - "chevron-right" - }, - px(14.), - theme::text_secondary(), + .child(self.section_chevron( + expanded, + section.element_id(), + SidebarHoverSection::Section(section), )) .into_any_element() } @@ -2594,6 +2911,7 @@ impl Sidebar { }; div() .id("archived-toggle") + .debug_selector(|| "archived-toggle".into()) .role(gpui::Role::Button) .aria_label(format!("Archived tasks {count_label}")) .aria_expanded(expanded) @@ -2614,6 +2932,7 @@ impl Sidebar { }) .on_click(cx.listener(|this, _event, _window, cx| { this.toggle_archived_visibility(cx); + this.hover_heading(SidebarEntry::ArchivedHeader, cx); })) .child(section_label("Archived")) .child( @@ -2622,14 +2941,10 @@ impl Sidebar { .text_color(gpui::rgb(theme::text_muted())) .child(count_label), ) - .child(icon( - if expanded { - "chevron-down" - } else { - "chevron-right" - }, - px(14.), - theme::text_secondary(), + .child(self.section_chevron( + expanded, + "archived-toggle", + SidebarHoverSection::Archived, )) .into_any_element() } @@ -2643,14 +2958,27 @@ impl Sidebar { } else { entry }; - if !self.application_vim_enabled { - return entry; - } let application_selected = self.vim_selects_row(ix); - let application_target = self.vim_target(ix).cloned(); + let application_target = self + .application_vim_enabled + .then(|| self.vim_target(ix).cloned()) + .flatten(); let chat = self.chat.clone(); div() + .id(("sidebar-entry", ix as u64)) .w_full() + .on_hover(cx.listener(move |this, hovered: &bool, window, cx| { + if *hovered { + this.set_hovered_entry(Some(ix), cx); + } else if this.hovered_entry == Some(ix) { + // Let another row's enter run before clearing this hover. + cx.defer_in(window, move |this, _window, cx| { + if this.hovered_entry == Some(ix) { + this.set_hovered_entry(None, cx); + } + }); + } + })) .when_some(application_target, |row, target| { row.on_mouse_down(gpui::MouseButton::Left, move |_event, window, cx| { if let Some(chat) = chat.upgrade() { @@ -2670,13 +2998,248 @@ impl Sidebar { .into_any_element() } - /// The project switcher row: names the scope the task list shows and - /// opens the menu that picks it. The overflow menu of one project - /// hangs off its row inside the switcher. + /// A heading stays under the pointer when its own rows fold or unfold. + fn hover_heading(&mut self, heading: SidebarEntry, cx: &mut Context) { + let entry = self.entries.iter().position(|entry| *entry == heading); + self.set_hovered_entry(entry, cx); + } + + fn set_hovered_entry(&mut self, entry: Option, cx: &mut Context) { + self.hover_scroll_top = self.list.logical_scroll_top(); + self.hovered_entry = entry; + let section = entry.and_then(|entry| self.entry_sections.get(entry).copied().flatten()); + if self.hovered_section != section { + self.hovered_section = section; + cx.notify(); + } + } + + #[cfg(test)] + pub(super) fn section_hover_is_clear_for_test(&self) -> bool { + self.hovered_entry.is_none() && self.hovered_section.is_none() + } + + fn section_hovered(&self, section: SidebarHoverSection) -> bool { + self.hovered_section == Some(section) + || (section == SidebarHoverSection::Projects + && matches!(self.hovered_section, Some(SidebarHoverSection::Section(label)) if label != SidebarSection::Pinned)) + } + + /// Reserve the chevron's space; a folded section always shows it. + fn section_chevron( + &self, + expanded: bool, + id: &'static str, + section: SidebarHoverSection, + ) -> AnyElement { + let chevron = div() + .id((id, 0_u64)) + .flex_none() + .size(px(14.)) + .flex() + .items_center() + .opacity(if !expanded || self.section_hovered(section) { + 1. + } else { + 0. + }) + .child(icon( + if expanded { + "chevron-down" + } else { + "chevron-right" + }, + px(14.), + theme::text_secondary(), + )); + chevron.into_any_element() + } + + /// The Projects header: its label folds the list under it, and its + /// button adds a project. fn render_projects_header(&self, window: &mut Window, cx: &mut Context) -> AnyElement { - let open = self.switcher_menu_open(); + if self.computers.len() > 1 { + return self.render_switcher_header(window, cx); + } + let add = self.new_project_button().on_click( + cx.listener(|_this, _event, _window, cx| cx.emit(SidebarEvent::ChooseProject)), + ); + let expanded = self.projects_expanded; + div() + .group("projects-header-actions") + .mt_3() + .mb_1() + .px_4() + .w_full() + .flex() + .items_center() + .gap_1() + .child( + div() + .id("projects-header") + .role(gpui::Role::Button) + .aria_label("Projects") + .aria_expanded(expanded) + .debug_selector(|| "projects-header".to_string()) + .flex_1() + .flex() + .items_center() + .gap_1() + .py_1() + .rounded(theme::RADIUS_SM) + .hover(|style| style.cursor_pointer()) + .on_click(cx.listener(|this, _event, _window, cx| { + this.set_projects_expanded(!this.projects_expanded, cx); + this.hover_heading(SidebarEntry::ProjectsHeader, cx); + })) + .child(section_label("Projects")) + .child(self.section_chevron( + expanded, + "projects-header-actions", + SidebarHoverSection::Projects, + )), + ) + .child(add) + .into_any_element() + } + + /// Add project stays visible until there is a project to browse. + fn new_project_button(&self) -> gpui::Stateful
{ + header_button("new-project", "folder-plus", "New project").when( + !self.switcher_roots.is_empty(), + |button| { + button + .opacity(0.) + .group_hover("projects-header-actions", |style| style.opacity(1.)) + }, + ) + } + + /// A folder group's header: the project's name, which folds its tasks, + /// with its overflow menu and a new task in it on hover. + fn render_folder_header( + &self, + index: usize, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let Some(root) = self.switcher_roots.get(index) else { + return div().into_any_element(); + }; + let section = SidebarSection::Folder(index); + let expanded = self.section_expanded(section); + let rename_field = self.project_rename_field(&root.target).map(|field| { + field.on_mouse_down_out(cx.listener(|this, _event, _window, cx| { + this.leave_rename(cx); + })) + }); + let renaming = rename_field.is_some(); + // While a sidebar menu is open, only its owning header shows actions. + let menu_open = self.project_popup.is_open(&root.target); + let hover = !renaming && !self.sidebar_menu_open(); + let new_task = root.target.clone(); + let menu_target = root.target.clone(); + let selector = root.folder_id.clone(); + let header = div() + .debug_selector(|| selector.to_string()) + .id(root.folder_id.clone()) + .role(gpui::Role::Button) + .aria_label(root.name.clone()) + .aria_expanded(expanded) + .group(root.row_group.clone()) + .relative() + .w_auto() + .mx_2() + .my(px(1.)) + .flex() + .items_center() + .gap(FOLDER_HEADER_GAP) + .px(FOLDER_HEADER_PADDING - gpui::rems(0.5)) + .py_1() + .rounded(ROW_RADIUS) + .text_sm() + .text_color(gpui::rgb(theme::text_sidebar())) + .when(menu_open, |row| { + row.bg(gpui::rgb(theme::bg_sidebar_row_hover())) + }) + .when(hover, |row| { + row.hover(|style| { + style + .bg(gpui::rgb(theme::bg_sidebar_row_hover())) + .cursor_pointer() + }) + }) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.toggle_section_expanded(section, cx); + this.hover_heading(SidebarEntry::SectionLabel(section), cx); + })) + .when(!renaming, |row| { + row.on_mouse_down( + gpui::MouseButton::Right, + cx.listener(move |this, event: &gpui::MouseDownEvent, _window, cx| { + this.open_project_menu_at(&menu_target, event.position, cx); + }), + ) + }) + .child(icon( + if expanded { "folder-open" } else { "folder" }, + FOLDER_ICON_SIZE, + theme::text_secondary(), + )) + .map(|row| match rename_field { + Some(field) => row.child(field), + None => row.child( + div() + .flex_1() + .min_w_0() + .line_clamp(1) + .text_ellipsis() + .child(root.name.clone()), + ), + }) + .when(hover || menu_open, |row| { + row.child( + hover_actions(&root.row_group, theme::bg_sidebar_row_hover()) + .when(menu_open, |strip| { + strip.bg(gpui::rgb(theme::bg_sidebar_row_hover())) + }) + .right_0() + .rounded_r(ROW_RADIUS) + .pr_2() + .child(self.project_menu_trigger(root, window, cx)) + .child( + row_button( + root.new_task_id.clone(), + &root.row_group, + "square-pen", + "New task", + ) + .aria_label(root.new_task_label.clone()) + .tooltip(widgets::tooltip(root.new_task_label.clone(), None)) + .on_click(cx.listener( + move |_this, _event, _window, cx| { + cx.stop_propagation(); + cx.emit(SidebarEvent::Navigate(Navigate { + computer: new_task.computer.clone(), + destination: Destination::DraftProject( + new_task.path.clone(), + ), + })); + }, + )), + ), + ) + }); + header.into_any_element() + } + + /// The project switcher row of paired computers: names the scope the + /// task list shows and opens the menu that picks it. The overflow menu + /// of one project hangs off its row inside the switcher. + fn render_switcher_header(&self, window: &mut Window, cx: &mut Context) -> AnyElement { + let open = self.popup.is_open(&SidebarPopup::Switcher); let menu = open.then(|| { - let menu = self.switcher_menu(window, cx); + let menu = self.computer_switcher_menu(window, cx); self.popup.render(menu, Placement::BelowStart, window, cx) }); let header = div() @@ -2727,104 +3290,29 @@ impl Sidebar { .child( self.popup .trigger(SidebarPopup::Switcher, header, cx, |this, _, cx| { - this.toggle_switcher_menu(cx) + this.toggle_header_menu(cx) }), ) .child( // A sibling over the header's end, not a button inside a // button. It blocks the pointer, so a press on it never // reaches the header behind it. - div() - .id("new-project") - .role(gpui::Role::Button) - .aria_label("New project") + header_button("new-project", "folder-plus", "New project") .occlude() .absolute() .right_4() .top_0() .bottom_0() .my_auto() - .size_6() - .flex() - .items_center() - .justify_center() - .rounded(theme::RADIUS_SM) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() - }) - .tooltip(widgets::tooltip("New project", None)) .on_click(cx.listener(|_this, _event, _window, cx| { cx.stop_propagation(); cx.emit(SidebarEvent::ChooseProject); - })) - .child(icon("folder-plus", px(16.), theme::text_secondary())), + })), ) .children(menu) .into_any_element() } - /// The menu the project switcher opens: every project, each with an - /// overflow menu of its own, plus a way back to all projects. - fn switcher_menu(&self, window: &mut Window, cx: &mut Context) -> Menu { - if self.computers.len() > 1 { - return self.computer_switcher_menu(window, cx); - } - let mut menu = Menu::new("switcher-menu", px(260.)) - .label("Projects") - .max_height(px(360.)) - .application_vim(self.application_vim_enabled) - .item( - MenuItem::new( - ALL_PROJECTS_ID, - "All projects", - |this: &mut Self, _: &mut Window, cx: &mut Context| { - this.set_project_filter(None, cx); - }, - ) - .current(self.project_filter.is_none()), - ); - for root in &self.switcher_roots { - let pick = root.root.clone(); - let group = root.row_group.clone(); - let mut item = MenuItem::new( - root.row_id.clone(), - root.name.clone(), - move |this: &mut Self, _: &mut Window, cx: &mut Context| { - this.set_project_filter(Some(pick.clone()), cx); - }, - ) - .current(self.project_filter.as_deref() == Some(root.root.as_str())) - // The row's overflow button shows while the row is hovered. - .style(move |row| row.group(group)) - .trailing(self.project_menu_trigger(root, window, cx)); - if let Some(field) = self.project_rename_field(&root.target) { - item = item.content(field); - } - menu = menu.item(item); - } - if self.pages.has_more(PageKind::Projects) { - menu = menu.item(MenuItem::new( - "switcher-more-projects", - "Load more projects", - |this: &mut Self, _: &mut Window, cx: &mut Context| { - this.load_page(PageKind::Projects, cx) - }, - )); - } - menu.item( - MenuItem::new( - "switcher-new-project", - "New project…", - |_: &mut Self, _: &mut Window, cx: &mut Context| { - cx.emit(SidebarEvent::ChooseProject); - }, - ) - .icon("folder-plus"), - ) - } - /// A switcher row's overflow button, with the project's menu under it /// while open. fn project_menu_trigger( @@ -2844,23 +3332,30 @@ impl Sidebar { .into_iter() .map(menu_item), ); - self.project_popup - .render(menu, Placement::BelowEnd, window, cx) + let placement = self + .project_menu_at + .map_or(Placement::BelowEnd, Placement::At); + self.project_popup.render(menu, placement, window, cx) }); 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(self.computers.len() > 1 && !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) } @@ -2885,12 +3380,17 @@ impl Sidebar { let session = row.id.clone(); div() .relative() - .child(self.popup.trigger( - key, - row_menu_button(row.menu_id.clone(), &row.group, "More", open), - cx, - move |this, _, cx| this.toggle_task_menu(session.clone(), cx), - )) + .child( + self.popup.trigger( + key, + row_menu_button(row.menu_id.clone(), &row.group, "More", open) + .when(!open, |button| { + button.tooltip(widgets::tooltip("More", None)) + }), + cx, + move |this, _, cx| this.toggle_task_menu(session.clone(), cx), + ), + ) .children(menu) } @@ -2954,7 +3454,9 @@ impl Sidebar { pinned: is_pinned, settled: _, archived, + .. } = task; + let folder_task = self.computers.len() <= 1 && !is_pinned && !archived; let row = &self.rows[index]; let is_selected = selected == Some(&*row.id); let activity = (!archived) @@ -2979,6 +3481,7 @@ impl Sidebar { .mx_2() .my(px(1.)) .id(row.element_id.clone()) + .debug_selector(|| row.element_id.to_string()) .role(gpui::Role::ListBoxOption) .accessibility_id(row.id.to_string()) .aria_label(row.title.clone()) @@ -2990,7 +3493,14 @@ impl Sidebar { .items_center() .gap_1p5() .when(archived, |row| row.pl_4()) - .when(!archived, |row| row.pl_6()) + .when(!archived && !folder_task, |row| row.pl_6()) + .when(folder_task, |row| { + row.pl( + (FOLDER_HEADER_PADDING + FOLDER_HEADER_GAP - gpui::rems(0.5)) + .to_pixels(window.rem_size()) + + FOLDER_ICON_SIZE, + ) + }) .pr_4() .py_1() .rounded(ROW_RADIUS) @@ -3007,8 +3517,7 @@ impl Sidebar { .on_click(cx.listener(move |_this, _event, _window, cx| { cx.emit(SidebarEvent::Select(session_id.to_string())); })) - // The rename field takes the title's line; the project line - // stays under it. + // The rename field takes the title's line. .child( div() .flex_1() @@ -3019,20 +3528,6 @@ impl Sidebar { Some(field) => column.child(field), None => column .child(div().line_clamp(1).text_ellipsis().child(row.title.clone())), - }) - .when(self.computers.len() <= 1, |column| { - column.child( - div() - .text_xs() - .text_color(gpui::rgb(if is_selected { - theme::text_secondary() - } else { - theme::text_muted() - })) - .line_clamp(1) - .text_ellipsis() - .child(row.project_name.clone()), - ) }), ) .when_some(activity, |row_element, activity| { @@ -3062,20 +3557,12 @@ impl Sidebar { }) .when(!renaming, |el| { el.child( - div() - .absolute() - .top_0() - .bottom_0() + hover_actions(&row.group, hover_bg) .when(is_pinned, |overlay| overlay.right(px(26.))) .when(!is_pinned, |overlay| { overlay.right_0().rounded_r(ROW_RADIUS) }) - .flex() - .items_center() - .gap_1p5() - .pl_2() .pr_4() - .group_hover(row.group.clone(), |style| style.bg(gpui::rgb(hover_bg))) .child(self.task_menu_trigger(row, task, window, cx)) .child(row_action( row.rename_id.clone(), @@ -3114,9 +3601,13 @@ impl Render for Sidebar { // First, so the rename field's focus request below wins over a menu // handing focus back. self.sync_popup_focus(window, cx); - // A project's rename field lives in the switcher and ends with it, - // however the switcher closed. - if matches!(self.rename, Some(RenameTarget::Project(_))) && !self.switcher_menu_open() { + // Paired project renames end with their switcher. + if self + .rename + .as_ref() + .is_some_and(|target| self.renames_in_switcher(target)) + && !self.header_menu_open() + { self.rename = None; self.rename_input = None; } @@ -3125,10 +3616,13 @@ impl Render for Sidebar { if let Some(input) = self.rename_input.clone() { let handle = input.read(cx).focus_handle(cx); window.focus(&handle, cx); - // A project's field ends with the switcher around it; a - // task's field ends where focus goes next. Deferred, so the - // commit never drops this subscription inside its own call. - if matches!(self.rename, Some(RenameTarget::Task(_))) { + // Inline fields commit on blur; switcher fields end with + // their menu. Defer so the subscription is not dropped here. + if self + .rename + .as_ref() + .is_some_and(|target| !self.renames_in_switcher(target)) + { self.rename_focus_out = Some( cx.on_focus_out(&handle, window, |_this, _event, window, cx| { @@ -3178,6 +3672,11 @@ impl Render for Sidebar { .child( div() .id("session-list") + .on_hover(cx.listener(|this, hovered: &bool, _window, cx| { + if !*hovered { + this.set_hovered_entry(None, cx); + } + })) .role(gpui::Role::ListBox) .aria_label("Tasks") .aria_orientation(gpui::Orientation::Vertical) @@ -3211,6 +3710,20 @@ fn activity_indicator(spinner_id: &SharedString, activity: SessionActivity) -> A } } +/// The strip over a row's end that holds its hover buttons, with the +/// row's hover color behind them so they cover the title. +fn hover_actions(group: &SharedString, hover_bg: u32) -> Div { + div() + .absolute() + .top_0() + .bottom_0() + .flex() + .items_center() + .gap_1p5() + .pl_2() + .group_hover(group.clone(), |style| style.bg(gpui::rgb(hover_bg))) +} + /// Small icon button that shows only while the pointer is over its row. pub(super) fn row_action( id: SharedString, @@ -3225,22 +3738,43 @@ pub(super) fn row_action( } /// A row's overflow button: [`row_action`]'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. Callers may add a tooltip while closed. 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())) + }) +} + +/// An icon button of the Projects header. +fn header_button( + id: &'static str, + icon_name: &'static str, + label: &'static str, +) -> gpui::Stateful
{ + div() + .id(id) + .role(gpui::Role::Button) + .aria_label(label) + .flex_none() + .size_6() + .flex() + .items_center() + .justify_center() + .rounded(theme::RADIUS_SM) + .hover(|style| { + style + .bg(gpui::rgb(theme::bg_sidebar_row_hover())) + .cursor_pointer() }) + .tooltip(widgets::tooltip(label, None)) + .child(icon(icon_name, px(16.), theme::text_secondary())) } /// The small icon button a sidebar row shows while it is hovered. @@ -3342,6 +3876,11 @@ mod tests { let chat = cx.new(|cx| ChatScreen::new_without_start(backend, "user".into(), cx)); let sidebar = chat.update(cx, |chat, _| chat.sidebar.clone()); sidebar.update(cx, |sidebar, cx| { + sidebar.computers = Arc::new(vec![ + Computer::pending("east".into(), "East".into(), None), + Computer::pending("west".into(), "West".into(), None), + ]); + sidebar.project_filter = Some("/tmp/proj".into()); sidebar.application_vim_enabled = true; sidebar.list = gpui::ListState::new(0, gpui::ListAlignment::Top, px(0.)); let sessions = (0..102) 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 7e0077475..b386f4a47 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs @@ -326,6 +326,9 @@ impl Sidebar { computers: Arc>, cx: &mut Context, ) { + let scope_changed = self.computers.len() > 1 + && computers.len() <= 1 + && self.project_filter.take().is_some(); let changed = computer_revision(&self.computers) != computer_revision(&computers) || existing_snapshot_changed(&self.computers, &computers); if changed { @@ -338,13 +341,14 @@ impl Sidebar { }); } self.computers = computers; - if changed { + if scope_changed && self.pages.enabled { + self.refresh_pages(cx); + } else if changed { self.revalidate_computer_pages(cx); } else { self.ensure_computer_pages(cx); } - self.rebuild_computer_switcher_roots(); - self.rebuild_entries(); + self.rebuild_sections(); self.list.remeasure_items(0..self.entries.len()); cx.notify(); } @@ -1091,6 +1095,7 @@ impl Sidebar { pinned: task.pinned_ms.is_some(), settled: state == AgentTaskState::Settled, archived: state == AgentTaskState::Archived, + gap_after: false, })); } } else { 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..2174c047c 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,359 @@ mod tests { assert!(published[0].is_empty()); } + #[gpui::test] + fn known_empty_folders_wait_for_complete_task_pages(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.enabled = true; + sidebar.set_application_vim_enabled(true, cx); + sidebar.set_project_name_for_test("/empty", "Empty"); + let note = SidebarEntry::FolderEmpty(0); + assert!( + !sidebar.entries.contains(¬e), + "unrequested pages are unknown" + ); + for kind in [PageKind::Active, PageKind::Settled, PageKind::Pinned] { + sidebar.pages.ready.insert(kind); + sidebar.rebuild_sections(); + assert_eq!(sidebar.entries.contains(¬e), kind == PageKind::Pinned); + } + let row = sidebar + .entries + .iter() + .position(|entry| *entry == note) + .unwrap(); + assert!(sidebar.vim_by_row[row].is_none()); + assert!(sidebar.entry_sections[row].is_none()); + for kind in [PageKind::Active, PageKind::Settled, PageKind::Pinned] { + sidebar.pages.next.insert(kind, "more".into()); + sidebar.rebuild_sections(); + assert!(!sidebar.entries.contains(¬e), "continuation is unknown"); + sidebar.pages.next.remove(&kind); + sidebar.pages.loading.insert(kind); + sidebar.rebuild_sections(); + assert!( + !sidebar.entries.contains(¬e), + "in-flight pages are unknown" + ); + sidebar.pages.loading.remove(&kind); + sidebar.pages.failed.insert(kind); + sidebar.rebuild_sections(); + assert!(!sidebar.entries.contains(¬e), "failed pages are unknown"); + sidebar.pages.failed.remove(&kind); + } + sidebar.pages.pin_batches.push_back(vec!["pin".into()]); + sidebar.rebuild_sections(); + assert!(!sidebar.entries.contains(¬e)); + sidebar.pages.pin_batches.clear(); + sidebar.set_folder_expanded("/empty", Some(false), cx); + assert!(!sidebar.entries.contains(¬e)); + sidebar.set_folder_expanded("/empty", Some(true), cx); + assert!(sidebar.entries.contains(¬e)); + sidebar.set_projects_expanded(false, cx); + assert!(!sidebar.entries.contains(¬e)); + sidebar.set_projects_expanded(true, cx); + sidebar.set_filter_for_test("empty", cx); + assert!( + !sidebar.entries.contains(¬e), + "search never shows the note" + ); + sidebar.set_filter_for_test("", cx); + assert!(sidebar.entries.contains(¬e)); + sidebar.apply_task_page( + PageKind::Pinned, + vec![TaskSummary { + project_root: "/empty".into(), + ..page_summary("pin") + }], + false, + ); + sidebar.rebuild_sections(); + assert!( + !sidebar.entries.contains(¬e), + "a pinned-only project is not empty" + ); + }); + } + + #[gpui::test] + fn scoped_pages_exclude_retained_tasks_from_other_projects(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.computers = Arc::new(vec![ + Computer::pending("one".into(), "One".into(), None), + Computer::pending("two".into(), "Two".into(), None), + ]); + for state in [ + AgentTaskState::Active, + AgentTaskState::Settled, + AgentTaskState::Archived, + ] { + sidebar.sessions = vec![ + AgentSessionSummary { + project_root: "/a".into(), + ..summary("running-a", AgentTaskState::Active) + }, + AgentSessionSummary { + project_root: "/a".into(), + ..summary("selected-a", state) + }, + ]; + sidebar.running = HashSet::from(["running-a".into()]); + sidebar.selected = Some("selected-a".into()); + sidebar.project_filter = Some("/b".into()); + sidebar.refresh_pages(cx); + assert_eq!(sidebar.sessions.len(), 2, "both A tasks remain cached"); + sidebar.pinned_tasks = vec!["running-a".into()]; + sidebar.apply_task_page( + PageKind::Active, + vec![TaskSummary { + project_root: "/b".into(), + ..page_summary("b") + }], + false, + ); + sidebar.archived_expanded = true; + sidebar.rebuild_sections(); + let shown: Vec<_> = sidebar + .entries + .iter() + .filter_map(|entry| match entry { + SidebarEntry::Task(task) => { + Some(sidebar.sessions[task.session].id.as_str()) + } + _ => None, + }) + .collect(); + assert_eq!(shown, ["b"]); + assert!(sidebar.pinned_rows.is_empty()); + assert!(sidebar.archived_rows.is_empty()); + assert!(sidebar.folder_names().is_empty()); + assert_eq!(sidebar.active_rows(), [2]); + assert!( + sidebar + .entries + .contains(&SidebarEntry::SectionLabel(SidebarSection::Active)) + ); + let generation = sidebar.pages.generation; + sidebar.set_computers(Arc::new(Vec::new()), cx); + assert!(sidebar.project_filter.is_none()); + assert_eq!(sidebar.pages.generation, generation + 1); + assert!(!sidebar.pages.contains("b", false)); + assert_eq!( + sidebar.sessions.len(), + 2, + "only live A tasks survive refresh" + ); + assert_eq!(sidebar.folder_names(), ["a"]); + sidebar.set_computers( + Arc::new(vec![ + Computer::pending("one".into(), "One".into(), None), + Computer::pending("two".into(), "Two".into(), None), + ]), + cx, + ); + } + }); + } + + #[gpui::test] + fn metadata_only_project_pages_add_visible_folders(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.enabled = true; + let project = |root: &str| maple_agent::store::ProjectSummary { + root: root.into(), + display_name: None, + name_revision: 1, + last_used_ms: 1, + is_default_workspace: false, + position: None, + truncated: false, + }; + sidebar.apply_project_page( + maple_agent::store::NavigationProjectPage { + projects: vec![project("/a")], + next_cursor: Some("next".into()), + }, + false, + cx, + ); + sidebar.rebuild_sections(); + assert_eq!(sidebar.folder_names(), ["a"]); + assert!( + sidebar + .entries + .contains(&SidebarEntry::SectionLabel(SidebarSection::Folder(0))) + ); + assert!( + sidebar + .entries + .contains(&SidebarEntry::LoadMore(PageKind::Projects)) + ); + sidebar.apply_project_page( + maple_agent::store::NavigationProjectPage { + projects: vec![project("/b")], + next_cursor: None, + }, + true, + cx, + ); + sidebar.rebuild_sections(); + assert_eq!(sidebar.folder_names(), ["a", "b"]); + assert_eq!( + sidebar + .entries + .iter() + .filter(|entry| matches!( + entry, + SidebarEntry::SectionLabel(SidebarSection::Folder(_)) + )) + .count(), + 2 + ); + assert!( + !sidebar + .entries + .iter() + .any(|entry| matches!(entry, SidebarEntry::Empty(_))) + ); + sidebar.apply_task_page( + PageKind::Active, + vec![TaskSummary { + project_root: "/b".into(), + ..page_summary("b-task") + }], + false, + ); + sidebar.rebuild_sections(); + assert_eq!(sidebar.folder_names(), ["b", "a"]); + sidebar.set_filter_for_test("a", cx); + assert_eq!(sidebar.folder_names(), ["a"]); + sidebar.set_filter_for_test("unmatched", cx); + assert!(sidebar.folder_names().is_empty()); + }); + } + + /// Folder projection must not consume cursors or strand tasks between pages. + #[gpui::test] + fn folder_pages_preserve_continuations_and_fold_boundaries(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() + }; + sidebar.selected = None; + let row = |id: &str, root: &str| TaskSummary { + project_root: root.into(), + ..page_summary(id) + }; + sidebar.apply_task_page(PageKind::Pinned, vec![row("pin", "/a")], false); + sidebar.apply_task_page(PageKind::Active, vec![row("a1", "/a")], false); + sidebar.apply_task_page( + PageKind::Settled, + vec![TaskSummary { + state: "settled".into(), + ..row("b1", "/b") + }], + false, + ); + sidebar.apply_task_page( + PageKind::Archived, + vec![TaskSummary { + state: "archived".into(), + ..row("old", "/a") + }], + false, + ); + for kind in [ + PageKind::Pinned, + PageKind::Active, + PageKind::Settled, + PageKind::Archived, + PageKind::Projects, + ] { + sidebar.pages.next.insert(kind, "continuation".into()); + } + sidebar.archived_expanded = true; + sidebar.rebuild_sections(); + assert_eq!(sidebar.folder_names(), ["a", "b"]); + assert_eq!(sidebar.pinned_rows.len(), 1); + assert_eq!(sidebar.archived_rows.len(), 1); + sidebar.apply_task_page( + PageKind::Active, + vec![row("a2", "/a"), row("c1", "/c")], + true, + ); + sidebar.rebuild_sections(); + assert_eq!(sidebar.folder_names(), ["a", "b", "c"]); + assert_eq!( + sidebar + .sections + .iter() + .map(|(_, rows)| rows.len()) + .sum::(), + 4 + ); + let more = |sidebar: &Sidebar| { + sidebar + .entries + .iter() + .filter_map(|entry| match entry { + SidebarEntry::LoadMore(kind) => Some(*kind), + _ => None, + }) + .collect::>() + }; + assert_eq!( + more(sidebar), + [ + PageKind::Pinned, + PageKind::Active, + PageKind::Settled, + PageKind::Projects, + PageKind::Archived + ] + ); + sidebar.set_projects_expanded(false, cx); + assert_eq!(more(sidebar), [PageKind::Pinned, PageKind::Archived]); + assert!( + !sidebar + .entries + .iter() + .any(|entry| matches!(entry, SidebarEntry::Empty(_))) + ); + sidebar.set_projects_expanded(true, cx); + assert_eq!(sidebar.pages.ids[&PageKind::Active], ["a1", "a2", "c1"]); + assert_eq!(sidebar.pages.next[&PageKind::Active], "continuation"); + }); + } + #[gpui::test] fn live_task_insert_preserves_the_loaded_cursor_boundary(cx: &mut gpui::TestAppContext) { let _settings = crate::settings::SETTINGS_IO_LOCK.lock(); @@ -152,7 +505,7 @@ mod tests { assert!(loaded.contains(id), "lost loaded boundary {id}"); assert!(sidebar.sessions.iter().any(|row| &row.id == id)); } - assert_eq!(sidebar.active_rows.len(), count + 2); + assert_eq!(sidebar.active_rows().len(), count + 2); assert_eq!(sidebar.pinned_rows.len(), 1); assert_eq!(sidebar.pages.next.contains_key(&PageKind::Active), has_more); assert!(!sidebar.pages.ids[&PageKind::Settled].contains(&"moved".into())); @@ -220,6 +573,13 @@ impl SidebarPages { || self.loading.contains(&kind) || (kind == PageKind::Pinned && !self.pin_batches.is_empty()) } + /// Full coverage for the tasks shown in project folders, including pins. + pub(super) fn non_archived_complete(&self) -> bool { + !self.enabled + || [PageKind::Active, PageKind::Settled, PageKind::Pinned] + .into_iter() + .all(|kind| self.ready.contains(&kind) && !self.has_more(kind)) + } pub(super) fn loading(&self, kind: PageKind) -> bool { self.loading.contains(&kind) } diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index c3b0d51ed..4110670cd 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -234,8 +234,17 @@ mod state_tests { // The projection never merges foreign records into the local runtime list. assert_eq!(this.sessions.len(), 1); assert_eq!(this.sessions[0].title, "Local task"); + this.sessions + .push(summary_at("other", "Other project", "/other")); + this.sync_sidebar(cx); + this.sidebar.update(cx, |sidebar, cx| { + sidebar.set_project_filter(Some("/tmp/proj".into()), cx); + assert!(sidebar.folder_names().is_empty()); + }); let local = this.computer_snapshot("This computer".into(), cx); this.set_computers(Arc::new(vec![local]), cx); + assert_eq!(this.sidebar.read(cx).project_filter(), None); + assert_eq!(this.sidebar.read(cx).folder_names(), ["proj", "other"]); assert!(!this.sidebar.read(cx).entries().iter().any(|e| matches!( e, SidebarEntry::OtherTask(_) | SidebarEntry::ComputerProject(_) @@ -5368,10 +5377,10 @@ mod state_tests { }); } - /// Tasks split into the inbox sections: pinned tasks first, the - /// active inbox, then the settled rest, each newest activity first. + /// Folder rows retain their active and settled ladder states, with + /// pinned work separate and newest activity first within each folder. #[gpui::test] - fn test_sections_split_pinned_active_and_settled(cx: &mut TestAppContext) { + fn test_folder_rows_keep_pinned_active_and_settled_states(cx: &mut TestAppContext) { cx.executor().allow_parking(); let screen = screen(cx); screen.update(cx, |this, cx| { @@ -5400,13 +5409,20 @@ mod state_tests { assert_eq!(this.sidebar_active(cx), vec![1]); assert_eq!(this.sidebar_settled(cx), vec![0]); - // Sections read newest activity first. + // Folders keep their tasks together in newest activity order. let mut older = summary_at("s3", "Older", "/b"); older.updated_ms = 5; this.sessions.push(older); this.sync_sidebar(cx); assert_eq!(this.sidebar_active(cx), vec![1, 2]); assert_eq!(this.sidebar_settled(cx), vec![0]); + use crate::ui::chat::sidebar::{SidebarEntry, SidebarSection}; + let sidebar = this.sidebar.read(cx); + assert_eq!(sidebar.section_rows(SidebarSection::Folder(0)), [1, 0]); + assert_eq!(sidebar.section_rows(SidebarSection::Folder(1)), [2]); + assert!(!sidebar.entries().iter().any(|entry| matches!( + entry, SidebarEntry::Task(task) if task.pinned + ))); }); } @@ -5457,26 +5473,164 @@ mod state_tests { }); } - /// The project filter scopes the sections to one project; the - /// switcher still lists every known root. + /// Grouped by folder, each project heads its own tasks, the project + /// with the latest activity first. Pinned tasks stay on top and + /// archived ones in their own tail, and rows drop the project line the + /// header already shows. #[gpui::test] - fn test_project_filter_scopes_the_sections(cx: &mut TestAppContext) { + fn test_folder_grouping_heads_each_project(cx: &mut TestAppContext) { + use crate::ui::chat::sidebar::{SidebarEntry, SidebarSection}; cx.executor().allow_parking(); let screen = screen(cx); screen.update(cx, |this, cx| { - this.sessions = vec![summary_at("s1", "One", "/a"), summary_at("s2", "Two", "/b")]; - this.settle_task("s1", cx); - assert_eq!(this.sidebar_settled(cx), vec![0]); - assert_eq!(this.sidebar_active(cx), vec![1]); - assert_eq!(this.switcher_root_paths(cx), ["/a", "/b"]); + let at = |id, root, updated_ms| AgentSessionSummary { + updated_ms, + ..summary_at(id, id, root) + }; + let mut archived = at("s5", "/a", 50); + archived.state = AgentTaskState::Archived; + this.sessions = vec![ + at("s1", "/a", 10), + at("s2", "/b", 15), + at("s3", "/a", 20), + at("s4", "/b", 40), + archived, + ]; + this.sync_sidebar(cx); + this.sidebar.update(cx, |sidebar, _cx| { + sidebar.set_pinned_tasks_for_test(vec!["s4".to_string()]); + assert_eq!(sidebar.folder_names(), ["b", "a"]); + assert_eq!(sidebar.pinned_rows(), [3]); + assert_eq!(sidebar.section_rows(SidebarSection::Folder(1)), [1]); + assert_eq!(sidebar.section_rows(SidebarSection::Folder(0)), [2, 0]); + assert_eq!(sidebar.archived_rows(), [4]); + let task = |session| { + SidebarEntry::Task(crate::ui::chat::sidebar::SidebarTaskEntry { + session, + pinned: session == 3, + settled: false, + archived: false, + gap_after: session != 2, + }) + }; + assert_eq!( + sidebar.entries(), + [ + SidebarEntry::NewTask, + SidebarEntry::Search, + SidebarEntry::SectionLabel(SidebarSection::Pinned), + task(3), + SidebarEntry::ProjectsHeader, + SidebarEntry::SectionLabel(SidebarSection::Folder(1)), + task(1), + SidebarEntry::SectionLabel(SidebarSection::Folder(0)), + task(2), + task(0), + SidebarEntry::ArchivedHeader, + ] + ); + sidebar.set_pinned_tasks_for_test(Vec::new()); + assert_eq!(sidebar.folder_names(), ["b", "a"]); + sidebar.set_pinned_tasks_for_test(vec!["s4".into()]); + assert_eq!(sidebar.folder_names(), ["b", "a"]); + }); + }); + } + /// A folder folds its tasks and stays folded when the list rebuilds; + /// the Projects header folds every group, never the pinned ones. + #[gpui::test] + fn test_folders_and_the_project_list_fold(cx: &mut TestAppContext) { + use crate::ui::chat::sidebar::{SidebarEntry, SidebarSection}; + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + this.sessions = vec![ + summary_at("s1", "One", "/a"), + summary_at("s2", "Two", "/b"), + summary_at("s3", "Three", "/b"), + ]; + this.sync_sidebar(cx); this.sidebar.update(cx, |sidebar, cx| { - sidebar.set_project_filter(Some("/b".to_string()), cx); + sidebar.set_pinned_tasks_for_test(vec!["s3".to_string()]); + sidebar.set_folder_expanded("/a", Some(false), cx); + }); + this.sessions.push(summary_at("s4", "Four", "/a")); + this.sync_sidebar(cx); + let tasks = |this: &ChatScreen, cx: &gpui::App| { + this.sidebar + .read(cx) + .entries() + .iter() + .filter_map(|entry| match entry { + SidebarEntry::Task(task) => Some(task.session), + _ => None, + }) + .collect::>() + }; + assert_eq!(tasks(this, cx), [2, 1]); + assert!( + this.sidebar + .read(cx) + .entries() + .contains(&SidebarEntry::SectionLabel(SidebarSection::Folder(0))) + ); + this.sidebar + .update(cx, |sidebar, cx| sidebar.set_projects_expanded(false, cx)); + assert_eq!(tasks(this, cx), [2]); + assert!( + !this.sidebar.read(cx).entries().iter().any(|entry| matches!( + entry, + SidebarEntry::SectionLabel(SidebarSection::Folder(_)) | SidebarEntry::Empty(_) + )) + ); + }); + } + + /// Equal-activity folders keep recent-root order, then path order, + /// even when the task list first encounters a different project. + #[gpui::test] + fn test_equal_activity_folders_keep_root_order(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + this.sessions = vec![ + summary_at("s1", "One", "/b"), + summary_at("s2", "Two", "/a"), + summary_at("s3", "Three", "/z"), + ]; + this.apply_recent_roots(vec![recent_root("/z")], cx); + this.sync_sidebar(cx); + assert_eq!(this.sidebar.read(cx).folder_names(), ["z", "a", "b"]); + }); + } + + /// Folded folders still hold their tasks: folding every one, or a + /// search that matches only inside a folded one, never claims the + /// list is empty. + #[gpui::test] + fn test_folded_folders_are_not_an_empty_list(cx: &mut TestAppContext) { + use crate::ui::chat::sidebar::{SidebarEmpty, SidebarEntry}; + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + this.sessions = vec![summary_at("s1", "One", "/a"), summary_at("s2", "Two", "/b")]; + this.sync_sidebar(cx); + this.sidebar.update(cx, |sidebar, cx| { + let empty = |sidebar: &crate::ui::chat::sidebar::Sidebar| { + sidebar.entries().iter().find_map(|entry| match entry { + SidebarEntry::Empty(reason) => Some(*reason), + _ => None, + }) + }; + sidebar.set_folder_expanded("/a", Some(false), cx); + sidebar.set_folder_expanded("/b", Some(false), cx); + assert_eq!(empty(sidebar), None); + sidebar.set_filter_for_test("two", cx); + assert_eq!(empty(sidebar), None); + sidebar.set_filter_for_test("nothing", cx); + assert_eq!(empty(sidebar), Some(SidebarEmpty::NoMatches)); }); - assert_eq!(this.sidebar_settled(cx), Vec::::new()); - assert_eq!(this.sidebar_active(cx), vec![1]); - // The switcher is not itself filtered. - assert_eq!(this.switcher_root_paths(cx), ["/a", "/b"]); }); } @@ -5947,31 +6101,344 @@ mod state_tests { assert_eq!(chat.update(cx, |this, cx| this.sidebar_pinned(cx)), vec![0]); } - /// 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. + /// Enter on Projects folds the list; paired computers keep their + /// switcher on the same target. #[gpui::test] - fn test_application_vim_drives_the_switcher_menu(cx: &mut TestAppContext) { + fn test_application_vim_activates_the_projects_header(cx: &mut TestAppContext) { + use crate::ui::chat::navigation::{ChatRegion, SidebarTarget}; let (chat, cx) = chat_window(cx, |this, cx| { - this.sessions = vec![summary_at("s1", "One", "/a")]; - this.recent_roots = vec![recent_root("/b")]; + this.sessions = vec![summary("s1", "One")]; this.sync_sidebar(cx); this.set_application_vim_enabled(true, cx); }); let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); - sidebar.update(cx, |sidebar, cx| sidebar.set_switcher_menu_open(true, cx)); - cx.run_until_parked(); - assert_eq!( - sidebar.update(cx, |sidebar, _| sidebar.popup_highlight()), - Some("switcher-all-projects".into()) + cx.update(|window, app| { + chat.update(app, |this, cx| { + this.focus_application_vim(window, cx); + this.application_vim.region = ChatRegion::Sidebar; + this.sidebar.update(cx, |sidebar, cx| { + sidebar.vim_select(SidebarTarget::Projects, cx); + }); + }); + }); + cx.simulate_keystrokes("enter"); + assert!( + !sidebar + .update(cx, |sidebar, _| sidebar.vim_targets_for_test()) + .contains(&SidebarTarget::Task("s1".into())) ); - // Rows: "All projects", "/b", "/a", "New project…". - cx.simulate_keystrokes("j enter"); + assert_eq!(open_menu(&chat, cx), ""); + cx.simulate_keystrokes("enter"); + assert!( + sidebar + .update(cx, |sidebar, _| sidebar.vim_targets_for_test()) + .contains(&SidebarTarget::Task("s1".into())) + ); + chat.update(cx, |this, cx| { + this.set_computers(identical_path_project_catalog(), cx) + }); + sidebar.update(cx, |sidebar, cx| { + sidebar.vim_select(SidebarTarget::Projects, cx) + }); + cx.simulate_keystrokes("enter"); + assert_eq!(open_menu(&chat, cx), "Switcher"); + cx.simulate_keystrokes("escape"); + } + + /// A folder header's menu opens from its "…" button, + /// and its compose button starts a task in that project. + #[gpui::test] + fn test_a_folder_header_offers_its_project_actions(cx: &mut TestAppContext) { + use crate::ui::chat::computers::Destination; + use std::cell::RefCell; + use std::rc::Rc; + 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 events = Rc::new(RefCell::new(Vec::new())); + let observed = events.clone(); + let _subscription = cx.update(|_, app| { + app.subscribe(&sidebar, move |_, event: &SidebarEvent, _| { + observed.borrow_mut().push(event.clone()); + }) + }); + press(cx, "menu-project-/tmp/proj"); + assert_eq!(open_menu(&chat, cx), "ProjectMenu(/tmp/proj)"); + press(cx, "menu-project-/tmp/proj"); + assert_eq!(open_menu(&chat, cx), ""); + press(cx, "new-task-in-/tmp/proj"); + assert!(events.borrow().iter().any(|event| matches!( + event, + SidebarEvent::Navigate(request) + if request.destination == Destination::DraftProject("/tmp/proj".into()) + ))); + } + + #[gpui::test] + fn test_folder_context_menu_survives_zero_distance_scroll(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = (0..100) + .map(|ix| summary(&format!("s{ix}"), "Task")) + .collect(); + this.sync_sidebar(cx); + }); + let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); + let header = cx.debug_bounds("folder-/tmp/proj").expect("folder header"); + let at = gpui::point(header.left() + px(60.), header.center().y); + cx.simulate_mouse_move(at, None, gpui::Modifiers::default()); + cx.run_until_parked(); + cx.simulate_mouse_down(at, gpui::MouseButton::Right, gpui::Modifiers::default()); + cx.simulate_mouse_up(at, gpui::MouseButton::Right, gpui::Modifiers::default()); + cx.run_until_parked(); + assert_eq!(open_menu(&chat, cx), "ProjectMenu(/tmp/proj)"); + assert!(cx.debug_bounds("rename-project-/tmp/proj").is_some()); + let wheel_at = gpui::point(header.left() + px(2.), at.y); + cx.simulate_mouse_move(wheel_at, None, gpui::Modifiers::default()); + cx.run_until_parked(); + let before = sidebar.update(cx, |sidebar, _| sidebar.list().logical_scroll_top()); + cx.simulate_event(gpui::ScrollWheelEvent { + // Outside the panel so the wheel event reaches the list. + position: wheel_at, + delta: gpui::ScrollDelta::Pixels(gpui::point(px(0.), px(0.))), + ..Default::default() + }); + cx.run_until_parked(); + assert_eq!(open_menu(&chat, cx), "ProjectMenu(/tmp/proj)"); + sidebar.update(cx, |sidebar, _| { + let after = sidebar.list().logical_scroll_top(); + assert_eq!( + (before.item_ix, before.offset_in_item), + (after.item_ix, after.offset_in_item) + ); + assert!( + !sidebar.section_hover_is_clear_for_test(), + "a stationary wheel preserves heading hover" + ); + }); + press(cx, "rename-project-/tmp/proj"); + assert!(cx.debug_bounds("rename-field").is_some()); + cx.simulate_keystrokes("escape"); + } + + /// Clicking a heading keeps its hover through both fold directions. + #[gpui::test] + fn test_section_chevrons_keep_heading_hover_after_folding(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + let mut archived = summary("archived", "Archived task"); + archived.state = AgentTaskState::Archived; + this.sessions = vec![ + summary("pinned", "Pinned task"), + summary("s1", "One"), + archived, + ]; + this.sidebar.update(cx, |sidebar, cx| { + sidebar.set_pinned_tasks_for_test(vec!["pinned".into()]); + sidebar.set_archived_expanded(true, cx); + }); + this.sync_sidebar(cx); + }); + let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); + for heading in [ + "projects-header", + "section-toggle-pinned", + "archived-toggle", + "folder-/tmp/proj", + ] { + for _ in 0..2 { + press(cx, heading); + assert!( + !sidebar.update(cx, |sidebar, _| sidebar.section_hover_is_clear_for_test()), + "{heading}" + ); + } + } + } + + /// Reordering, filtering and folding invalidate a stationary pointer's + /// old row index instead of assigning its hover to the rebuilt entry. + #[gpui::test] + fn test_section_chevrons_clear_on_stationary_rebuilds(cx: &mut TestAppContext) { + let (chat, cx) = chat_window(cx, |this, cx| { + let mut one = summary_at("s1", "One", "/a"); + one.updated_ms = 1; + let mut two = summary_at("s2", "Two", "/b"); + two.updated_ms = 2; + this.sessions = vec![one, two]; + this.sync_sidebar(cx); + }); + let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); + for change in ["reorder", "filter", "fold"] { + cx.simulate_mouse_move( + gpui::point(px(600.), px(400.)), + None, + gpui::Modifiers::default(), + ); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + let row = cx.debug_bounds("session-s1").expect("task row"); + cx.simulate_mouse_move(row.center(), None, gpui::Modifiers::default()); + cx.run_until_parked(); + sidebar.update(cx, |sidebar, _| { + assert!(!sidebar.section_hover_is_clear_for_test()); + }); + match change { + "reorder" => chat.update(cx, |this, cx| { + this.sessions[0].updated_ms = 3; + this.sync_sidebar(cx); + }), + "filter" => sidebar.update(cx, |sidebar, cx| { + sidebar.set_filter_for_test("One", cx); + cx.notify(); + }), + "fold" => sidebar.update(cx, |sidebar, cx| { + sidebar.set_folder_expanded("/a", Some(false), cx); + }), + _ => unreachable!(), + } + // No mouse move between the structural change and these checks. + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + sidebar.update(cx, |sidebar, _| { + assert!( + sidebar.section_hover_is_clear_for_test(), + "{change} clears both fields" + ); + }); + } + } + + /// Wheel scroll and programmatic reveals clear hover without relying + /// on a pointer move to repair the virtualized list's old hover. + #[gpui::test] + fn test_section_chevrons_clear_on_stationary_scrolls(cx: &mut TestAppContext) { + use crate::ui::chat::navigation::SidebarTarget; + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = std::iter::once(summary("pinned", "Pinned task")) + .chain((0..100).map(|n| summary(&format!("s{n}"), &format!("Task {n}")))) + .collect(); + this.sidebar.update(cx, |sidebar, _| { + sidebar.set_pinned_tasks_for_test(vec!["pinned".into()]); + }); + this.sync_sidebar(cx); + this.set_application_vim_enabled(true, cx); + }); + let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); + for scroll in ["wheel", "reveal", "vim"] { + sidebar.update(cx, |sidebar, cx| sidebar.reveal_row(0, cx)); + cx.simulate_mouse_move( + gpui::point(px(600.), px(400.)), + None, + gpui::Modifiers::default(), + ); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + let row = cx.debug_bounds("session-pinned").expect("pinned row"); + let pointer = row.center(); + cx.simulate_mouse_move(pointer, None, gpui::Modifiers::default()); + cx.run_until_parked(); + let before = sidebar.update(cx, |sidebar, _| { + assert!(!sidebar.section_hover_is_clear_for_test()); + sidebar.list().logical_scroll_top() + }); + match scroll { + "wheel" => cx.simulate_event(gpui::ScrollWheelEvent { + position: pointer, + delta: gpui::ScrollDelta::Pixels(gpui::point(px(0.), px(-20.))), + ..Default::default() + }), + "reveal" => sidebar.update(cx, |sidebar, cx| { + sidebar.reveal_row(sidebar.entries().len() - 1, cx); + }), + "vim" => sidebar.update(cx, |sidebar, cx| { + sidebar.vim_select(SidebarTarget::Task("s99".into()), cx); + sidebar.vim_reveal(cx); + }), + _ => unreachable!(), + } + // No mouse move between scrolling and checking the state. + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + sidebar.update(cx, |sidebar, _| { + let after = sidebar.list().logical_scroll_top(); + assert_ne!( + (before.item_ix, before.offset_in_item), + (after.item_ix, after.offset_in_item), + "{scroll} moved the list" + ); + assert!( + sidebar.section_hover_is_clear_for_test(), + "{scroll} clears both fields" + ); + }); + } + } + + /// Application Vim walks the visible rows in order under folder + /// grouping, headers included; h and l fold a folder. + #[gpui::test] + fn test_application_vim_walks_folder_groups(cx: &mut TestAppContext) { + use crate::ui::chat::navigation::SidebarTarget; + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![ + AgentSessionSummary { + updated_ms: 1, + ..summary_at("s1", "One", "/a") + }, + AgentSessionSummary { + updated_ms: 2, + ..summary_at("s2", "Two", "/b") + }, + summary_at("s3", "Three", "/a"), + ]; + this.sync_sidebar(cx); + this.set_application_vim_enabled(true, cx); + }); + let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); + let mut walked = Vec::new(); + sidebar.update(cx, |sidebar, cx| { + sidebar.vim_edge(true, cx); + loop { + walked.push(sidebar.vim_selected().cloned().unwrap()); + if walked.len() > 8 || !sidebar.vim_move(1, 1, cx) { + break; + } + if sidebar.vim_selected() == walked.last() { + break; + } + } + }); + let task = |id: &str| SidebarTarget::Task(id.into()); assert_eq!( - sidebar.update(cx, |sidebar, _| sidebar.project_filter().map(str::to_owned)), - Some("/b".to_string()) + walked, + [ + SidebarTarget::NewTask, + SidebarTarget::Search, + SidebarTarget::Projects, + SidebarTarget::Folder("/b".into()), + task("s2"), + SidebarTarget::Folder("/a".into()), + task("s1"), + task("s3"), + ] ); - assert!(!sidebar.update(cx, |sidebar, _| sidebar.switcher_menu_open())); + chat.update(cx, |this, cx| { + this.selected_session = Some("s2".into()); + this.sync_sidebar_selection(cx); + assert_eq!( + this.task_step_target(1, cx) + .map(|(_, request)| request.destination), + Some(crate::ui::chat::computers::Destination::Task("s1".into())) + ); + }); + sidebar.update(cx, |sidebar, cx| { + sidebar.vim_select(SidebarTarget::Folder("/a".into()), cx); + sidebar.vim_set_expanded(false, cx); + assert!(!sidebar.vim_targets_for_test().contains(&task("s1"))); + sidebar.vim_set_expanded(true, cx); + assert!(sidebar.vim_targets_for_test().contains(&task("s1"))); + }); } /// Every computer has its own Chat and sidebar, and the window swaps @@ -6122,7 +6589,7 @@ mod state_tests { if let Some(popup) = this.popup.open_key() { open.push(format!("{popup:?}")); } - if sidebar.switcher_menu_open() { + if sidebar.header_menu_open() { open.push("Switcher".to_string()); } if let Some(task) = sidebar.task_menu() { @@ -6781,7 +7248,7 @@ mod state_tests { this.sync_sidebar(cx); }); for (trigger, menu) in [ - ("projects-header", "Switcher"), + ("menu-project-/tmp/proj", "ProjectMenu(/tmp/proj)"), ("menu-session-s1", "Task(s1)"), ("model-picker", "Model"), ("mcp-menu", "Integrations"), @@ -6796,13 +7263,6 @@ mod state_tests { "a second press on {trigger} closes it" ); } - - // The project menu opens from inside the switcher. - press(cx, "projects-header"); - press(cx, "menu-project-/tmp/proj"); - assert_eq!(open_menu(&chat, cx), "Switcher + ProjectMenu(/tmp/proj)"); - press(cx, "menu-project-/tmp/proj"); - assert_eq!(open_menu(&chat, cx), "Switcher"); } /// One menu at a time: pressing another menu's button closes the open @@ -6815,11 +7275,14 @@ mod state_tests { this.sync_sidebar(cx); }); for (first, then, open) in [ - ("projects-header", "model-picker", "Model"), + ("menu-project-/tmp/proj", "model-picker", "Model"), ("menu-session-s1", "model-picker", "Model"), ("model-picker", "menu-session-s1", "Task(s1)"), - ("model-picker", "projects-header", "Switcher"), - ("menu-session-s1", "projects-header", "Switcher"), + ( + "model-picker", + "menu-project-/tmp/proj", + "ProjectMenu(/tmp/proj)", + ), ("root-picker", "mcp-menu", "Integrations"), ] { press(cx, first); @@ -6832,6 +7295,29 @@ mod state_tests { }); } + // With a task menu open, the folder has no strip. An outside + // press closes the menu and still reaches the folder underneath. + press(cx, "menu-session-s1"); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + assert!(cx.debug_bounds("menu-project-/tmp/proj").is_none()); + let header = cx.debug_bounds("folder-/tmp/proj").expect("folder header"); + cx.simulate_click( + gpui::point(header.right() - px(30.), header.center().y), + gpui::Modifiers::default(), + ); + assert_eq!(open_menu(&chat, cx), "", "the first click closes the menu"); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + assert!(cx.debug_bounds("session-s1").is_none(), "the folder folded"); + press(cx, "menu-project-/tmp/proj"); + assert_eq!(open_menu(&chat, cx), "ProjectMenu(/tmp/proj)"); + let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); + sidebar.update(cx, |sidebar, cx| { + sidebar.close_popups(cx); + sidebar.set_folder_expanded("/tmp/proj", Some(true), cx); + }); + // A menu opened without the pointer closes the open one too: it // takes the focus. press(cx, "menu-session-s1"); @@ -7105,6 +7591,88 @@ mod state_tests { ) } + #[gpui::test] + fn test_paired_scoped_sidebar_keeps_status_sections_and_switcher_rename( + cx: &mut TestAppContext, + ) { + use sidebar::{SidebarEntry, SidebarSection}; + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![ + summary_at("pinned", "Pinned", "/shared"), + summary_at("active", "Active", "/shared"), + summary_at("settled", "Settled", "/shared"), + summary_at("other", "Other project", "/other"), + ]; + this.sessions[2].state = AgentTaskState::Settled; + this.apply_recent_roots(vec![named_root("/shared", "Local project", 1)], cx); + this.set_computers(identical_path_project_catalog(), cx); + this.sync_sidebar(cx); + this.sidebar.update(cx, |sidebar, cx| { + sidebar.set_pinned_tasks_for_test(vec!["pinned".into()]); + sidebar.set_project_filter(Some("/shared".into()), cx); + }); + }); + let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); + sidebar.update(cx, |sidebar, _| { + let entries = sidebar.entries(); + assert_eq!( + entries[0..4], + [ + SidebarEntry::NewTask, + SidebarEntry::Search, + SidebarEntry::ProjectsHeader, + SidebarEntry::SectionLabel(SidebarSection::Pinned) + ] + ); + let sections: Vec<_> = entries + .iter() + .filter_map(|entry| match entry { + SidebarEntry::SectionLabel(section) => Some(*section), + _ => None, + }) + .collect(); + assert_eq!( + sections, + [ + SidebarSection::Pinned, + SidebarSection::Active, + SidebarSection::Settled + ] + ); + assert_eq!(sidebar.active_rows(), [1]); + assert_eq!(sidebar.settled_rows(), [2]); + assert!(sidebar.folder_names().is_empty()); + }); + press(cx, "projects-header"); + assert_eq!(open_menu(&chat, cx), "Switcher"); + let options = cx.debug_bounds("menu-project-/shared").unwrap(); + cx.simulate_mouse_move(options.center(), None, gpui::Modifiers::default()); + cx.run_until_parked(); + cx.executor() + .advance_clock(std::time::Duration::from_secs(1)); + cx.run_until_parked(); + assert!( + cx.debug_bounds("tooltip").is_some(), + "paired project options retains its tooltip" + ); + press(cx, "menu-project-/shared"); + press(cx, "rename-project-/shared"); + cx.run_until_parked(); + assert_eq!( + sidebar.update(cx, |sidebar, _| sidebar.rename_target()), + Some(RenameTarget::Project(ProjectTarget { + computer: None, + path: "/shared".into() + })) + ); + assert!(cx.debug_bounds("rename-field").is_some()); + cx.simulate_input("Renamed project"); + let field = sidebar.update(cx, |sidebar, _| sidebar.inputs().last().unwrap().clone()); + assert_eq!(field.update(cx, |field, _| field.text()), "Renamed project"); + cx.simulate_keystrokes("escape"); + assert!(sidebar.update(cx, |sidebar, _| sidebar.rename_target().is_none())); + } + #[gpui::test] fn inactive_project_rename_controls_are_scoped_and_do_not_navigate(cx: &mut TestAppContext) { use std::cell::RefCell; @@ -7149,7 +7717,7 @@ mod state_tests { assert!(own_menu.contains(&"Rename project")); assert!(own_menu.contains(&"Open folder")); assert!(own_menu.contains(&"Remove project")); - sidebar.set_switcher_menu_open(true, cx); + sidebar.set_header_menu_open(true, cx); }); cx.update(|window, _| window.refresh()); cx.run_until_parked(); @@ -7505,59 +8073,95 @@ mod state_tests { assert!(rename_button(cx).is_some(), "the actions come back after"); } - /// A project's rename field sits in the switcher. Escape ends the - /// rename and Enter commits it; either way the switcher stays open and - /// has the keyboard again. + /// The folder menu focuses its inline rename. Enter, focus leaving, + /// and an outside click commit; Escape cancels before focus moves. #[gpui::test] - fn test_a_project_rename_ends_inside_the_switcher(cx: &mut TestAppContext) { + fn test_a_project_rename_ends_in_its_folder_header(cx: &mut TestAppContext) { let backend = backend_with_mcp_servers(cx, "rename-keyboard", Vec::new()); let root = mcp_project("rename-keyboard"); let roots = register_test_project(&backend, &root); let (chat, cx) = chat_window_in(cx, backend.clone(), |this, cx| { this.project_root = Some(root.clone()); - this.sessions = vec![summary_at("s1", "One", &root)]; + this.sessions = vec![ + summary_at("s1", "One", &root), + summary_at("s2", "Two", &root), + ]; this.apply_recent_roots(roots, cx); }); let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); - for commit in [false, true] { - sidebar.update(cx, |sidebar, cx| { - sidebar.set_switcher_menu_open(true, cx); - sidebar.begin_rename( - RenameTarget::Project(ProjectTarget { - computer: None, - path: root.clone(), - }), - cx, - ); - }); + // GPUI's debug selector API takes static strings. + let menu: &'static str = Box::leak(format!("menu-project-{root}").into_boxed_str()); + let rename: &'static str = Box::leak(format!("rename-project-{root}").into_boxed_str()); + for ending in ["escape", "enter", "focus-out", "outside-click"] { + let before = sidebar.update(cx, |sidebar, _| sidebar.root_name(&root)); + press(cx, menu); + press(cx, rename); + assert!( + matches!(sidebar.update(cx, |sidebar, _| sidebar.rename_target()), + Some(RenameTarget::Project(target)) if target.path == root) + ); + let input = sidebar.update(cx, |sidebar, _| sidebar.inputs().last().unwrap().clone()); + assert!( + cx.update(|window, app| input.read(app).focus_handle(app).is_focused(window)), + "the menu transfers focus to the folder field" + ); + let typed = format!("Project {ending}"); + cx.simulate_input(&typed); + assert_eq!( + input.read_with(cx, |input, _| input.text()), + typed, + "typing replaces the selected project name" + ); + match ending { + "escape" | "enter" => cx.simulate_keystrokes(ending), + "focus-out" => cx.update(|window, app| { + sidebar.update(app, |sidebar, cx| sidebar.focus_search(window, cx)) + }), + "outside-click" => { + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + let task = cx.debug_bounds("menu-session-s2").expect("the other task"); + let point = task.center() - gpui::point(px(80.), px(0.)); + cx.simulate_mouse_down( + point, + gpui::MouseButton::Left, + gpui::Modifiers::default(), + ); + cx.simulate_mouse_up( + point, + gpui::MouseButton::Left, + gpui::Modifiers::default(), + ); + } + _ => unreachable!(), + } cx.run_until_parked(); - if commit { - cx.simulate_keystrokes("secondary-a"); - cx.simulate_input("Nice"); - cx.simulate_keystrokes("enter"); + assert!( + sidebar.update(cx, |sidebar, _| sidebar.rename_target().is_none()), + "{ending}" + ); + if ending == "escape" { + cx.update(|window, app| { + sidebar.update(app, |sidebar, cx| sidebar.focus_search(window, cx)) + }); + cx.run_until_parked(); + assert_eq!( + sidebar.update(cx, |sidebar, _| sidebar.root_name(&root)), + before + ); + assert!(!sidebar.update(cx, |sidebar, _| sidebar.project_rename_pending(&root))); } else { - cx.simulate_keystrokes("escape"); + wait_until(&chat, cx, "project name committed", |this| { + this.recent_roots.iter().any(|project| { + project.path == root && project.name == typed && project.name_revision > 0 + }) + }); + assert_eq!( + sidebar.update(cx, |sidebar, _| sidebar.root_name(&root)), + typed + ); } - assert!(sidebar.update(cx, |sidebar, _| sidebar.rename_target().is_none())); - assert_eq!(open_menu(&chat, cx), "Switcher", "commit: {commit}"); - cx.simulate_keystrokes("down"); - assert_eq!( - sidebar.update(cx, |sidebar, _| sidebar.popup_highlight()), - Some(format!("switcher-{root}").into()), - "the switcher has the keyboard, commit: {commit}" - ); - cx.simulate_keystrokes("escape"); - assert_eq!(open_menu(&chat, cx), ""); } - wait_until(&chat, cx, "project name committed", |this| { - this.recent_roots.iter().any(|project| { - project.path == root && project.name == "Nice" && project.name_revision > 0 - }) - }); - assert_eq!( - sidebar.update(cx, |sidebar, _| sidebar.root_name(&root)), - "Nice" - ); let owner = backend.clone(); let roots = await_backend_result(&backend, async move { owner @@ -7582,7 +8186,7 @@ mod state_tests { .find(|project| project.path == root) .unwrap() .name, - "Nice" + "Project outside-click" ); } @@ -7816,9 +8420,9 @@ mod state_tests { let screen = screen(cx); screen.update(cx, |this, cx| { this.sidebar - .update(cx, |sidebar, cx| sidebar.set_switcher_menu_open(true, cx)); + .update(cx, |sidebar, cx| sidebar.open_task_menu_for_test("s1", cx)); this.escape(cx); - assert!(!this.sidebar.read(cx).switcher_menu_open()); + assert!(this.sidebar.read(cx).task_menu().is_none()); this.sidebar .update(cx, |sidebar, cx| sidebar.open_task_menu_for_test("s1", cx)); this.escape(cx); @@ -8105,7 +8709,7 @@ mod state_tests { this.project_root = Some("/tmp/proj".to_string()); this.sync_sidebar(cx); // The screen fixture selects s1: one step moves to s2, and - // back stops at the top. + // back stops at the top. The second folder adds a header row. assert_eq!(this.task_step_target(1, cx), Some((5, local("s2")))); assert_eq!( this.task_step_target(-1, cx), @@ -8114,13 +8718,13 @@ mod state_tests { ); this.selected_session = Some("s2".to_string()); this.sync_sidebar_selection(cx); - assert_eq!(this.task_step_target(1, cx), Some((6, local("s3")))); + assert_eq!(this.task_step_target(1, cx), Some((7, local("s3")))); assert_eq!(this.task_step_target(-1, cx), Some((4, local("s1")))); // With nothing selected, each direction enters from its end. this.selected_session = None; this.sync_sidebar_selection(cx); assert_eq!(this.task_step_target(1, cx), Some((4, local("s1")))); - assert_eq!(this.task_step_target(-1, cx), Some((6, local("s3")))); + assert_eq!(this.task_step_target(-1, cx), Some((7, local("s3")))); }); } From f4bdf246a54f25c8eb9c6488232d20299f6db4b1 Mon Sep 17 00:00:00 2001 From: Michael Atwood <70112949+doowta@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:33:30 -0500 Subject: [PATCH 02/29] Agent: let a menu item open a submenu Grouped choices need a child menu attached to their row, with the current value visible before it opens. Reuse nested popups for delayed hover, keyboard navigation, focus return, and placement that flips at the edge. Dismiss ancestors only for submenu selections so existing nested field and project menus keep their selection behavior. Co-Authored-By: Codex GPT-6.1-Sol --- apps/maple-agent/app/src/keymap.rs | 20 + apps/maple-agent/app/src/ui/popup.rs | 566 ++++++++++++++++++++++++++- 2 files changed, 569 insertions(+), 17 deletions(-) diff --git a/apps/maple-agent/app/src/keymap.rs b/apps/maple-agent/app/src/keymap.rs index 466a73e8a..798c1d634 100644 --- a/apps/maple-agent/app/src/keymap.rs +++ b/apps/maple-agent/app/src/keymap.rs @@ -71,6 +71,8 @@ enum SlotAction { MenuLast, MenuConfirm, MenuCancel, + MenuOpenSubmenu, + MenuCloseSubmenu, Backspace, Delete, Left, @@ -174,6 +176,8 @@ impl SlotAction { Self::MenuLast => Box::new(popup::SelectLast), Self::MenuConfirm => Box::new(popup::Confirm), Self::MenuCancel => Box::new(popup::Cancel), + Self::MenuOpenSubmenu => Box::new(popup::OpenSubmenu), + Self::MenuCloseSubmenu => Box::new(popup::CloseSubmenu), Self::Backspace => Box::new(text_input::Backspace), Self::Delete => Box::new(text_input::Delete), Self::Left => Box::new(text_input::Left), @@ -592,6 +596,22 @@ fn catalog_for(os: HostOs) -> Vec { "escape", SlotAction::MenuCancel, ), + slot( + "menu.open_submenu", + "Open submenu", + ShortcutCategory::Menus, + Some(popup::MENU_CONTEXT), + "right", + SlotAction::MenuOpenSubmenu, + ), + slot( + "menu.close_submenu", + "Return to parent menu", + ShortcutCategory::Menus, + Some(popup::MENU_CONTEXT), + "left", + SlotAction::MenuCloseSubmenu, + ), ]); add_application_vim_slots(&mut slots); diff --git a/apps/maple-agent/app/src/ui/popup.rs b/apps/maple-agent/app/src/ui/popup.rs index fb69e291f..6d52fd2cc 100644 --- a/apps/maple-agent/app/src/ui/popup.rs +++ b/apps/maple-agent/app/src/ui/popup.rs @@ -6,6 +6,9 @@ //! the open menu as a [`Menu`] on every render. The state stays in the view //! and the rows are rebuilt each frame, so a switch turned on or a status //! that arrives late shows without rebuilding anything. +//! A submenu row comes from the next level's [`Popup::submenu`], which +//! attaches an ordinary child [`Menu`] beside the row. Its selection closes +//! the submenu chain; other nested menus keep their usual selection behavior. //! //! What every popup gets: //! @@ -32,15 +35,16 @@ //! expanded, a named menu of named items, and the highlighted item as the //! focused one. -use std::cell::Cell; +use std::cell::{Cell, RefCell}; use std::rc::Rc; +use std::time::Duration; use gpui::{ AnyElement, App, AvailableSpace, Bounds, ClickEvent, Context, Div, Edges, ElementId, FocusHandle, GlobalElementId, HitboxBehavior, HitboxId, InspectorElementId, KeyDownEvent, LayoutId, MouseButton, MouseDownEvent, Pixels, Point, Position, Role, ScrollHandle, - SharedString, Size, Stateful, Style, Subscription, Toggled, WeakFocusHandle, Window, canvas, - div, point, prelude::*, px, + SharedString, Size, Stateful, Style, Subscription, Task, Toggled, WeakFocusHandle, Window, + canvas, div, point, prelude::*, px, }; use super::icons::icon; @@ -61,6 +65,8 @@ gpui::actions!( SelectFirst, SelectLast, Confirm, + OpenSubmenu, + CloseSubmenu, Cancel ] ); @@ -184,6 +190,13 @@ pub(crate) struct Popup { reveal: Rc>, /// This popup has an entry in [`Popups`]. registered: bool, + /// Only submenu selections dismiss ancestors; ordinary nested menus do not. + parent: Option>, + /// Only a child opened through `submenu` closes on an ordinary row's hover. + close_child: Option>, + hover: Option>, + /// The current enabled submenu; timers never retain an obsolete opener. + hover_opener: RefCell)>>, } impl Popup { @@ -199,6 +212,10 @@ impl Popup { scroll: ScrollHandle::new(), reveal: Rc::new(Cell::new(Reveal::Done)), registered: false, + parent: None, + close_child: None, + hover: None, + hover_opener: RefCell::new(None), } } @@ -239,6 +256,10 @@ impl Popup { } fn open_with(&mut self, key: K, item: Option, cx: &mut Context) { + self.parent = None; + self.close_child = None; + self.hover = None; + *self.hover_opener.get_mut() = None; if self.open.as_ref() != Some(&key) { self.scroll.set_offset(point(px(0.), px(0.))); } @@ -269,6 +290,9 @@ impl Popup { /// focus, focus goes back on the owner's next render (see /// [`Self::sync_focus`]). pub(crate) fn close(&mut self, cx: &mut Context) -> bool { + self.hover = None; + *self.hover_opener.get_mut() = None; + self.close_child = None; self.focus_pending = false; self.highlighted = None; if self.open.take().is_none() { @@ -285,9 +309,14 @@ impl Popup { true } + fn closes_on_select(&self, submenu: bool, keep_open: bool) -> bool { + !submenu && (!keep_open || self.parent.is_some()) + } + /// Highlight `item`, or nothing. pub(crate) fn highlight(&mut self, item: Option, cx: &mut Context) { if self.highlighted != item { + self.hover = None; self.highlighted = item; cx.notify(); } @@ -427,6 +456,72 @@ impl Popup { })) } + /// A parent row and its child menu, using this popup for the next level. + /// Sync this popup's focus after the outer popup's, even while closed. + #[cfg_attr( + not(test), + allow( + dead_code, + reason = "The submenu API is exercised by its fixture until a caller adopts it." + ) + )] + pub(crate) fn submenu( + &self, + key: K, + id: impl Into, + label: impl Into, + menu: Menu, + window: &mut Window, + cx: &mut Context, + ) -> MenuItem { + let expanded = self.is_open(&key); + let level = menu.priority.max(2); + let (panel, initial_menu) = if expanded { + ( + Some(self.render(menu.level(level), Placement::Beside, window, cx)), + None, + ) + } else { + (None, Some(menu)) + }; + let get = self.get; + let close_key = key.clone(); + let mut item = MenuItem::new(id, label, |_, _, _| {}); + item.keep_open = true; + item.submenu = Some(Box::new(Submenu { + close: Rc::new(move |view, _, cx| { + let popup = get(view); + if popup.parent.is_some() && popup.is_open(&close_key) { + popup.close(cx); + } + }), + expanded, + panel, + value: None, + open: Rc::new(move |view, parent, cx| { + let popup = get(view); + if !popup.is_open(&key) { + let initial = initial_menu.as_ref().and_then(|menu| { + let mut first = menu.entries.iter().filter_map(|entry| match entry { + Entry::Item(item) if item.enabled => Some(item), + _ => None, + }); + first + .clone() + .find(|item| matches!(item.state, Some(ItemState::Current(true)))) + .or_else(|| first.next()) + .map(|item| item.id.clone()) + }); + popup.open_with(key.clone(), initial, cx); + } else { + popup.refocus(cx); + } + popup.parent = Some(parent); + }), + })); + item + } + /// The open menu, placed and wired to this popup. Build it only while /// the popup is open. pub(crate) fn render( @@ -450,17 +545,44 @@ impl Popup { // What the keyboard needs about each item: its id, its action, // whether it can be picked, the letter it starts with, and its // child index for scrolling it into view. + self.hover_opener.replace(None); let mut picks: Vec> = Vec::new(); let mut children: Vec = Vec::with_capacity(entries.len()); for entry in entries { match entry { - Entry::Item(item) => { + Entry::Item(mut item) => { + if let Some(submenu) = &item.submenu { + let open = submenu.open.clone(); + let close = submenu.close.clone(); + let id = item.id.clone(); + item.on_select = Rc::new(move |view, _, cx| { + get(view).hover = None; + get(view).highlight(Some(id.clone()), cx); + get(view).close_child = Some(close.clone()); + open( + view, + Rc::new(move |view, exit, window, cx| { + get(view).hover = None; + get(view).close_child = None; + if matches!(exit, Exit::Select) { + close_menu(view, get, exit, window, cx); + } + }), + cx, + ); + }); + } let highlighted = self.highlighted.as_ref() == Some(&item.id); + if highlighted && item.enabled && item.submenu.is_some() { + self.hover_opener + .replace(Some((item.id.clone(), item.on_select.clone()))); + } picks.push(Pick { id: item.id.clone(), on_select: item.on_select.clone(), enabled: item.enabled, keep_open: item.keep_open, + submenu: item.submenu.is_some(), initial: initial(&item.label), child: children.len(), }); @@ -543,6 +665,29 @@ impl Popup { .on_action(self.navigation::(&picks, Step::By(-1), cx)) .on_action(self.navigation::(&picks, Step::First, cx)) .on_action(self.navigation::(&picks, Step::Last, cx)) + .on_action({ + let picks = picks.clone(); + let focus = self.focus.clone(); + cx.listener(move |view, _: &OpenSubmenu, window, cx| { + if !focus.is_focused(window) { + cx.propagate(); + } else if let Some(row) = get(view).highlighted_row(&picks) + && picks[row].submenu + { + pick(view, get, &picks, row, window, cx); + } + }) + }) + .on_action({ + let focus = self.focus.clone(); + cx.listener(move |view, _: &CloseSubmenu, window, cx| { + if focus.is_focused(window) && get(view).parent.is_some() { + close_menu(view, get, Exit::Return, window, cx); + } else { + cx.propagate(); + } + }) + }) .on_action({ let picks = picks.clone(); let focus = self.focus.clone(); @@ -568,7 +713,7 @@ impl Popup { cx.propagate(); return; } - get(view).close(cx); + close_menu(view, get, Exit::Return, window, cx); }) }) .on_key_down({ @@ -642,6 +787,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 { @@ -661,6 +808,7 @@ impl Placement { 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), }; let room_below = window.height - margin - anchor.bottom() - gap; let room_above = anchor.top() - gap - margin; @@ -689,6 +837,27 @@ impl Placement { } } +fn beside( + trigger: Bounds, + size: Size, + window: Size, + margin: 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 @@ -785,6 +954,35 @@ impl Element for Float { /// What picking a row does, with the owning view in hand. type OnSelect = Rc)>; +type OnParent = Rc)>; +type OpenChild = Rc, &mut Context)>; + +enum Exit { + Return, + Select, +} + +struct Submenu { + expanded: bool, + panel: Option, + value: Option, + open: OpenChild, + close: OnSelect, +} + +fn close_menu( + view: &mut V, + get: fn(&mut V) -> &mut Popup, + exit: Exit, + window: &mut Window, + cx: &mut Context, +) { + let parent = get(view).parent.take(); + get(view).close(cx); + if let Some(parent) = parent { + parent(view, exit, window, cx); + } +} /// A caller's adjustment to one row. type RowStyle = Box) -> Stateful
>; @@ -893,6 +1091,7 @@ pub(crate) struct MenuItem { trailing: Option, style: Option, on_select: OnSelect, + submenu: Option>>, } #[derive(Clone, Copy)] @@ -920,6 +1119,7 @@ impl MenuItem { trailing: None, style: None, on_select: Rc::new(on_select), + submenu: None, } } @@ -928,6 +1128,15 @@ impl MenuItem { self } + /// The current choice, shown beside a submenu's chevron. + #[cfg_attr(not(test), allow(dead_code, reason = "Part of the submenu API."))] + pub(crate) fn value(mut self, value: impl Into) -> Self { + if let Some(submenu) = &mut self.submenu { + submenu.value = Some(value.into()); + } + self + } + /// A second, smaller line under the label. pub(crate) fn note(mut self, note: impl Into) -> Self { self.note = Some(note.into()); @@ -987,6 +1196,7 @@ struct Pick { on_select: OnSelect, enabled: bool, keep_open: bool, + submenu: bool, initial: Option, child: usize, } @@ -1015,6 +1225,7 @@ fn item_row( trailing, style, on_select, + submenu, } = item; let role = match state { None => Role::MenuItem, @@ -1027,11 +1238,19 @@ fn item_row( theme::text_muted() }; let hovered_id = id.clone(); + let opens_submenu = submenu.is_some(); + let expanded = submenu.as_ref().is_some_and(|submenu| submenu.expanded); let row_element = div() .debug_selector(|| id.to_string()) .id(id) .role(role) .aria_label(label.clone()) + .when(opens_submenu, |row| { + row.aria_expanded(expanded).a11y_synthetic_children(|tree| { + tree.parent_node() + .set_has_popup(gpui::accesskit::HasPopup::Menu); + }) + }) .when_some(state, |row, state| { let on = match state { ItemState::Current(on) | ItemState::Switch(on) => on, @@ -1052,12 +1271,48 @@ fn item_row( .text_color(gpui::rgb(text)) // The pointer highlights the row under it, and nothing over a // disabled row, the way a native menu does. - .on_hover(cx.listener(move |view, hovered: &bool, _, cx| { + .on_hover(cx.listener(move |view, hovered: &bool, window, cx| { + if *hovered + && !opens_submenu + && let Some(close) = get(view).close_child.take() + { + close(view, window, cx); + } let popup = get(view); if *hovered { + popup.hover = None; popup.highlight(enabled.then(|| hovered_id.clone()), cx); + if enabled && opens_submenu { + let id = hovered_id.clone(); + popup.hover = Some(cx.spawn_in(window, async move |view, cx| { + cx.background_executor() + .timer(Duration::from_millis(175)) + .await; + view.update_in(cx, |view, window, cx| { + let open = { + let popup = get(view); + let opener = popup.hover_opener.borrow(); + opener + .as_ref() + .filter(|(current, _)| { + popup.open.is_some() + && current == &id + && popup.highlighted.as_ref() == Some(current) + }) + .map(|(_, open)| open.clone()) + }; + if let Some(open) = open { + open(view, window, cx); + } + }) + .ok(); + })); + } } else if popup.highlighted.as_ref() == Some(&hovered_id) { - popup.highlight(None, cx); + popup.hover = None; + if !expanded { + popup.highlight(None, cx); + } } })) .when(enabled, |row_element| { @@ -1066,8 +1321,8 @@ fn item_row( .active(|style| style.bg(gpui::rgb(theme::bg_sidebar_row_selected()))) .on_click(cx.listener(move |view, _: &ClickEvent, window, cx| { cx.stop_propagation(); - if !keep_open { - get(view).close(cx); + if get(view).closes_on_select(opens_submenu, keep_open) { + close_menu(view, get, Exit::Select, window, cx); } on_select(view, window, cx); })) @@ -1125,7 +1380,21 @@ fn item_row( .into_any_element(), ItemState::Switch(on) => widgets::switch_track(on).into_any_element(), } - })); + })) + .when_some(submenu, |row, submenu| { + row.children(submenu.value.map(|value| { + div() + .text_color(gpui::rgb(theme::text_muted())) + .child(value) + })) + .child(icon( + "chevron-right", + widgets::ROW_ICON, + theme::text_muted(), + )) + .children(submenu.panel) + }) + .relative(); match style { Some(style) => style(row_element).into_any_element(), None => row_element.into_any_element(), @@ -1184,8 +1453,8 @@ fn pick( let Some(item) = picks.get(row).filter(|item| item.enabled) else { return; }; - if !item.keep_open { - get(view).close(cx); + if get(view).closes_on_select(item.submenu, item.keep_open) { + close_menu(view, get, Exit::Select, window, cx); } (item.on_select)(view, window, cx); } @@ -1279,6 +1548,11 @@ mod tests { struct Host { popup: Popup, nested: Popup, + child: Popup, + grandchild: Popup, + submenus: bool, + group_enabled: bool, + placement: Placement, other: Entity, /// Whether the host draws the other view at all. show_other: bool, @@ -1295,7 +1569,7 @@ mod tests { } impl Host { - fn menu_a(&self, cx: &mut Context) -> Menu { + fn menu_a(&self, window: &mut Window, cx: &mut Context) -> Menu { let pick = |name: &'static str| { move |this: &mut Host, _: &mut Window, _: &mut Context| this.picked.push(name) }; @@ -1315,7 +1589,8 @@ mod tests { if self.lead { menu = menu.item(MenuItem::new("a-lead", "Lead", pick("lead"))); } - menu.item(MenuItem::new("a-one", "One", pick("one"))) + let menu = menu + .item(MenuItem::new("a-one", "One", pick("one"))) .item(MenuItem::new("a-two", "Two", pick("two")).enabled(self.second_enabled)) .separator() .item(MenuItem::new("a-three", "Three", pick("three")).trailing(more)) @@ -1328,7 +1603,40 @@ mod tests { }, ) .switch(false), - ) + ); + if !self.submenus { + return menu; + } + let group = Menu::new("group-menu", px(180.)) + .application_vim(self.vim) + .items([ + MenuItem::new("group-disabled", "Disabled", pick("disabled")).enabled(false), + MenuItem::new("group-date", "Date", pick("date")), + MenuItem::new("group-folder", "Folder", pick("folder")).current(true), + MenuItem::new("group-switch", "Metadata", pick("metadata")).switch(false), + self.grandchild.submenu( + (), + "deep", + "More", + Menu::new("deep-menu", px(160.)) + .level(3) + .item(MenuItem::new("deep-one", "Deep choice", pick("deep"))), + window, + cx, + ), + ]); + let sort = Menu::new("sort-menu", px(180.)).items([ + MenuItem::new("sort-disabled", "Disabled", pick("disabled")).enabled(false), + MenuItem::new("sort-name", "Name", pick("name")), + MenuItem::new("sort-date", "Date", pick("date")), + ]); + menu.item( + self.child + .submenu(1, "group", "Group by", group, window, cx) + .value("Folder") + .enabled(self.group_enabled), + ) + .item(self.child.submenu(2, "sort", "Sort by", sort, window, cx)) } fn menu_low(&self) -> Menu { @@ -1348,6 +1656,8 @@ mod tests { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.popup.sync_focus(window, cx); self.nested.sync_focus(window, cx); + self.child.sync_focus(window, cx); + self.grandchild.sync_focus(window, cx); let trigger = |popup: &Popup, which: Which, id: &'static str, @@ -1369,7 +1679,7 @@ mod tests { ) }; let menu_a = self.popup.is_open(&Which::A).then(|| { - let mut menu = self.menu_a(cx); + let mut menu = self.menu_a(window, cx); if self.nested.is_open(&()) { menu = menu.child( div().relative().child( @@ -1390,7 +1700,7 @@ mod tests { ), ); } - self.popup.render(menu, Placement::BelowStart, window, cx) + self.popup.render(menu, self.placement, window, cx) }); let menu_b = self.popup.is_open(&Which::B).then(|| { self.popup.render( @@ -1483,6 +1793,11 @@ mod tests { Host { popup: Popup::new(|this| &mut this.popup, cx), nested: Popup::new(|this| &mut this.nested, cx), + child: Popup::new(|this| &mut this.child, cx), + grandchild: Popup::new(|this| &mut this.grandchild, cx), + submenus: false, + group_enabled: true, + placement: Placement::BelowStart, other, show_other: true, picked: Vec::new(), @@ -1526,6 +1841,223 @@ mod tests { cx.update(|window, cx| window.focused(cx)) } + fn submenu_setup(cx: &mut TestAppContext) -> (Entity, &mut VisualTestContext) { + let (host, cx) = setup(cx); + host.update(cx, |host, cx| { + host.submenus = true; + host.popup.open(Which::A, cx); + }); + cx.run_until_parked(); + (host, cx) + } + + fn hover(cx: &mut VisualTestContext, selector: &'static str) { + let row = cx.debug_bounds(selector).expect(selector); + cx.simulate_mouse_move(row.center(), None, Modifiers::default()); + cx.run_until_parked(); + } + + fn wait_hover(cx: &mut VisualTestContext, millis: u64) { + cx.executor().advance_clock(Duration::from_millis(millis)); + cx.run_until_parked(); + } + + fn child(host: &Entity, cx: &mut VisualTestContext) -> Option { + host.update(cx, |host, _| host.child.open_key().copied()) + } + + #[gpui::test] + fn submenu_hover_waits_switches_and_cancels(cx: &mut TestAppContext) { + let (host, cx) = submenu_setup(cx); + hover(cx, "group"); + wait_hover(cx, 174); + assert_eq!(child(&host, cx), None); + hover(cx, "sort"); + wait_hover(cx, 174); + assert_eq!(child(&host, cx), None); + wait_hover(cx, 1); + assert_eq!(child(&host, cx), Some(2)); + hover(cx, "group"); + wait_hover(cx, 175); + let row = cx.debug_bounds("group").unwrap(); + cx.simulate_mouse_move( + point(row.right() + GAP / 2., row.center().y), + None, + Modifiers::default(), + ); + wait_hover(cx, 200); + hover(cx, "group-folder"); + assert_eq!(child(&host, cx), Some(1)); + hover(cx, "outside"); + assert_eq!(open(&host, cx), Some(Which::A)); + cx.simulate_keystrokes("escape"); + hover(cx, "sort"); + cx.simulate_keystrokes("escape"); + wait_hover(cx, 200); + assert_eq!(child(&host, cx), None); + assert_eq!(open(&host, cx), None); + } + + #[gpui::test] + fn submenu_hover_ignores_disabled_or_removed_rows(cx: &mut TestAppContext) { + let (host, cx) = submenu_setup(cx); + for removed in [false, true] { + hover(cx, "outside"); + host.update(cx, |host, cx| { + host.submenus = true; + host.group_enabled = true; + cx.notify(); + }); + cx.run_until_parked(); + hover(cx, "group"); + wait_hover(cx, 100); + host.update(cx, |host, cx| { + host.submenus = !removed; + host.group_enabled = false; + cx.notify(); + }); + cx.run_until_parked(); + wait_hover(cx, 100); + assert_eq!(child(&host, cx), None, "removed: {removed}"); + } + } + + #[gpui::test] + fn submenu_selections_dismiss_the_chain(cx: &mut TestAppContext) { + let (host, cx) = submenu_setup(cx); + for (keys, pick) in [ + ("", "date"), + ("end right enter", "name"), + ("g right down enter", "metadata"), + ("g right end right enter", "deep"), + ] { + host.update(cx, |host, cx| host.popup.open(Which::A, cx)); + cx.run_until_parked(); + if keys.is_empty() { + click(cx, "group"); + assert_eq!( + host.update(cx, |host, _| host.child.highlighted.clone()), + Some("group-folder".into()) + ); + click(cx, "group-date"); + } else { + cx.simulate_keystrokes(keys); + } + host.update(cx, |host, _| { + assert_eq!(host.picked.last(), Some(&pick)); + assert!(!host.popup.is_open(&Which::A)); + assert!(host.child.open_key().is_none()); + assert!(!host.grandchild.is_open(&())); + }); + } + host.update(cx, |host, cx| { + host.placement = Placement::At(point(px(620.), px(100.))); + host.popup.open(Which::A, cx); + }); + cx.run_until_parked(); + click(cx, "sort"); + let panel = cx.debug_bounds("sort-menu").expect("child panel"); + let window = Bounds::new(point(px(0.), px(0.)), size(px(800.), px(600.))); + assert!(window.contains(&panel.origin) && window.contains(&panel.bottom_right())); + click(cx, "sort-name"); + assert_eq!( + host.update(cx, |host, _| host.picked.last().copied()), + Some("name") + ); + assert_eq!(open(&host, cx), None); + } + + #[gpui::test] + fn submenu_keyboard_returns_focus_and_cancels_sibling_hover(cx: &mut TestAppContext) { + let (host, cx) = submenu_setup(cx); + let field = focus_field(&host, cx); + cx.run_until_parked(); + host.update(cx, |host, cx| host.popup.open(Which::A, cx)); + cx.run_until_parked(); + cx.simulate_keystrokes("g right left"); + assert_eq!(highlighted(&host, cx), Some("group".into())); + for key in ["left", "escape"] { + cx.simulate_keystrokes("g right d"); + assert_eq!( + host.update(cx, |host, _| host.child.highlighted.clone()), + Some("group-date".into()) + ); + hover(cx, "sort"); + wait_hover(cx, 100); + assert_eq!(child(&host, cx), Some(1)); + cx.simulate_keystrokes(key); + wait_hover(cx, 200); + assert_eq!(child(&host, cx), None); + assert_eq!(highlighted(&host, cx), Some("sort".into())); + assert_eq!( + focused(cx), + Some(host.update(cx, |host, _| host.popup.focus.clone())) + ); + hover(cx, "outside"); + } + cx.simulate_keystrokes("end enter"); + assert_eq!(child(&host, cx), Some(2)); + cx.simulate_keystrokes("escape escape"); + assert_eq!(open(&host, cx), None); + assert_eq!(focused(cx), Some(field)); + } + + #[gpui::test] + fn submenu_hovering_an_ordinary_row_returns_the_keyboard(cx: &mut TestAppContext) { + let (host, cx) = submenu_setup(cx); + cx.simulate_keystrokes("g right"); + hover(cx, "a-one"); + assert_eq!(child(&host, cx), None); + assert_eq!(highlighted(&host, cx), Some("a-one".into())); + cx.simulate_keystrokes("enter"); + assert_eq!(host.update(cx, |host, _| host.picked.clone()), vec!["one"]); + assert_eq!(open(&host, cx), None); + } + + #[gpui::test] + fn submenu_application_vim_walks_and_selects_in_the_child(cx: &mut TestAppContext) { + let (host, cx) = submenu_setup(cx); + host.update(cx, |host, cx| { + host.vim = true; + cx.notify(); + }); + cx.run_until_parked(); + click(cx, "group"); + for (keys, row) in [ + ("k", "group-date"), + ("j", "group-folder"), + ("g g", "group-date"), + ("G", "deep"), + ] { + cx.simulate_keystrokes(keys); + assert_eq!( + host.update(cx, |host, _| host.child.highlighted.clone()), + Some(row.into()) + ); + } + cx.simulate_keystrokes("k enter"); + assert_eq!( + host.update(cx, |host, _| host.picked.clone()), + vec!["metadata"] + ); + assert_eq!(open(&host, cx), None); + } + + #[gpui::test] + fn submenu_hover_keeps_ordinary_nested_popups_open(cx: &mut TestAppContext) { + let (host, cx) = submenu_setup(cx); + click(cx, "group"); + host.update(cx, |host, cx| host.child.open(1, cx)); + cx.run_until_parked(); + hover(cx, "a-one"); + assert_eq!(child(&host, cx), Some(1)); + host.update(cx, |host, cx| host.child.close(cx)); + click(cx, "a-sub"); + hover(cx, "a-one"); + assert!(host.update(cx, |host, _| host.nested.is_open(&()))); + assert_eq!(open(&host, cx), Some(Which::A)); + } + #[gpui::test] fn a_second_press_on_the_trigger_closes_the_menu(cx: &mut TestAppContext) { let (host, cx) = setup(cx); From d00ac8965f71c232f5e64e064e0b2ca87c5b8078 Mon Sep 17 00:00:00 2001 From: Michael Atwood <70112949+doowta@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:14:25 -0500 Subject: [PATCH 03/29] Agent: add sidebar View options Let a single-computer sidebar switch between Folder, Status and Recent, choose a stable order for every section, and choose whether task metadata shows. Persist the view with safe defaults and keep paired browsing unchanged. Reveal the header controls on hover while keeping the open View menu's button visible. Omit Date grouping until its clock lifecycle is defined. Co-Authored-By: Codex GPT-6.1-Sol --- .../app/assets/icons/list-filter.svg | 1 + apps/maple-agent/app/src/assets.rs | 1 + apps/maple-agent/app/src/settings.rs | 80 ++++ .../maple-agent/app/src/ui/chat/navigation.rs | 3 +- apps/maple-agent/app/src/ui/chat/sidebar.rs | 440 +++++++++++++----- .../app/src/ui/chat/sidebar_paging.rs | 23 + apps/maple-agent/app/src/ui/chat/tests.rs | 275 +++++++++-- 7 files changed, 666 insertions(+), 157 deletions(-) create mode 100644 apps/maple-agent/app/assets/icons/list-filter.svg diff --git a/apps/maple-agent/app/assets/icons/list-filter.svg b/apps/maple-agent/app/assets/icons/list-filter.svg new file mode 100644 index 000000000..5e516fe43 --- /dev/null +++ b/apps/maple-agent/app/assets/icons/list-filter.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/maple-agent/app/src/assets.rs b/apps/maple-agent/app/src/assets.rs index bbb74b98c..c8595dcca 100644 --- a/apps/maple-agent/app/src/assets.rs +++ b/apps/maple-agent/app/src/assets.rs @@ -34,6 +34,7 @@ assets!( "icons/folder.svg", "icons/globe.svg", "icons/image.svg", + "icons/list-filter.svg", "icons/loader-circle.svg", "icons/lock.svg", "icons/mic.svg", diff --git a/apps/maple-agent/app/src/settings.rs b/apps/maple-agent/app/src/settings.rs index db4b5b941..3088119b4 100644 --- a/apps/maple-agent/app/src/settings.rs +++ b/apps/maple-agent/app/src/settings.rs @@ -13,6 +13,40 @@ pub struct RemoteSidebarSettings { pub pinned_tasks: Vec, } +/// How the sidebar groups and orders tasks: its View options menu. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(default)] +pub struct SidebarView { + pub group: SidebarGroup, + pub sort: SidebarSort, + pub ascending: bool, + /// The project line under each title. `None` shows it except when + /// grouped by folder, whose header already names the project. + pub metadata: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SidebarGroup { + /// Active, settled: the task ladder. + Status, + #[serde(alias = "none")] + Recent, + #[default] + #[serde(other)] + Folder, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SidebarSort { + Created, + Name, + #[default] + #[serde(other)] + Updated, +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct AppSettings { /// Whether tool cards show input/output payloads by default. @@ -47,6 +81,8 @@ pub struct AppSettings { /// `/work/project` is not the same project as that path on this computer. #[serde(default)] pub remote_sidebars: std::collections::HashMap, + #[serde(default)] + pub sidebar_view: SidebarView, /// Whether run completion and questions raise desktop notifications /// while the window is not focused. #[serde(default = "default_desktop_notifications")] @@ -246,6 +282,7 @@ impl Default for AppSettings { pinned_tasks: Vec::new(), project_names: std::collections::HashMap::new(), remote_sidebars: std::collections::HashMap::new(), + sidebar_view: SidebarView::default(), desktop_notifications: default_desktop_notifications(), automatic_update_downloads: default_automatic_update_downloads(), password_notification_details: false, @@ -804,6 +841,41 @@ pub(crate) fn install_test_store(store: std::sync::Arc self.new_session(cx), Some(SidebarTarget::Search) => self.focus_sidebar_search(window, cx), Some(SidebarTarget::Projects) => self.sidebar.update(cx, |sidebar, cx| { - sidebar.activate_projects(cx); + let open = sidebar.header_menu_open(); + sidebar.set_header_menu_open(!open, cx); }), Some(SidebarTarget::Folder(root)) => self.sidebar.update(cx, |sidebar, cx| { sidebar.set_folder_expanded(&root, None, cx); diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index aa779c4af..a0e5a4ce3 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -76,6 +76,7 @@ use super::commands::ChatCommand; use super::navigation::SidebarTarget; use super::{ChatScreen, section_label}; use crate::backend::AgentBackend; +use crate::settings::{SidebarGroup, SidebarSort, SidebarView}; use crate::ui::icons::{icon, spinner_with_id, wordmark}; use crate::ui::popup::{Menu, MenuItem, Placement, Popup}; use crate::ui::text_input::TextInput; @@ -198,7 +199,7 @@ pub(super) struct SidebarRow { pub(super) menu_delete_id: SharedString, pub(super) menu_reopen_id: SharedString, pub(super) title: SharedString, - /// Display name of the task's project, for search and accessibility. + /// Display name of the task's project, for metadata, search and accessibility. pub(super) project_name: SharedString, /// Lower-cased title, matched against the sidebar filter. pub(super) search: String, @@ -243,6 +244,8 @@ const ALL_PROJECTS_ID: &str = "switcher-all-projects"; #[derive(Clone, Debug, PartialEq, Eq)] enum SidebarPopup { Switcher, + /// The View options menu: grouping, order, what rows show. + View, /// A task row's overflow menu, by the row's shared id. Task(Arc), } @@ -357,12 +360,15 @@ pub(super) enum SidebarEmpty { NoMatches, } -/// A named section of the task list: pinned tasks, then project folders. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// A named section of the task list: pinned tasks, then the sections of +/// the chosen grouping. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub(super) enum SidebarSection { Pinned, Active, Settled, + /// One flat list of recent tasks. + Recents, /// A project's tasks, by index into the switcher roots. Folder(usize), } @@ -381,6 +387,7 @@ impl SidebarSection { Self::Pinned => "Pinned", Self::Active => "Active", Self::Settled => "Settled", + Self::Recents => "Recents", Self::Folder(_) => "Tasks", } } @@ -390,6 +397,7 @@ impl SidebarSection { Self::Pinned => "section-toggle-pinned", Self::Active => "section-toggle-active", Self::Settled => "section-toggle-settled", + Self::Recents => "section-toggle-recents", Self::Folder(_) => "section-toggle-tasks", } } @@ -459,13 +467,13 @@ pub(super) struct Sidebar { /// order. sections: Vec<(SidebarSection, Vec)>, archived_rows: Vec, - pinned_expanded: bool, - active_expanded: bool, - settled_expanded: bool, + /// Folded sections other than folders, which fold by root below. + collapsed: HashSet, collapsed_roots: HashSet, /// Whether the list under the Projects header shows. projects_expanded: bool, archived_expanded: bool, + view: SidebarView, switcher_roots: Vec, computer_switcher_roots: Vec>, scope_label: SharedString, @@ -753,12 +761,11 @@ impl Sidebar { pinned_rows: Vec::new(), sections: Vec::new(), archived_rows: Vec::new(), - pinned_expanded: true, - active_expanded: true, - settled_expanded: true, + collapsed: HashSet::new(), collapsed_roots: HashSet::new(), projects_expanded: true, archived_expanded: false, + view: settings.sidebar_view, switcher_roots: Vec::new(), computer_switcher_roots: Vec::new(), scope_label: "All projects".into(), @@ -1121,27 +1128,13 @@ impl Sidebar { } #[cfg(test)] - pub(super) fn active_rows(&self) -> Vec { - self.entries - .iter() - .filter_map(|entry| match entry { - SidebarEntry::Task(task) if !task.pinned && !task.settled && !task.archived => { - Some(task.session) - } - _ => None, - }) - .collect() + pub(super) fn active_rows(&self) -> &[usize] { + self.section_rows(SidebarSection::Active) } #[cfg(test)] - pub(super) fn settled_rows(&self) -> Vec { - self.entries - .iter() - .filter_map(|entry| match entry { - SidebarEntry::Task(task) if task.settled => Some(task.session), - _ => None, - }) - .collect() + pub(super) fn settled_rows(&self) -> &[usize] { + self.section_rows(SidebarSection::Settled) } /// The folder groups' project names, in display order. @@ -1156,6 +1149,16 @@ impl Sidebar { .collect() } + #[cfg(test)] + pub(super) fn view(&self) -> SidebarView { + self.view + } + + #[cfg(test)] + pub(super) fn show_metadata_for_test(&self) -> bool { + self.show_metadata() + } + #[cfg(test)] pub(super) fn archived_rows(&self) -> &[usize] { &self.archived_rows @@ -1175,8 +1178,18 @@ impl Sidebar { self.popup.open_key().is_some() || self.project_popup.open_key().is_some() } + /// The menu the Projects header opens: the switcher while paired + /// computers list their projects there, else the View options. + fn header_popup(&self) -> SidebarPopup { + if self.computers.len() > 1 { + SidebarPopup::Switcher + } else { + SidebarPopup::View + } + } + pub(super) fn header_menu_open(&self) -> bool { - self.popup.is_open(&SidebarPopup::Switcher) + self.popup.is_open(&self.header_popup()) } #[cfg(test)] @@ -1244,6 +1257,11 @@ impl Sidebar { /// process reads; a fixture starts from nothing persisted. #[cfg(test)] pub(super) fn reset_persisted_for_test(&mut self) { + // Most fixtures check the task ladder, so they group by status. + self.view = SidebarView { + group: SidebarGroup::Status, + ..SidebarView::default() + }; self.pinned_tasks.clear(); self.project_metadata.clear(); self.task_project_labels.clear(); @@ -1370,9 +1388,8 @@ impl Sidebar { true } - /// Rebuild pinned tasks and project folders, each newest activity - /// first. Called when sessions, roots, names, or the filter change, - /// not per render. + /// Rebuild pinned tasks and the view's grouped, sorted sections when + /// sessions, roots, names, the filter, or the view change, never per frame. fn rebuild_sections(&mut self) { self.sync_rows(); let filter = self.filter.as_str(); @@ -1448,59 +1465,87 @@ impl Sidebar { .filter(|root| !self.removed_projects.contains(*root)) .map(|root| SwitcherRoot::new(self.project_target(root), self.root_name(root).into())) .collect(); - let order = |&ix: &usize| std::cmp::Reverse(self.sessions[ix].updated_ms); - pinned.sort_by_key(order); - listed.sort_by_key(order); + let order = |a: &usize, b: &usize| { + let order = match if self.computers.len() > 1 { + SidebarSort::Updated + } else { + self.view.sort + } { + SidebarSort::Name => self.rows[*a].search.cmp(&self.rows[*b].search), + SidebarSort::Created => self.sessions[*a] + .created_ms + .cmp(&self.sessions[*b].created_ms), + SidebarSort::Updated => self.sessions[*a] + .updated_ms + .cmp(&self.sessions[*b].updated_ms), + }; + if self.computers.len() <= 1 && self.view.ascending { + order + } else { + order.reverse() + } + }; + pinned.sort_by(order); + listed.sort_by(order); if self.computers.len() <= 1 { - archived.sort_by_key(order); + archived.sort_by(order); } - if self.computers.len() > 1 { - let (active, settled) = listed - .into_iter() - .partition(|&ix| self.session_is_active(&self.sessions[ix])); - self.sections = vec![ - (SidebarSection::Active, active), - (SidebarSection::Settled, settled), - ]; + let mut sections = Vec::new(); + match if self.computers.len() > 1 { + SidebarGroup::Status } else { - let folder_of: HashMap<&str, usize> = self - .switcher_roots - .iter() - .enumerate() - .map(|(index, root)| (root.root.as_str(), index)) - .collect(); - // Each root's newest activity, pinned tasks included, and - // its unpinned rows. - let mut folders = vec![(None, Vec::new()); self.switcher_roots.len()]; - for (position, &ix) in pinned.iter().chain(&listed).enumerate() { - let session = &self.sessions[ix]; - if let Some(&folder) = folder_of.get(session.project_root.as_str()) { - let (latest, rows) = &mut folders[folder]; - *latest = (*latest).max(Some(session.updated_ms)); - if position >= pinned.len() { - rows.push(ix); + self.view.group + } { + SidebarGroup::Status => { + let (active, settled) = listed + .into_iter() + .partition(|&ix| self.session_is_active(&self.sessions[ix])); + sections.push((SidebarSection::Active, active)); + sections.push((SidebarSection::Settled, settled)); + } + SidebarGroup::Recent => sections.push((SidebarSection::Recents, listed)), + SidebarGroup::Folder => { + let folder_of: HashMap<&str, usize> = self + .switcher_roots + .iter() + .enumerate() + .map(|(index, root)| (root.root.as_str(), index)) + .collect(); + // Each root's newest activity, pinned tasks included, and + // its unpinned rows. + let mut folders = vec![(None, Vec::new()); self.switcher_roots.len()]; + for (position, &ix) in pinned.iter().chain(&listed).enumerate() { + let session = &self.sessions[ix]; + if let Some(&folder) = folder_of.get(session.project_root.as_str()) { + let (latest, rows) = &mut folders[folder]; + *latest = (*latest).max(Some(session.updated_ms)); + if position >= pinned.len() { + rows.push(ix); + } } } + let mut folders: Vec<_> = folders + .into_iter() + .enumerate() + .filter(|(index, (_, rows))| { + let root = &self.switcher_roots[*index]; + scoped.is_none_or(|scoped| scoped == root.root) + && (!rows.is_empty() + || !filtering + || root.name.to_lowercase().contains(filter)) + }) + .collect(); + // The folder with the latest activity leads. + folders.sort_by_key(|(_, (latest, _))| std::cmp::Reverse(*latest)); + sections.extend( + folders + .into_iter() + .map(|(index, (_, rows))| (SidebarSection::Folder(index), rows)), + ); } - let mut folders: Vec<_> = folders - .into_iter() - .enumerate() - .filter(|(index, (_, rows))| { - let root = &self.switcher_roots[*index]; - scoped.is_none_or(|scoped| scoped == root.root) - && (!rows.is_empty() - || !filtering - || root.name.to_lowercase().contains(filter)) - }) - .collect(); - // The folder with the latest activity leads. - folders.sort_by_key(|(_, (latest, _))| std::cmp::Reverse(*latest)); - self.sections = folders - .into_iter() - .map(|(index, (_, rows))| (SidebarSection::Folder(index), rows)) - .collect(); } self.pinned_rows = pinned; + self.sections = sections; self.archived_rows = archived; self.rebuild_computer_switcher_roots(); self.rebuild_entries(); @@ -1571,13 +1616,21 @@ impl Sidebar { tasks(&mut entries, *section, rows); } if self.pages.enabled { - for kind in [ - PageKind::Active, - PageKind::Settled, - PageKind::Search, - PageKind::Projects, + for (kind, expanded) in [ + ( + PageKind::Active, + self.view.group != SidebarGroup::Status + || self.section_expanded(SidebarSection::Active), + ), + ( + PageKind::Settled, + self.view.group != SidebarGroup::Status + || self.section_expanded(SidebarSection::Settled), + ), + (PageKind::Search, !self.filter.is_empty()), + (PageKind::Projects, true), ] { - if self.pages.has_more(kind) { + if expanded && self.pages.has_more(kind) { entries.push(SidebarEntry::LoadMore(kind)); } } @@ -1600,9 +1653,18 @@ impl Sidebar { } if self.computers.len() > 1 && self.pages.enabled { for (kind, expanded) in [ - (PageKind::Pinned, self.pinned_expanded), - (PageKind::Active, self.active_expanded), - (PageKind::Settled, self.settled_expanded), + ( + PageKind::Pinned, + self.section_expanded(SidebarSection::Pinned), + ), + ( + PageKind::Active, + self.section_expanded(SidebarSection::Active), + ), + ( + PageKind::Settled, + self.section_expanded(SidebarSection::Settled), + ), (PageKind::Archived, self.archived_expanded), (PageKind::Search, !self.filter.is_empty()), ] { @@ -1620,7 +1682,11 @@ impl Sidebar { || (!self.pages.enabled && matches!(entry, SidebarEntry::ArchivedHeader)) }) } else { - self.pinned_rows.is_empty() && self.archived_rows.is_empty() && self.sections.is_empty() + self.pinned_rows.is_empty() + && self.archived_rows.is_empty() + && self.sections.iter().all(|(section, rows)| { + !matches!(section, SidebarSection::Folder(_)) && rows.is_empty() + }) }; if empty && !self.pages.any_loading() { entries.push(SidebarEntry::Empty(if self.filter.is_empty() { @@ -1868,9 +1934,7 @@ impl Sidebar { .switcher_roots .get(index) .is_none_or(|root| !self.collapsed_roots.contains(&root.root)), - SidebarSection::Pinned => self.pinned_expanded, - SidebarSection::Active => self.active_expanded, - SidebarSection::Settled => self.settled_expanded, + section => !self.collapsed.contains(§ion), } } @@ -1893,13 +1957,10 @@ impl Sidebar { } else { self.collapsed_roots.insert(root); } + } else if expanded { + self.collapsed.remove(§ion); } else { - match section { - SidebarSection::Pinned => self.pinned_expanded = expanded, - SidebarSection::Active => self.active_expanded = expanded, - SidebarSection::Settled => self.settled_expanded = expanded, - SidebarSection::Folder(_) => unreachable!(), - } + self.collapsed.insert(section); } self.rebuild_entries(); cx.notify(); @@ -1933,10 +1994,33 @@ impl Sidebar { } } + /// Whether task rows show their project line. + fn show_metadata(&self) -> bool { + self.view + .metadata + .unwrap_or(self.view.group != SidebarGroup::Folder) + } + + /// Change the view, keep it in the app settings, and rebuild what it + /// shapes. The project line changes row heights, so every row is + /// measured again. + pub(super) fn set_view(&mut self, view: SidebarView, cx: &mut Context) { + if self.view == view { + return; + } + self.view = view; + self.rebuild_sections(); + self.list.remeasure_items(0..self.entries.len()); + cx.notify(); + crate::settings::update_settings_in_background(move |settings| { + settings.sidebar_view = view + }); + } + /// Open or close the Projects header's menu from its button. pub(super) fn toggle_header_menu(&mut self, cx: &mut Context) { self.project_popup.close(cx); - self.popup.toggle(SidebarPopup::Switcher, cx); + self.popup.toggle(self.header_popup(), cx); } /// Open or close the Projects header's menu from the keyboard. @@ -1947,7 +2031,9 @@ impl Sidebar { return; } self.project_popup.close(cx); - if open { + if open && self.header_popup() == SidebarPopup::View { + self.popup.open(SidebarPopup::View, cx); + } else if open { let current = self .project_filter .as_deref() @@ -1963,16 +2049,6 @@ impl Sidebar { } } - /// Activate the Projects header: paired computers open the switcher; - /// a single computer folds its project list. - pub(super) fn activate_projects(&mut self, cx: &mut Context) { - if self.computers.len() > 1 { - self.set_header_menu_open(!self.header_menu_open(), cx); - } else { - self.set_projects_expanded(!self.projects_expanded, cx); - } - } - /// Scope the sidebar to one project, or show every project again. pub(super) fn set_project_filter(&mut self, root: Option, cx: &mut Context) { self.project_filter = root.filter(|root| !root.is_empty()); @@ -3056,15 +3132,25 @@ impl Sidebar { } /// The Projects header: its label folds the list under it, and its - /// button adds a project. + /// buttons open the View options and add a project. fn render_projects_header(&self, window: &mut Window, cx: &mut Context) -> AnyElement { if self.computers.len() > 1 { return self.render_switcher_header(window, cx); } + let open = self.popup.is_open(&SidebarPopup::View); + let menu = open.then(|| { + self.popup + .render(self.view_menu(), Placement::BelowEnd, window, cx) + }); let add = self.new_project_button().on_click( cx.listener(|_this, _event, _window, cx| cx.emit(SidebarEvent::ChooseProject)), ); let expanded = self.projects_expanded; + let label = if self.view.group == SidebarGroup::Folder { + "Projects" + } else { + "Tasks" + }; div() .group("projects-header-actions") .mt_3() @@ -3078,7 +3164,7 @@ impl Sidebar { div() .id("projects-header") .role(gpui::Role::Button) - .aria_label("Projects") + .aria_label(label) .aria_expanded(expanded) .debug_selector(|| "projects-header".to_string()) .flex_1() @@ -3092,20 +3178,115 @@ impl Sidebar { this.set_projects_expanded(!this.projects_expanded, cx); this.hover_heading(SidebarEntry::ProjectsHeader, cx); })) - .child(section_label("Projects")) + .child(section_label(label)) .child(self.section_chevron( expanded, "projects-header-actions", SidebarHoverSection::Projects, )), ) + .child( + div() + .relative() + .child(self.popup.trigger( + SidebarPopup::View, + self.view_button(open), + cx, + |this, _, cx| this.toggle_header_menu(cx), + )) + .children(menu), + ) .child(add) .into_any_element() } + fn view_button(&self, open: bool) -> gpui::Stateful
{ + header_button("view-options", "list-filter", "View options", open) + .debug_selector(|| "view-options".into()) + .when(!open, |button| { + button + .opacity(0.) + .group_hover("projects-header-actions", |style| style.opacity(1.)) + }) + } + + /// The View options menu: one list of labeled sections, the current + /// choice of each checked. + fn view_menu(&self) -> Menu { + let view = self.view; + let choice = |id: &'static str, label: &'static str, current: bool, next: SidebarView| { + MenuItem::new(id, label, move |this: &mut Self, _: &mut Window, cx| { + this.set_view(next, cx) + }) + .current(current) + }; + let mut menu = Menu::new("view-menu", px(220.)) + .label("View options") + .application_vim(self.application_vim_enabled) + .header("Group by"); + for (id, label, group) in [ + ("view-group-folder", "Folder", SidebarGroup::Folder), + ("view-group-status", "Status", SidebarGroup::Status), + ("view-group-recent", "Recent", SidebarGroup::Recent), + ] { + menu = menu.item(choice( + id, + label, + view.group == group, + SidebarView { group, ..view }, + )); + } + menu = menu.separator().header("Sort by"); + for (id, label, sort) in [ + ("view-sort-updated", "Last updated", SidebarSort::Updated), + ("view-sort-created", "Date created", SidebarSort::Created), + ("view-sort-name", "Name", SidebarSort::Name), + ] { + menu = menu.item(choice( + id, + label, + view.sort == sort, + SidebarView { sort, ..view }, + )); + } + for (id, label, ascending) in [ + ("view-sort-descending", "Descending", false), + ("view-sort-ascending", "Ascending", true), + ] { + menu = menu.item(choice( + id, + label, + view.ascending == ascending, + SidebarView { ascending, ..view }, + )); + } + let metadata = self.show_metadata(); + menu.separator() + .header("Show") + .item(choice( + "view-show-metadata", + "Metadata", + metadata, + SidebarView { + metadata: Some(!metadata), + ..view + }, + )) + .separator() + .item( + choice( + "view-reset", + "Reset to defaults", + false, + SidebarView::default(), + ) + .enabled(view != SidebarView::default()), + ) + } + /// Add project stays visible until there is a project to browse. fn new_project_button(&self) -> gpui::Stateful
{ - header_button("new-project", "folder-plus", "New project").when( + header_button("new-project", "folder-plus", "New project", false).when( !self.switcher_roots.is_empty(), |button| { button @@ -3297,7 +3478,7 @@ impl Sidebar { // A sibling over the header's end, not a button inside a // button. It blocks the pointer, so a press on it never // reaches the header behind it. - header_button("new-project", "folder-plus", "New project") + header_button("new-project", "folder-plus", "New project", false) .occlude() .absolute() .right_4() @@ -3456,7 +3637,10 @@ impl Sidebar { archived, .. } = task; - let folder_task = self.computers.len() <= 1 && !is_pinned && !archived; + let folder_task = self.computers.len() <= 1 + && self.view.group == SidebarGroup::Folder + && !is_pinned + && !archived; let row = &self.rows[index]; let is_selected = selected == Some(&*row.id); let activity = (!archived) @@ -3528,7 +3712,24 @@ impl Sidebar { Some(field) => column.child(field), None => column .child(div().line_clamp(1).text_ellipsis().child(row.title.clone())), - }), + }) + .when( + self.computers.len() <= 1 && self.show_metadata(), + |column| { + column.child( + div() + .text_xs() + .text_color(gpui::rgb(if is_selected { + theme::text_secondary() + } else { + theme::text_muted() + })) + .line_clamp(1) + .text_ellipsis() + .child(row.project_name.clone()), + ) + }, + ), ) .when_some(activity, |row_element, activity| { row_element.child(activity_indicator(&row.spinner_id, activity)) @@ -3752,11 +3953,13 @@ fn row_menu_button( }) } -/// An icon button of the Projects header. +/// An icon button of the Projects header. It stays pressed, without its +/// tooltip, while its menu is open. fn header_button( id: &'static str, icon_name: &'static str, label: &'static str, + open: bool, ) -> gpui::Stateful
{ div() .id(id) @@ -3773,7 +3976,12 @@ fn header_button( .bg(gpui::rgb(theme::bg_sidebar_row_hover())) .cursor_pointer() }) - .tooltip(widgets::tooltip(label, None)) + .when(open, |button| { + button.bg(gpui::rgb(theme::bg_sidebar_row_selected())) + }) + .when(!open, |button| { + button.tooltip(widgets::tooltip(label, None)) + }) .child(icon(icon_name, px(16.), theme::text_secondary())) } 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 2174c047c..01332cacf 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_paging.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_paging.rs @@ -104,6 +104,7 @@ mod tests { let sidebar = chat.read_with(cx, |chat, _| chat.sidebar.clone()); sidebar.update(cx, |sidebar, cx| { sidebar.reset_persisted_for_test(); + sidebar.view = SidebarView::default(); sidebar.pages.enabled = true; sidebar.set_application_vim_enabled(true, cx); sidebar.set_project_name_for_test("/empty", "Empty"); @@ -185,6 +186,7 @@ mod tests { let sidebar = chat.read_with(cx, |chat, _| chat.sidebar.clone()); sidebar.update(cx, |sidebar, cx| { sidebar.reset_persisted_for_test(); + sidebar.view = SidebarView::default(); sidebar.computers = Arc::new(vec![ Computer::pending("one".into(), "One".into(), None), Computer::pending("two".into(), "Two".into(), None), @@ -272,6 +274,7 @@ mod tests { let sidebar = chat.read_with(cx, |chat, _| chat.sidebar.clone()); sidebar.update(cx, |sidebar, cx| { sidebar.reset_persisted_for_test(); + sidebar.view = SidebarView::default(); sidebar.pages.enabled = true; let project = |root: &str| maple_agent::store::ProjectSummary { root: root.into(), @@ -329,6 +332,16 @@ mod tests { .iter() .any(|entry| matches!(entry, SidebarEntry::Empty(_))) ); + for group in [SidebarGroup::Status, SidebarGroup::Recent] { + sidebar.view.group = group; + sidebar.rebuild_sections(); + assert!( + sidebar + .entries + .contains(&SidebarEntry::Empty(SidebarEmpty::NoTasks)) + ); + } + sidebar.view.group = SidebarGroup::Folder; sidebar.apply_task_page( PageKind::Active, vec![TaskSummary { @@ -357,6 +370,7 @@ mod tests { let sidebar = chat.read_with(cx, |chat, _| chat.sidebar.clone()); sidebar.update(cx, |sidebar, cx| { sidebar.reset_persisted_for_test(); + sidebar.view = SidebarView::default(); sidebar.pages = SidebarPages { enabled: true, ..SidebarPages::default() @@ -444,6 +458,15 @@ mod tests { sidebar.set_projects_expanded(true, cx); assert_eq!(sidebar.pages.ids[&PageKind::Active], ["a1", "a2", "c1"]); assert_eq!(sidebar.pages.next[&PageKind::Active], "continuation"); + sidebar.view.group = SidebarGroup::Status; + sidebar.rebuild_sections(); + sidebar.toggle_section_expanded(SidebarSection::Active, cx); + assert!(!more(sidebar).contains(&PageKind::Active)); + assert!(more(sidebar).contains(&PageKind::Settled)); + sidebar.view.group = SidebarGroup::Recent; + sidebar.rebuild_sections(); + assert!(more(sidebar).contains(&PageKind::Active)); + assert_eq!(sidebar.section_rows(SidebarSection::Recents).len(), 4); }); } diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 4110670cd..9252665a9 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -238,6 +238,7 @@ mod state_tests { .push(summary_at("other", "Other project", "/other")); this.sync_sidebar(cx); this.sidebar.update(cx, |sidebar, cx| { + sidebar.set_view(crate::settings::SidebarView::default(), cx); sidebar.set_project_filter(Some("/tmp/proj".into()), cx); assert!(sidebar.folder_names().is_empty()); }); @@ -5377,10 +5378,10 @@ mod state_tests { }); } - /// Folder rows retain their active and settled ladder states, with - /// pinned work separate and newest activity first within each folder. + /// Tasks split into the inbox sections: pinned tasks first, the + /// active inbox, then the settled rest, each newest activity first. #[gpui::test] - fn test_folder_rows_keep_pinned_active_and_settled_states(cx: &mut TestAppContext) { + fn test_sections_split_pinned_active_and_settled(cx: &mut TestAppContext) { cx.executor().allow_parking(); let screen = screen(cx); screen.update(cx, |this, cx| { @@ -5409,20 +5410,13 @@ mod state_tests { assert_eq!(this.sidebar_active(cx), vec![1]); assert_eq!(this.sidebar_settled(cx), vec![0]); - // Folders keep their tasks together in newest activity order. + // Sections read newest activity first. let mut older = summary_at("s3", "Older", "/b"); older.updated_ms = 5; this.sessions.push(older); this.sync_sidebar(cx); assert_eq!(this.sidebar_active(cx), vec![1, 2]); assert_eq!(this.sidebar_settled(cx), vec![0]); - use crate::ui::chat::sidebar::{SidebarEntry, SidebarSection}; - let sidebar = this.sidebar.read(cx); - assert_eq!(sidebar.section_rows(SidebarSection::Folder(0)), [1, 0]); - assert_eq!(sidebar.section_rows(SidebarSection::Folder(1)), [2]); - assert!(!sidebar.entries().iter().any(|entry| matches!( - entry, SidebarEntry::Task(task) if task.pinned - ))); }); } @@ -5479,6 +5473,7 @@ mod state_tests { /// header already shows. #[gpui::test] fn test_folder_grouping_heads_each_project(cx: &mut TestAppContext) { + use crate::settings::SidebarView; use crate::ui::chat::sidebar::{SidebarEntry, SidebarSection}; cx.executor().allow_parking(); let screen = screen(cx); @@ -5497,8 +5492,11 @@ mod state_tests { archived, ]; this.sync_sidebar(cx); - this.sidebar.update(cx, |sidebar, _cx| { + this.sidebar.update(cx, |sidebar, cx| { sidebar.set_pinned_tasks_for_test(vec!["s4".to_string()]); + assert!(sidebar.show_metadata_for_test()); + sidebar.set_view(SidebarView::default(), cx); + assert!(!sidebar.show_metadata_for_test()); assert_eq!(sidebar.folder_names(), ["b", "a"]); assert_eq!(sidebar.pinned_rows(), [3]); assert_eq!(sidebar.section_rows(SidebarSection::Folder(1)), [1]); @@ -5537,10 +5535,64 @@ mod state_tests { }); } + /// Metadata can be shown over Folder defaults; Recent is one sorted + /// list below Pinned, with its own heading and Archived at the tail. + #[gpui::test] + fn test_metadata_and_recent_grouping(cx: &mut TestAppContext) { + use crate::settings::{SidebarGroup, SidebarView}; + use crate::ui::chat::sidebar::{SidebarEntry, SidebarSection}; + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + this.sessions = vec![ + summary("s1", "One"), + summary("s2", "Two"), + summary("s3", "Three"), + ]; + this.sessions[0].updated_ms = 1; + this.sessions[1].updated_ms = 2; + this.sessions[2].state = AgentTaskState::Archived; + this.apply_recent_roots(vec![recent_root("/empty")], cx); + this.sync_sidebar(cx); + this.sidebar.update(cx, |sidebar, cx| { + sidebar.set_view(SidebarView::default(), cx); + assert_eq!(sidebar.folder_names(), ["proj", "empty"]); + sidebar.set_filter_for_test("one", cx); + assert_eq!(sidebar.folder_names(), ["proj"]); + sidebar.set_filter_for_test("", cx); + assert!(!sidebar.show_metadata_for_test()); + sidebar.set_view( + SidebarView { + metadata: Some(true), + ..SidebarView::default() + }, + cx, + ); + assert!(sidebar.show_metadata_for_test()); + sidebar.set_pinned_tasks_for_test(vec!["s1".into()]); + sidebar.set_view( + SidebarView { + group: SidebarGroup::Recent, + ..SidebarView::default() + }, + cx, + ); + assert_eq!(sidebar.section_rows(SidebarSection::Recents), [1]); + assert_eq!(sidebar.pinned_rows(), [0]); + assert!(sidebar.show_metadata_for_test()); + assert!(matches!( + sidebar.entries().last(), + Some(SidebarEntry::ArchivedHeader) + )); + }); + }); + } + /// A folder folds its tasks and stays folded when the list rebuilds; /// the Projects header folds every group, never the pinned ones. #[gpui::test] fn test_folders_and_the_project_list_fold(cx: &mut TestAppContext) { + use crate::settings::SidebarView; use crate::ui::chat::sidebar::{SidebarEntry, SidebarSection}; cx.executor().allow_parking(); let screen = screen(cx); @@ -5553,6 +5605,7 @@ mod state_tests { this.sync_sidebar(cx); this.sidebar.update(cx, |sidebar, cx| { sidebar.set_pinned_tasks_for_test(vec!["s3".to_string()]); + sidebar.set_view(SidebarView::default(), cx); sidebar.set_folder_expanded("/a", Some(false), cx); }); this.sessions.push(summary_at("s4", "Four", "/a")); @@ -5587,6 +5640,77 @@ mod state_tests { }); } + /// Every sort key and direction orders Recent, Pinned and Archived, + /// preserving input order for equal keys, including case-insensitive names. + #[gpui::test] + fn test_sort_and_direction_order_each_section(cx: &mut TestAppContext) { + use crate::settings::SidebarSort::{Created, Name, Updated}; + use crate::settings::{SidebarGroup, SidebarView}; + use crate::ui::chat::sidebar::SidebarSection; + cx.executor().allow_parking(); + let screen = screen(cx); + screen.update(cx, |this, cx| { + let at = |id, title, updated_ms, created_ms| AgentSessionSummary { + updated_ms, + created_ms, + ..summary(id, title) + }; + let archived = |id, title, updated_ms, created_ms| AgentSessionSummary { + state: AgentTaskState::Archived, + ..at(id, title, updated_ms, created_ms) + }; + this.sessions = vec![ + at("s1", "Beta", 1, 3), + at("s2", "alpha", 2, 1), + at("s3", "Gamma", 3, 2), + at("s4", "BETA", 1, 3), + archived("s5", "Delta", 4, 5), + archived("s6", "epsilon", 5, 4), + archived("s7", "delta", 4, 5), + at("p1", "zeta", 6, 8), + at("p2", "ZETA", 6, 8), + at("p3", "eta", 7, 7), + ]; + this.sync_sidebar(cx); + this.sidebar.update(cx, |sidebar, cx| { + sidebar.set_pinned_tasks_for_test(vec!["p1".into(), "p2".into(), "p3".into()]); + for (sort, ascending, listed, archived, pinned) in [ + (Updated, false, [2, 1, 0, 3], [5, 4, 6], [9, 7, 8]), + (Updated, true, [0, 3, 1, 2], [4, 6, 5], [7, 8, 9]), + (Created, false, [0, 3, 2, 1], [4, 6, 5], [7, 8, 9]), + (Created, true, [1, 2, 0, 3], [5, 4, 6], [9, 7, 8]), + (Name, false, [2, 0, 3, 1], [5, 4, 6], [7, 8, 9]), + (Name, true, [1, 0, 3, 2], [4, 6, 5], [9, 7, 8]), + ] { + sidebar.set_view( + SidebarView { + group: SidebarGroup::Recent, + sort, + ascending, + ..SidebarView::default() + }, + cx, + ); + assert_eq!( + sidebar.section_rows(SidebarSection::Recents), + listed, + "Recent: {sort:?}, ascending={ascending}" + ); + assert_eq!( + sidebar.archived_rows(), + archived, + "Archived: {sort:?}, ascending={ascending}" + ); + assert_eq!( + sidebar.pinned_rows(), + pinned, + "Pinned: {sort:?}, ascending={ascending}" + ); + } + }); + }); + } + /// Equal-activity folders keep recent-root order, then path order, /// even when the task list first encounters a different project. #[gpui::test] @@ -5601,6 +5725,9 @@ mod state_tests { ]; this.apply_recent_roots(vec![recent_root("/z")], cx); this.sync_sidebar(cx); + this.sidebar.update(cx, |sidebar, cx| { + sidebar.set_view(crate::settings::SidebarView::default(), cx) + }); assert_eq!(this.sidebar.read(cx).folder_names(), ["z", "a", "b"]); }); } @@ -5610,6 +5737,7 @@ mod state_tests { /// list is empty. #[gpui::test] fn test_folded_folders_are_not_an_empty_list(cx: &mut TestAppContext) { + use crate::settings::SidebarView; use crate::ui::chat::sidebar::{SidebarEmpty, SidebarEntry}; cx.executor().allow_parking(); let screen = screen(cx); @@ -5623,6 +5751,7 @@ mod state_tests { _ => None, }) }; + sidebar.set_view(SidebarView::default(), cx); sidebar.set_folder_expanded("/a", Some(false), cx); sidebar.set_folder_expanded("/b", Some(false), cx); assert_eq!(empty(sidebar), None); @@ -6101,15 +6230,19 @@ mod state_tests { assert_eq!(chat.update(cx, |this, cx| this.sidebar_pinned(cx)), vec![0]); } - /// Enter on Projects folds the list; paired computers keep their - /// switcher on the same target. + /// Application Vim opens View options from the Projects header and + /// its j and Enter pick a grouping. #[gpui::test] - fn test_application_vim_activates_the_projects_header(cx: &mut TestAppContext) { + fn test_application_vim_drives_the_view_menu(cx: &mut TestAppContext) { + use crate::settings::SidebarGroup; use crate::ui::chat::navigation::{ChatRegion, SidebarTarget}; let (chat, cx) = chat_window(cx, |this, cx| { - this.sessions = vec![summary("s1", "One")]; + this.sessions = vec![summary_at("s1", "One", "/a")]; this.sync_sidebar(cx); this.set_application_vim_enabled(true, cx); + this.sidebar.update(cx, |sidebar, cx| { + sidebar.set_view(crate::settings::SidebarView::default(), cx) + }); }); let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); cx.update(|window, app| { @@ -6122,18 +6255,14 @@ mod state_tests { }); }); cx.simulate_keystrokes("enter"); - assert!( - !sidebar - .update(cx, |sidebar, _| sidebar.vim_targets_for_test()) - .contains(&SidebarTarget::Task("s1".into())) + assert_eq!(open_menu(&chat, cx), "View"); + // Rows: Folder, Status, Recent, … + cx.simulate_keystrokes("j j enter"); + assert_eq!( + sidebar.update(cx, |sidebar, _| sidebar.view().group), + SidebarGroup::Status ); assert_eq!(open_menu(&chat, cx), ""); - cx.simulate_keystrokes("enter"); - assert!( - sidebar - .update(cx, |sidebar, _| sidebar.vim_targets_for_test()) - .contains(&SidebarTarget::Task("s1".into())) - ); chat.update(cx, |this, cx| { this.set_computers(identical_path_project_catalog(), cx) }); @@ -6141,20 +6270,61 @@ mod state_tests { sidebar.vim_select(SidebarTarget::Projects, cx) }); cx.simulate_keystrokes("enter"); - assert_eq!(open_menu(&chat, cx), "Switcher"); + assert!(sidebar.update(cx, |sidebar, _| sidebar.header_menu_open())); + assert_eq!( + sidebar.update(cx, |sidebar, _| sidebar.popup_highlight()), + Some("switcher-all-projects".into()) + ); cx.simulate_keystrokes("escape"); } + /// The View options button opens one menu; its items change the view + /// and Reset to defaults brings the defaults back. + #[gpui::test] + fn test_the_view_menu_sets_and_resets_the_view(cx: &mut TestAppContext) { + use crate::settings::{SidebarGroup, SidebarSort, SidebarView}; + 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 view = + |cx: &mut gpui::VisualTestContext| sidebar.update(cx, |sidebar, _| sidebar.view()); + press(cx, "view-options"); + assert_eq!(open_menu(&chat, cx), "View"); + press(cx, "view-sort-name"); + assert_eq!(view(cx).sort, SidebarSort::Name); + press(cx, "view-options"); + press(cx, "view-reset"); + assert_eq!(view(cx), SidebarView::default()); + assert_eq!(view(cx).group, SidebarGroup::Folder); + press(cx, "view-options"); + press(cx, "view-group-recent"); + assert_eq!(view(cx).group, SidebarGroup::Recent); + assert!(sidebar.update(cx, |sidebar, _| sidebar.entries().contains( + &crate::ui::chat::sidebar::SidebarEntry::SectionLabel( + crate::ui::chat::sidebar::SidebarSection::Recents + ) + ))); + press(cx, "view-options"); + press(cx, "view-show-metadata"); + assert_eq!(view(cx).metadata, Some(false)); + } + /// A folder header's menu opens from its "…" button, /// and its compose button starts a task in that project. #[gpui::test] fn test_a_folder_header_offers_its_project_actions(cx: &mut TestAppContext) { + use crate::settings::SidebarView; use crate::ui::chat::computers::Destination; use std::cell::RefCell; use std::rc::Rc; 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.set_view(SidebarView::default(), cx) + }); }); let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); let events = Rc::new(RefCell::new(Vec::new())); @@ -6183,6 +6353,9 @@ mod state_tests { .map(|ix| summary(&format!("s{ix}"), "Task")) .collect(); this.sync_sidebar(cx); + this.sidebar.update(cx, |sidebar, cx| { + sidebar.set_view(crate::settings::SidebarView::default(), cx) + }); }); let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); let header = cx.debug_bounds("folder-/tmp/proj").expect("folder header"); @@ -6234,6 +6407,7 @@ mod state_tests { archived, ]; this.sidebar.update(cx, |sidebar, cx| { + sidebar.set_view(crate::settings::SidebarView::default(), cx); sidebar.set_pinned_tasks_for_test(vec!["pinned".into()]); sidebar.set_archived_expanded(true, cx); }); @@ -6260,6 +6434,7 @@ mod state_tests { /// old row index instead of assigning its hover to the rebuilt entry. #[gpui::test] fn test_section_chevrons_clear_on_stationary_rebuilds(cx: &mut TestAppContext) { + use crate::settings::SidebarView; let (chat, cx) = chat_window(cx, |this, cx| { let mut one = summary_at("s1", "One", "/a"); one.updated_ms = 1; @@ -6267,6 +6442,9 @@ mod state_tests { two.updated_ms = 2; this.sessions = vec![one, two]; this.sync_sidebar(cx); + this.sidebar.update(cx, |sidebar, cx| { + sidebar.set_view(SidebarView::default(), cx) + }); }); let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); for change in ["reorder", "filter", "fold"] { @@ -6379,6 +6557,7 @@ mod state_tests { /// grouping, headers included; h and l fold a folder. #[gpui::test] fn test_application_vim_walks_folder_groups(cx: &mut TestAppContext) { + use crate::settings::SidebarView; use crate::ui::chat::navigation::SidebarTarget; let (chat, cx) = chat_window(cx, |this, cx| { this.sessions = vec![ @@ -6394,6 +6573,9 @@ mod state_tests { ]; this.sync_sidebar(cx); this.set_application_vim_enabled(true, cx); + this.sidebar.update(cx, |sidebar, cx| { + sidebar.set_view(SidebarView::default(), cx) + }); }); let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); let mut walked = Vec::new(); @@ -6590,7 +6772,14 @@ mod state_tests { open.push(format!("{popup:?}")); } if sidebar.header_menu_open() { - open.push("Switcher".to_string()); + open.push( + if this.computers.len() > 1 { + "Switcher" + } else { + "View" + } + .to_string(), + ); } if let Some(task) = sidebar.task_menu() { open.push(format!("Task({task})")); @@ -7248,7 +7437,7 @@ mod state_tests { this.sync_sidebar(cx); }); for (trigger, menu) in [ - ("menu-project-/tmp/proj", "ProjectMenu(/tmp/proj)"), + ("view-options", "View"), ("menu-session-s1", "Task(s1)"), ("model-picker", "Model"), ("mcp-menu", "Integrations"), @@ -7269,20 +7458,18 @@ mod state_tests { /// menu, whichever view owns either of them. #[gpui::test] fn test_opening_a_menu_closes_the_open_one(cx: &mut TestAppContext) { + use crate::settings::SidebarView; let (chat, cx) = chat_window(cx, |this, cx| { this.sessions = vec![summary("s1", "One")]; this.models = vec!["voxtral-small-24b".to_string()]; this.sync_sidebar(cx); }); for (first, then, open) in [ - ("menu-project-/tmp/proj", "model-picker", "Model"), + ("view-options", "model-picker", "Model"), ("menu-session-s1", "model-picker", "Model"), ("model-picker", "menu-session-s1", "Task(s1)"), - ( - "model-picker", - "menu-project-/tmp/proj", - "ProjectMenu(/tmp/proj)", - ), + ("model-picker", "view-options", "View"), + ("menu-session-s1", "view-options", "View"), ("root-picker", "mcp-menu", "Integrations"), ] { press(cx, first); @@ -7295,6 +7482,10 @@ mod state_tests { }); } + let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); + sidebar.update(cx, |sidebar, cx| { + sidebar.set_view(SidebarView::default(), cx) + }); // With a task menu open, the folder has no strip. An outside // press closes the menu and still reaches the folder underneath. press(cx, "menu-session-s1"); @@ -7610,6 +7801,7 @@ mod state_tests { this.sidebar.update(cx, |sidebar, cx| { sidebar.set_pinned_tasks_for_test(vec!["pinned".into()]); sidebar.set_project_filter(Some("/shared".into()), cx); + sidebar.set_view(crate::settings::SidebarView::default(), cx); }); }); let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); @@ -8087,6 +8279,9 @@ mod state_tests { summary_at("s2", "Two", &root), ]; this.apply_recent_roots(roots, cx); + this.sidebar.update(cx, |sidebar, cx| { + sidebar.set_view(crate::settings::SidebarView::default(), cx) + }); }); let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); // GPUI's debug selector API takes static strings. @@ -8420,9 +8615,9 @@ mod state_tests { let screen = screen(cx); screen.update(cx, |this, cx| { this.sidebar - .update(cx, |sidebar, cx| sidebar.open_task_menu_for_test("s1", cx)); + .update(cx, |sidebar, cx| sidebar.set_header_menu_open(true, cx)); this.escape(cx); - assert!(this.sidebar.read(cx).task_menu().is_none()); + assert!(!this.sidebar.read(cx).header_menu_open()); this.sidebar .update(cx, |sidebar, cx| sidebar.open_task_menu_for_test("s1", cx)); this.escape(cx); @@ -8709,7 +8904,7 @@ mod state_tests { this.project_root = Some("/tmp/proj".to_string()); this.sync_sidebar(cx); // The screen fixture selects s1: one step moves to s2, and - // back stops at the top. The second folder adds a header row. + // back stops at the top. assert_eq!(this.task_step_target(1, cx), Some((5, local("s2")))); assert_eq!( this.task_step_target(-1, cx), @@ -8718,13 +8913,13 @@ mod state_tests { ); this.selected_session = Some("s2".to_string()); this.sync_sidebar_selection(cx); - assert_eq!(this.task_step_target(1, cx), Some((7, local("s3")))); + assert_eq!(this.task_step_target(1, cx), Some((6, local("s3")))); assert_eq!(this.task_step_target(-1, cx), Some((4, local("s1")))); // With nothing selected, each direction enters from its end. this.selected_session = None; this.sync_sidebar_selection(cx); assert_eq!(this.task_step_target(1, cx), Some((4, local("s1")))); - assert_eq!(this.task_step_target(-1, cx), Some((7, local("s3")))); + assert_eq!(this.task_step_target(-1, cx), Some((6, local("s3")))); }); } From 771a4090f00a757f81be153e616ea99d2620bad7 Mon Sep 17 00:00:00 2001 From: Michael Atwood <70112949+doowta@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:15:23 -0500 Subject: [PATCH 04/29] Agent: show View options as submenus Keep grouping, sort order, and row visibility in separate child menus so View options stays compact and shows its current choices. Reuse the shared popup navigation and dismissal while preserving saved view preferences. Co-Authored-By: Codex GPT-6.1-Sol --- apps/maple-agent/app/src/ui/chat/sidebar.rs | 77 ++++++++++++++++----- apps/maple-agent/app/src/ui/chat/tests.rs | 33 +++++++-- apps/maple-agent/app/src/ui/popup.rs | 8 --- 3 files changed, 89 insertions(+), 29 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index a0e5a4ce3..3f2b5f0f0 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -250,6 +250,13 @@ enum SidebarPopup { Task(Arc), } +#[derive(Clone, Copy, PartialEq, Eq)] +enum ViewSubmenu { + Group, + Sort, + Show, +} + /// One row of the virtualized sidebar list, in display order. Rebuilt /// with the sections and when a section folds; the list builds only the /// rows on screen. @@ -483,6 +490,7 @@ pub(super) struct Sidebar { /// A project's overflow menu, keyed by execution computer and path. project_popup: Popup, project_menu_at: Option>, + view_popup: Popup, /// Trust status of the project whose menu is open, once it loads. menu_trust: Option, // Search and rename. @@ -773,6 +781,7 @@ impl Sidebar { popup: Popup::new(|this| &mut this.popup, cx), project_popup: Popup::new(|this| &mut this.project_popup, cx), project_menu_at: None, + view_popup: Popup::new(|this| &mut this.view_popup, cx), menu_trust: None, filter: String::new(), search_input, @@ -1192,6 +1201,11 @@ impl Sidebar { self.popup.is_open(&self.header_popup()) } + #[cfg(test)] + pub(super) fn view_submenu_open(&self) -> bool { + self.view_popup.open_key().is_some() + } + #[cfg(test)] pub(super) fn task_menu(&self) -> Option<&str> { match self.popup.open_key() { @@ -1855,7 +1869,8 @@ impl Sidebar { /// 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); - self.popup.close(cx) || project + let view = self.view_popup.close(cx); + self.popup.close(cx) || project || view } /// Run a backend future and hand its result back on this thread. @@ -3140,7 +3155,7 @@ impl Sidebar { let open = self.popup.is_open(&SidebarPopup::View); let menu = open.then(|| { self.popup - .render(self.view_menu(), Placement::BelowEnd, window, cx) + .render(self.view_menu(window, cx), Placement::BelowEnd, window, cx) }); let add = self.new_project_button().on_click( cx.listener(|_this, _event, _window, cx| cx.emit(SidebarEvent::ChooseProject)), @@ -3210,9 +3225,10 @@ impl Sidebar { }) } - /// The View options menu: one list of labeled sections, the current - /// choice of each checked. - fn view_menu(&self) -> Menu { + /// The View options menu, with each set of choices in its own submenu. + fn view_menu(&self, window: &mut Window, cx: &mut Context) -> Menu { + use ViewSubmenu::{Group, Show, Sort}; + let view = self.view; let choice = |id: &'static str, label: &'static str, current: bool, next: SidebarView| { MenuItem::new(id, label, move |this: &mut Self, _: &mut Window, cx| { @@ -3220,40 +3236,42 @@ impl Sidebar { }) .current(current) }; - let mut menu = Menu::new("view-menu", px(220.)) - .label("View options") - .application_vim(self.application_vim_enabled) - .header("Group by"); + let mut group_menu = Menu::new("view-group-menu", px(220.)) + .label("Group by") + .application_vim(self.application_vim_enabled); for (id, label, group) in [ ("view-group-folder", "Folder", SidebarGroup::Folder), ("view-group-status", "Status", SidebarGroup::Status), ("view-group-recent", "Recent", SidebarGroup::Recent), ] { - menu = menu.item(choice( + group_menu = group_menu.item(choice( id, label, view.group == group, SidebarView { group, ..view }, )); } - menu = menu.separator().header("Sort by"); + let mut sort_menu = Menu::new("view-sort-menu", px(220.)) + .label("Sort by") + .application_vim(self.application_vim_enabled); for (id, label, sort) in [ ("view-sort-updated", "Last updated", SidebarSort::Updated), ("view-sort-created", "Date created", SidebarSort::Created), ("view-sort-name", "Name", SidebarSort::Name), ] { - menu = menu.item(choice( + sort_menu = sort_menu.item(choice( id, label, view.sort == sort, SidebarView { sort, ..view }, )); } + sort_menu = sort_menu.separator(); for (id, label, ascending) in [ ("view-sort-descending", "Descending", false), ("view-sort-ascending", "Ascending", true), ] { - menu = menu.item(choice( + sort_menu = sort_menu.item(choice( id, label, view.ascending == ascending, @@ -3261,8 +3279,9 @@ impl Sidebar { )); } let metadata = self.show_metadata(); - menu.separator() - .header("Show") + let show_menu = Menu::new("view-show-menu", px(220.)) + .label("Show") + .application_vim(self.application_vim_enabled) .item(choice( "view-show-metadata", "Metadata", @@ -3271,7 +3290,32 @@ impl Sidebar { metadata: Some(!metadata), ..view }, - )) + )); + Menu::new("view-menu", px(220.)) + .label("View options") + .application_vim(self.application_vim_enabled) + .item( + self.view_popup + .submenu(Group, "view-group", "Group by", group_menu, window, cx) + .value(match view.group { + SidebarGroup::Folder => "Folder", + SidebarGroup::Status => "Status", + SidebarGroup::Recent => "Recent", + }), + ) + .item( + self.view_popup + .submenu(Sort, "view-sort", "Sort by", sort_menu, window, cx) + .value(match view.sort { + SidebarSort::Updated => "Last updated", + SidebarSort::Created => "Date created", + SidebarSort::Name => "Name", + }), + ) + .item( + self.view_popup + .submenu(Show, "view-show", "Show", show_menu, window, cx), + ) .separator() .item( choice( @@ -3794,6 +3838,7 @@ impl Sidebar { pub(super) fn sync_popup_focus(&mut self, window: &mut Window, cx: &mut Context) { self.popup.sync_focus(window, cx); self.project_popup.sync_focus(window, cx); + self.view_popup.sync_focus(window, cx); } } diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 9252665a9..87685bfcc 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -6231,7 +6231,7 @@ mod state_tests { } /// Application Vim opens View options from the Projects header and - /// its j and Enter pick a grouping. + /// its arrows, j and Enter pick a grouping in a submenu. #[gpui::test] fn test_application_vim_drives_the_view_menu(cx: &mut TestAppContext) { use crate::settings::SidebarGroup; @@ -6256,13 +6256,22 @@ mod state_tests { }); cx.simulate_keystrokes("enter"); assert_eq!(open_menu(&chat, cx), "View"); - // Rows: Folder, Status, Recent, … - cx.simulate_keystrokes("j j enter"); + // Enter on the header opens View options; Right enters Group by. + cx.simulate_keystrokes("j right"); + assert!(sidebar.update(cx, |sidebar, _| sidebar.view_submenu_open())); + cx.simulate_keystrokes("left"); + assert!(!sidebar.update(cx, |sidebar, _| sidebar.view_submenu_open())); + assert_eq!( + sidebar.update(cx, |sidebar, _| sidebar.popup_highlight()), + Some("view-group".into()) + ); + cx.simulate_keystrokes("right j enter"); assert_eq!( sidebar.update(cx, |sidebar, _| sidebar.view().group), SidebarGroup::Status ); assert_eq!(open_menu(&chat, cx), ""); + assert!(!sidebar.update(cx, |sidebar, _| sidebar.view_submenu_open())); chat.update(cx, |this, cx| { this.set_computers(identical_path_project_catalog(), cx) }); @@ -6278,8 +6287,7 @@ mod state_tests { cx.simulate_keystrokes("escape"); } - /// The View options button opens one menu; its items change the view - /// and Reset to defaults brings the defaults back. + /// Picking a View option closes the chain; reset restores the defaults. #[gpui::test] fn test_the_view_menu_sets_and_resets_the_view(cx: &mut TestAppContext) { use crate::settings::{SidebarGroup, SidebarSort, SidebarView}; @@ -6292,14 +6300,27 @@ mod state_tests { |cx: &mut gpui::VisualTestContext| sidebar.update(cx, |sidebar, _| sidebar.view()); press(cx, "view-options"); assert_eq!(open_menu(&chat, cx), "View"); + let closed = |cx: &mut gpui::VisualTestContext| { + assert_eq!(open_menu(&chat, cx), ""); + assert!(!sidebar.update(cx, |sidebar, _| sidebar.view_submenu_open())); + }; + press(cx, "view-sort"); press(cx, "view-sort-name"); assert_eq!(view(cx).sort, SidebarSort::Name); + closed(cx); + press(cx, "view-options"); + press(cx, "view-sort"); + press(cx, "view-sort-ascending"); + assert!(view(cx).ascending); + closed(cx); press(cx, "view-options"); press(cx, "view-reset"); assert_eq!(view(cx), SidebarView::default()); assert_eq!(view(cx).group, SidebarGroup::Folder); press(cx, "view-options"); + press(cx, "view-group"); press(cx, "view-group-recent"); + closed(cx); assert_eq!(view(cx).group, SidebarGroup::Recent); assert!(sidebar.update(cx, |sidebar, _| sidebar.entries().contains( &crate::ui::chat::sidebar::SidebarEntry::SectionLabel( @@ -6307,8 +6328,10 @@ mod state_tests { ) ))); press(cx, "view-options"); + press(cx, "view-show"); press(cx, "view-show-metadata"); assert_eq!(view(cx).metadata, Some(false)); + closed(cx); } /// A folder header's menu opens from its "…" button, diff --git a/apps/maple-agent/app/src/ui/popup.rs b/apps/maple-agent/app/src/ui/popup.rs index 6d52fd2cc..621b87cab 100644 --- a/apps/maple-agent/app/src/ui/popup.rs +++ b/apps/maple-agent/app/src/ui/popup.rs @@ -458,13 +458,6 @@ impl Popup { /// A parent row and its child menu, using this popup for the next level. /// Sync this popup's focus after the outer popup's, even while closed. - #[cfg_attr( - not(test), - allow( - dead_code, - reason = "The submenu API is exercised by its fixture until a caller adopts it." - ) - )] pub(crate) fn submenu( &self, key: K, @@ -1129,7 +1122,6 @@ impl MenuItem { } /// The current choice, shown beside a submenu's chevron. - #[cfg_attr(not(test), allow(dead_code, reason = "Part of the submenu API."))] pub(crate) fn value(mut self, value: impl Into) -> Self { if let Some(submenu) = &mut self.submenu { submenu.value = Some(value.into()); From 9659601795476c5527c75455281092df6330554e Mon Sep 17 00:00:00 2001 From: marks Date: Mon, 26 Oct 2026 16:08:56 -0500 Subject: [PATCH 05/29] Agent: support manual sidebar folder ordering --- apps/maple-agent/app/src/backend.rs | 4 + apps/maple-agent/app/src/backend/local.rs | 100 ++++- apps/maple-agent/app/src/remote/wire.rs | 6 + apps/maple-agent/app/src/settings.rs | 117 +++++- apps/maple-agent/app/src/ui/chat/sidebar.rs | 428 ++++++++++++++++++-- 5 files changed, 615 insertions(+), 40 deletions(-) diff --git a/apps/maple-agent/app/src/backend.rs b/apps/maple-agent/app/src/backend.rs index 185795da4..bf4c73062 100644 --- a/apps/maple-agent/app/src/backend.rs +++ b/apps/maple-agent/app/src/backend.rs @@ -245,6 +245,8 @@ operations! { add_word(word: (String)) -> bool; get_remote_sidebars(user_id: (&str)) -> HashMap; set_remote_sidebar(user_id: (&str), computer: (String), patch: (serde_json::Value)) -> (); + get_sidebar_folder_order(user_id: (&str), computer: (Option)) -> Vec; + set_sidebar_folder_order(user_id: (&str), computer: (Option), order: (Vec)) -> (); set_task_pinned(user_id: (&str), task: (&str), pinned: (bool)) -> (); login(email: (String), password: (String)) -> AuthSession; logout_and_clear(account_user: (&str)) -> (); @@ -521,6 +523,8 @@ impl AgentBackend { | "account" | "get_remote_sidebars" | "set_remote_sidebar" + | "get_sidebar_folder_order" + | "set_sidebar_folder_order" ) { &controller.client } else { diff --git a/apps/maple-agent/app/src/backend/local.rs b/apps/maple-agent/app/src/backend/local.rs index ca8e80031..e730b1a60 100644 --- a/apps/maple-agent/app/src/backend/local.rs +++ b/apps/maple-agent/app/src/backend/local.rs @@ -5,7 +5,7 @@ // there by design. #![cfg_attr(not(feature = "desktop"), allow(dead_code))] -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; #[cfg(target_os = "macos")] use std::process::Command; @@ -63,6 +63,40 @@ pub struct AuthSession { pub user_id: String, } +fn sidebar_folder_order_key(computer: &Option) -> Result { + if computer.as_ref().is_some_and(|computer| { + computer.is_empty() || computer.len() > 128 || computer.chars().any(char::is_control) + }) { + return Err("Invalid sidebar computer".into()); + } + serde_json::to_string(computer).map_err(|_| "Could not encode sidebar computer".into()) +} + +fn validate_sidebar_folder_order(order: &[String]) -> Result<(), String> { + const MAX_FOLDERS: usize = 4096; + const MAX_ROOT_BYTES: usize = 32768; + const MAX_ORDER_BYTES: usize = 1024 * 1024; + if order.len() > MAX_FOLDERS { + return Err("Sidebar folder order is too large".into()); + } + let mut bytes = 0_usize; + let mut seen = HashSet::new(); + for root in order { + if root.is_empty() + || root.len() > MAX_ROOT_BYTES + || root.contains('\0') + || !seen.insert(root.as_str()) + { + return Err("Invalid sidebar folder order".into()); + } + bytes += root.len(); + if bytes > MAX_ORDER_BYTES { + return Err("Sidebar folder order is too large".into()); + } + } + Ok(()) +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct SelectedProject { @@ -1312,6 +1346,70 @@ impl LocalBackend { .map_err(|_| "Could not save remote sidebar settings".to_string())? } + /// Folder order is account-owned controller presentation state, independent + /// of the execution host's project ordering and navigation cursors. + pub async fn get_sidebar_folder_order( + &self, + user_id: &str, + computer: Option, + ) -> Result, String> { + let key = sidebar_folder_order_key(&computer)?; + let (store, account) = self.presentation_account(user_id)?; + tokio::task::spawn_blocking(move || { + let mut all: HashMap> = serde_json::from_value( + store + .account_setting(account, "sidebar.folder.order") + .map_err(|error| error.to_string())? + .unwrap_or_else(|| serde_json::json!({})), + ) + .map_err(|_| "Saved sidebar folder order is invalid".to_string())?; + let order = all.remove(&key).unwrap_or_default(); + validate_sidebar_folder_order(&order)?; + Ok(order) + }) + .await + .map_err(|_| "Could not read sidebar folder order".to_string())? + } + + pub async fn set_sidebar_folder_order( + &self, + user_id: &str, + computer: Option, + order: Vec, + ) -> Result<(), String> { + let key = sidebar_folder_order_key(&computer)?; + validate_sidebar_folder_order(&order)?; + // Host dispatch already holds the account read gate. Reuse the + // presentation writer gate without taking a nested account read lock. + let _gate = self.ui_settings_gate.lock().await; + let (store, account) = self.presentation_account(user_id)?; + tokio::task::spawn_blocking(move || { + let mut all: HashMap> = serde_json::from_value( + store + .account_setting(account, "sidebar.folder.order") + .map_err(|error| error.to_string())? + .unwrap_or_else(|| serde_json::json!({})), + ) + .map_err(|_| "Saved sidebar folder order is invalid".to_string())?; + if order.is_empty() { + all.remove(&key); + } else { + all.insert(key, order); + } + store + .set_account_setting( + account, + "sidebar.folder.order", + &serde_json::to_value(all) + .map_err(|_| "Could not encode sidebar folder order".to_string())?, + crate::settings::timestamp_ms(), + ) + .map_err(|error| error.to_string()) + }) + .await + .map_err(|_| "Could not save sidebar folder order".to_string())? + } + pub async fn set_task_pinned( &self, user_id: &str, diff --git a/apps/maple-agent/app/src/remote/wire.rs b/apps/maple-agent/app/src/remote/wire.rs index 710bce588..f502dd3af 100644 --- a/apps/maple-agent/app/src/remote/wire.rs +++ b/apps/maple-agent/app/src/remote/wire.rs @@ -238,6 +238,12 @@ impl Method { ) } pub fn parse(value: &str) -> Result { + if matches!( + value, + "get_sidebar_folder_order" | "set_sidebar_folder_order" + ) { + return Err("This operation is available only on the local computer.".into()); + } serde_json::from_value(serde_json::Value::String(value.into())) .map_err(|_| "This operation is available only on the local computer.".into()) } diff --git a/apps/maple-agent/app/src/settings.rs b/apps/maple-agent/app/src/settings.rs index 3088119b4..2955bfd87 100644 --- a/apps/maple-agent/app/src/settings.rs +++ b/apps/maple-agent/app/src/settings.rs @@ -19,6 +19,9 @@ pub struct RemoteSidebarSettings { pub struct SidebarView { pub group: SidebarGroup, pub sort: SidebarSort, + /// Use saved folder order; task sorting stays unchanged. + #[serde(default)] + pub manual_folders: bool, pub ascending: bool, /// The project line under each title. `None` shows it except when /// grouped by folder, whose header already names the project. @@ -307,11 +310,19 @@ enum SettingsSink { Test(std::sync::Arc), } +#[derive(Clone)] +struct PendingFolderOrder { + backend: std::sync::Arc, + order: Vec, +} + struct SettingsCache { generation: u64, value: AppSettings, pending: serde_json::Map, words: std::collections::BTreeSet, + /// Account-owned writes stay in memory and never enter global UI settings. + pending_folder_orders: std::collections::BTreeMap<(String, Option), PendingFolderOrder>, sink: Option, } @@ -322,6 +333,7 @@ static CACHE: std::sync::LazyLock> = value: AppSettings::default(), pending: Default::default(), words: Default::default(), + pending_folder_orders: Default::default(), sink: None, }) }); @@ -343,6 +355,9 @@ pub fn attach_backend(backend: std::sync::Arc, val pub fn clear_decrypted_cache() { let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); cache.value = AppSettings::default(); + // Account-owned paths cannot survive losing the verified account/store + // context. Normal navigation and Quit flush them before this boundary. + cache.pending_folder_orders.clear(); // Retain unsynchronized edits privately for this generation, but stop // retries until an Open snapshot supplies a fresh host attachment. cache.sink = None; @@ -356,6 +371,7 @@ pub fn discard_decrypted_cache() { cache.value = AppSettings::default(); cache.pending.clear(); cache.words.clear(); + cache.pending_folder_orders.clear(); cache.sink = None; cache.generation = cache.generation.wrapping_add(1); } @@ -445,6 +461,24 @@ pub fn update_settings_in_background(update: impl FnOnce(&mut AppSettings) + Sen queue_flush(None); } +/// Queue an account-owned folder preference through the same Quit barrier as +/// ordinary settings. The captured account/backend are never rebound on retry. +pub(crate) fn save_sidebar_folder_order( + backend: std::sync::Arc, + user: String, + computer: Option, + order: Vec, +) -> tokio::sync::oneshot::Receiver> { + CACHE + .lock() + .unwrap_or_else(|e| e.into_inner()) + .pending_folder_orders + .insert((user, computer), PendingFolderOrder { backend, order }); + let (sender, receiver) = tokio::sync::oneshot::channel(); + queue_flush(Some(SettingsAck::Async(sender))); + receiver +} + /// A synchronous barrier for existing GUI state tests. Runtime callers use /// the asynchronous flush_pending barrier before ordinary Quit. #[cfg(test)] @@ -533,9 +567,12 @@ fn queue_flush(ack: Option) { } fn synchronize_once() -> Result<(), String> { - let (generation, sink, patch, words) = { + let (generation, sink, patch, words, folder_orders) = { let cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); - if cache.pending.is_empty() && cache.words.is_empty() { + if cache.pending.is_empty() + && cache.words.is_empty() + && cache.pending_folder_orders.is_empty() + { return Ok(()); } ( @@ -543,6 +580,7 @@ fn synchronize_once() -> Result<(), String> { cache.sink.clone(), cache.pending.clone(), cache.words.clone(), + cache.pending_folder_orders.clone(), ) }; match sink.ok_or("Maple host is unavailable; settings edits remain pending")? { @@ -574,19 +612,69 @@ fn synchronize_once() -> Result<(), String> { } } } - let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); - if cache.generation != generation { - return Ok(()); - } - for (key, value) in patch { - if cache.pending.get(&key) == Some(&value) { - cache.pending.remove(&key); + { + let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + if cache.generation != generation { + return if folder_orders.is_empty() { + Ok(()) + } else { + Err("Maple settings state changed; folder order edits remain pending".into()) + }; + } + for (key, value) in patch { + if cache.pending.get(&key) == Some(&value) { + cache.pending.remove(&key); + } + } + for word in words { + cache.words.remove(&word); } } - for word in words { - cache.words.remove(&word); + let mut first_error = None; + for ((user, computer), pending) in folder_orders { + { + let cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + if cache.generation != generation || cache.sink.is_none() { + first_error.get_or_insert_with(|| { + "Maple settings state changed; folder order edits remain pending".to_string() + }); + break; + } + } + let result = pending.backend.runtime_handle().block_on(async { + tokio::time::timeout( + std::time::Duration::from_secs(5), + pending.backend.set_sidebar_folder_order( + &user, + computer.clone(), + pending.order.clone(), + ), + ) + .await + .map_err(|_| "Maple folder order synchronization timed out".to_string())? + }); + match result { + Ok(()) => { + let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + let key = (user, computer); + if cache.generation == generation + && cache + .pending_folder_orders + .get(&key) + .is_some_and(|current| { + current.order == pending.order + && std::sync::Arc::ptr_eq(¤t.backend, &pending.backend) + }) + { + cache.pending_folder_orders.remove(&key); + } + } + Err(error) => { + first_error.get_or_insert(error); + } + } } - Ok(()) + first_error.map_or(Ok(()), Err) } fn merge_patch(target: &mut serde_json::Value, patch: &serde_json::Value) { @@ -819,6 +907,7 @@ pub(crate) fn install_test_settings(value: AppSettings) { value, pending: Default::default(), words: Default::default(), + pending_folder_orders: Default::default(), sink: None, }; } @@ -833,6 +922,7 @@ pub(crate) fn install_test_store(store: std::sync::Arc) -> impl IntoElement { + div() + .flex() + .items_center() + .gap_2() + .px_3() + .py_1() + .rounded(ROW_RADIUS) + .bg(gpui::rgb(theme::bg_sidebar_row_hover())) + .text_sm() + .text_color(gpui::rgb(theme::text_sidebar())) + .child(icon("folder", FOLDER_ICON_SIZE, theme::text_secondary())) + .child(self.name.clone()) + } } pub(super) struct Sidebar { @@ -481,6 +506,10 @@ pub(super) struct Sidebar { projects_expanded: bool, archived_expanded: bool, view: SidebarView, + folder_order: Vec, + folder_order_loaded: bool, + folder_order_loading: bool, + folder_order_reset_pending: bool, switcher_roots: Vec, computer_switcher_roots: Vec>, scope_label: SharedString, @@ -550,6 +579,7 @@ impl SwitcherRoot { new_task_id: target.element_id("new-task-in"), new_task_label: format!("New task in {name}").into(), empty_id: target.element_id("folder-empty"), + folder_position: usize::MAX, target, name, } @@ -741,7 +771,7 @@ impl Sidebar { .ok(); }); }); - Self { + let mut sidebar = Self { pages: SidebarPages::default(), group_pages: ComputerPages::default(), computers: Arc::new(Vec::new()), @@ -774,6 +804,10 @@ impl Sidebar { projects_expanded: true, archived_expanded: false, view: settings.sidebar_view, + folder_order: Vec::new(), + folder_order_loaded: false, + folder_order_loading: false, + folder_order_reset_pending: false, switcher_roots: Vec::new(), computer_switcher_roots: Vec::new(), scope_label: "All projects".into(), @@ -804,7 +838,9 @@ impl Sidebar { vim_by_row: Vec::new(), vim_order: Vec::new(), bridged_tasks: RefCell::new(Vec::new()), - } + }; + sidebar.load_folder_order(cx); + sidebar } // ---- Inputs from the screen ------------------------------------------ @@ -1277,6 +1313,10 @@ impl Sidebar { ..SidebarView::default() }; self.pinned_tasks.clear(); + self.folder_order.clear(); + self.folder_order_loaded = true; + self.folder_order_loading = false; + self.folder_order_reset_pending = false; self.project_metadata.clear(); self.task_project_labels.clear(); self.removed_projects.clear(); @@ -1479,6 +1519,19 @@ impl Sidebar { .filter(|root| !self.removed_projects.contains(*root)) .map(|root| SwitcherRoot::new(self.project_target(root), self.root_name(root).into())) .collect(); + if self.manual_folder_mode() && self.folder_order_loaded { + let known: HashSet<&str> = self.folder_order.iter().map(String::as_str).collect(); + let added: Vec = self + .switcher_roots + .iter() + .filter(|root| !known.contains(root.root.as_str())) + .map(|root| root.root.clone()) + .collect(); + if !added.is_empty() { + self.folder_order.extend(added); + self.persist_folder_order(); + } + } let order = |a: &usize, b: &usize| { let order = match if self.computers.len() > 1 { SidebarSort::Updated @@ -1549,8 +1602,26 @@ impl Sidebar { || root.name.to_lowercase().contains(filter)) }) .collect(); - // The folder with the latest activity leads. - folders.sort_by_key(|(_, (latest, _))| std::cmp::Reverse(*latest)); + if self.manual_folder_mode() && self.folder_order_loaded { + let ranks: HashMap<&str, usize> = self + .folder_order + .iter() + .enumerate() + .map(|(position, root)| (root.as_str(), position)) + .collect(); + folders.sort_by_key(|(index, _)| { + ranks + .get(self.switcher_roots[*index].root.as_str()) + .copied() + .unwrap_or(usize::MAX) + }); + } else { + // The folder with the latest activity leads. + folders.sort_by_key(|(_, (latest, _))| std::cmp::Reverse(*latest)); + } + for (position, (index, _)) in folders.iter().enumerate() { + self.switcher_roots[*index].folder_position = position; + } sections.extend( folders .into_iter() @@ -2016,19 +2087,219 @@ impl Sidebar { .unwrap_or(self.view.group != SidebarGroup::Folder) } + fn manual_folder_mode(&self) -> bool { + self.computers.len() <= 1 + && self.view.group == SidebarGroup::Folder + && self.view.manual_folders + } + + fn can_reorder_folders(&self) -> bool { + self.manual_folder_mode() + && self.folder_order_loaded + && self.filter.is_empty() + && self.project_filter.is_none() + && self.rename.is_none() + } + + fn load_folder_order(&mut self, cx: &mut Context) { + if self.folder_order_loaded || self.folder_order_loading { + return; + } + self.folder_order_loading = true; + let backend = self.backend.clone(); + let user = self.user_id.clone(); + let computer = backend.remote_host().map(str::to_owned); + self.call( + async move { backend.get_sidebar_folder_order(&user, computer).await }, + cx, + |this, result, cx| { + if this.folder_order_loaded { + return; + } + this.folder_order_loading = false; + match result { + Ok(order) => { + let reset = std::mem::take(&mut this.folder_order_reset_pending); + this.folder_order = if reset { Vec::new() } else { order }; + this.folder_order_loaded = true; + let seed = this.manual_folder_mode() && this.folder_order.is_empty(); + if seed { + this.seed_folder_order(); + } + this.rebuild_sections(); + if seed || reset { + this.save_folder_order(cx); + } + cx.notify(); + } + Err(message) => cx.emit(SidebarEvent::Notice(message.into())), + } + }, + ); + } + + fn seed_folder_order(&mut self) { + self.folder_order = self + .sections + .iter() + .filter_map(|(section, _)| match section { + SidebarSection::Folder(index) => Some(self.switcher_roots[*index].root.clone()), + _ => None, + }) + .collect(); + } + + fn persist_folder_order(&self) { + if self.folder_order_loaded { + // The shared settings writer owns retries and the normal Quit + // barrier, so a screen change cannot discard this presentation edit. + drop(crate::settings::save_sidebar_folder_order( + self.backend.clone(), + self.user_id.clone(), + self.backend.remote_host().map(str::to_owned), + self.folder_order.clone(), + )); + } + } + + fn save_folder_order(&self, cx: &mut Context) { + let saved = crate::settings::save_sidebar_folder_order( + self.backend.clone(), + self.user_id.clone(), + self.backend.remote_host().map(str::to_owned), + self.folder_order.clone(), + ); + self.call( + async move { + saved + .await + .map_err(|_| "Could not save folder order".to_string())? + }, + cx, + |_, result, cx| { + if let Err(message) = result { + cx.emit(SidebarEvent::Notice(message.into())); + } + }, + ); + } + + /// Move only the two current folders' presentation slots. Resolve roots at + /// drop time so paging or a metadata refresh cannot retarget a stale index. + fn reorder_folder( + &mut self, + source: &ProjectTarget, + target: &ProjectTarget, + cx: &mut Context, + ) { + if !self.can_reorder_folders() + || source == target + || source.computer.as_deref() != self.backend.remote_host() + || target.computer.as_deref() != self.backend.remote_host() + || ![source, target].iter().all(|target| { + self.switcher_roots + .iter() + .any(|root| &root.target == *target) + }) + { + return; + } + let Some(from) = self + .folder_order + .iter() + .position(|root| root == &source.path) + else { + return; + }; + let Some(to) = self + .folder_order + .iter() + .position(|root| root == &target.path) + else { + return; + }; + let moved = self.folder_order.remove(from); + self.folder_order.insert(to, moved); + self.close_popups(cx); + self.save_folder_order(cx); + self.rebuild_sections(); + cx.notify(); + } + + fn move_folder(&mut self, target: &ProjectTarget, forward: bool, cx: &mut Context) { + if !self.can_reorder_folders() { + return; + } + let roots: Vec<_> = self + .sections + .iter() + .filter_map(|(section, _)| match section { + SidebarSection::Folder(index) => Some(self.switcher_roots[*index].target.clone()), + _ => None, + }) + .collect(); + let Some(position) = roots.iter().position(|root| root == target) else { + return; + }; + let adjacent = if forward { + roots.get(position + 1) + } else { + position + .checked_sub(1) + .and_then(|position| roots.get(position)) + }; + if let Some(adjacent) = adjacent { + self.reorder_folder(target, adjacent, cx); + } + } + + fn reset_view(&mut self, cx: &mut Context) { + self.folder_order.clear(); + self.folder_order_reset_pending = !self.folder_order_loaded; + self.view = SidebarView::default(); + self.rebuild_sections(); + self.list.remeasure_items(0..self.entries.len()); + self.save_folder_order(cx); + self.load_folder_order(cx); + crate::settings::update_settings_in_background(|settings| { + settings.sidebar_view = SidebarView::default(); + }); + cx.notify(); + } + /// Change the view, keep it in the app settings, and rebuild what it /// shapes. The project line changes row heights, so every row is /// measured again. pub(super) fn set_view(&mut self, view: SidebarView, cx: &mut Context) { if self.view == view { + if self.manual_folder_mode() { + self.load_folder_order(cx); + if self.folder_order_loaded { + self.save_folder_order(cx); + } + } return; } + if view.group == SidebarGroup::Folder + && view.manual_folders + && self.folder_order_loaded + && self.folder_order.is_empty() + { + // Enter manual ordering without moving the folders already on screen. + self.seed_folder_order(); + } self.view = view; self.rebuild_sections(); self.list.remeasure_items(0..self.entries.len()); cx.notify(); + if self.manual_folder_mode() { + self.load_folder_order(cx); + if self.folder_order_loaded { + self.save_folder_order(cx); + } + } crate::settings::update_settings_in_background(move |settings| { - settings.sidebar_view = view + settings.sidebar_view = view; }); } @@ -2502,6 +2773,39 @@ impl Sidebar { this.begin_rename(RenameTarget::Project(rename_target.clone()), cx) }), }]; + if self.can_reorder_folders() + && let Some(root) = self + .switcher_roots + .iter() + .find(|root| &root.target == target) + { + for (forward, available, id, icon, label) in [ + ( + false, + root.folder_position > 0 && root.folder_position != usize::MAX, + "move-project-up", + "arrow-up", + "Move up", + ), + ( + true, + root.folder_position.saturating_add(1) < self.sections.len(), + "move-project-down", + "chevron-down", + "Move down", + ), + ] { + if available { + let target = target.clone(); + items.push(SidebarMenuItem { + id: target.element_id(id), + icon, + label, + on_click: Box::new(move |this, cx| this.move_folder(&target, forward, cx)), + }); + } + } + } // Other actions are still scoped to the active ChatScreen. Never offer // them against a foreign folder or reveal its path on this computer. if target.computer.as_deref() != self.backend.remote_host() { @@ -3230,6 +3534,7 @@ impl Sidebar { use ViewSubmenu::{Group, Show, Sort}; let view = self.view; + let manual = view.group == SidebarGroup::Folder && view.manual_folders; let choice = |id: &'static str, label: &'static str, current: bool, next: SidebarView| { MenuItem::new(id, label, move |this: &mut Self, _: &mut Window, cx| { this.set_view(next, cx) @@ -3262,22 +3567,39 @@ impl Sidebar { sort_menu = sort_menu.item(choice( id, label, - view.sort == sort, - SidebarView { sort, ..view }, + !manual && view.sort == sort, + SidebarView { + sort, + manual_folders: false, + ..view + }, )); } - sort_menu = sort_menu.separator(); - for (id, label, ascending) in [ - ("view-sort-descending", "Descending", false), - ("view-sort-ascending", "Ascending", true), - ] { + if view.group == SidebarGroup::Folder { sort_menu = sort_menu.item(choice( - id, - label, - view.ascending == ascending, - SidebarView { ascending, ..view }, + "view-sort-manual", + "Manually", + manual, + SidebarView { + manual_folders: true, + ..view + }, )); } + if !manual { + sort_menu = sort_menu.separator(); + for (id, label, ascending) in [ + ("view-sort-descending", "Descending", false), + ("view-sort-ascending", "Ascending", true), + ] { + sort_menu = sort_menu.item(choice( + id, + label, + view.ascending == ascending, + SidebarView { ascending, ..view }, + )); + } + } let metadata = self.show_metadata(); let show_menu = Menu::new("view-show-menu", px(220.)) .label("Show") @@ -3306,10 +3628,14 @@ impl Sidebar { .item( self.view_popup .submenu(Sort, "view-sort", "Sort by", sort_menu, window, cx) - .value(match view.sort { - SidebarSort::Updated => "Last updated", - SidebarSort::Created => "Date created", - SidebarSort::Name => "Name", + .value(if manual { + "Manually" + } else { + match view.sort { + SidebarSort::Updated => "Last updated", + SidebarSort::Created => "Date created", + SidebarSort::Name => "Name", + } }), ) .item( @@ -3318,13 +3644,16 @@ impl Sidebar { ) .separator() .item( - choice( + MenuItem::new( "view-reset", "Reset to defaults", - false, - SidebarView::default(), + |this: &mut Self, _, cx| this.reset_view(cx), ) - .enabled(view != SidebarView::default()), + .enabled( + view != SidebarView::default() + || !self.folder_order.is_empty() + || self.folder_order_reset_pending, + ), ) } @@ -3362,6 +3691,7 @@ impl Sidebar { // While a sidebar menu is open, only its owning header shows actions. let menu_open = self.project_popup.is_open(&root.target); let hover = !renaming && !self.sidebar_menu_open(); + let reorderable = hover && self.can_reorder_folders(); let new_task = root.target.clone(); let menu_target = root.target.clone(); let selector = root.folder_id.clone(); @@ -3389,10 +3719,54 @@ impl Sidebar { }) .when(hover, |row| { row.hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() + let style = style.bg(gpui::rgb(theme::bg_sidebar_row_hover())); + if reorderable { + style.cursor_move() + } else { + style.cursor_pointer() + } + }) + }) + .when(reorderable, |row| { + let sidebar_id = cx.entity_id(); + let owner = cx.weak_entity(); + let target = root.target.clone(); + let highlight_target = target.clone(); + let drop_target = target.clone(); + let root_target = target.clone(); + row.on_drag( + DraggedFolder { + sidebar: sidebar_id, + target, + name: root.name.clone(), + }, + move |drag, _, _, cx| { + owner + .update(cx, |this, cx| { + this.close_popups(cx); + this.set_hovered_entry(None, cx); + }) + .ok(); + cx.new(|_| drag.clone()) + }, + ) + .can_drop(move |value, _, _| { + value.downcast_ref::().is_some_and(|drag| { + drag.sidebar == sidebar_id && drag.target != drop_target + }) }) + .drag_over::(move |style, drag, _, _| { + if drag.sidebar == sidebar_id && drag.target != highlight_target { + style.bg(gpui::rgb(theme::bg_sidebar_row_selected_hover())) + } else { + style + } + }) + .on_drop(cx.listener(move |this, drag: &DraggedFolder, _, cx| { + if drag.sidebar == cx.entity_id() { + this.reorder_folder(&drag.target, &root_target, cx); + } + })) }) .on_click(cx.listener(move |this, _event, _window, cx| { this.toggle_section_expanded(section, cx); From e973c4e36e919794921acad92e148790b4c99a99 Mon Sep 17 00:00:00 2001 From: marks Date: Mon, 26 Oct 2026 19:40:16 -0500 Subject: [PATCH 06/29] Agent: honor folder sort choices and mark drag insertion --- apps/maple-agent/app/src/ui/chat/commands.rs | 9 + apps/maple-agent/app/src/ui/chat/sidebar.rs | 294 ++++++++++++++++--- 2 files changed, 266 insertions(+), 37 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/commands.rs b/apps/maple-agent/app/src/ui/chat/commands.rs index 7ed177ae7..a904d4906 100644 --- a/apps/maple-agent/app/src/ui/chat/commands.rs +++ b/apps/maple-agent/app/src/ui/chat/commands.rs @@ -44,6 +44,15 @@ impl ChatScreen { window: &mut Window, cx: &mut Context, ) { + if matches!( + command, + ChatCommand::Escape | ChatCommand::ApplicationVim(ApplicationVimCommand::Escape) + ) && self + .sidebar + .update(cx, |sidebar, cx| sidebar.cancel_folder_drag(window, cx)) + { + return; + } if let Some(dialog) = self.project_folder_dialog.clone() { if matches!( command, diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index ea692a8a0..1adc5e289 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -437,6 +437,16 @@ pub(super) struct SwitcherRoot { new_task_label: SharedString, empty_id: SharedString, folder_position: usize, + folder_start: usize, + folder_end: usize, +} + +#[derive(Clone, PartialEq, Eq)] +struct FolderDrop { + target: ProjectTarget, + after: bool, + hover_row: usize, + line_row: usize, } #[derive(Clone)] @@ -488,6 +498,9 @@ pub(super) struct Sidebar { // The list and its sections. list: gpui::ListState, entries: Vec, + folder_rows: Vec>, + folder_dragging: bool, + folder_drop: Option, heading_gaps: Vec, entry_sections: Vec>, hovered_section: Option, @@ -580,6 +593,8 @@ impl SwitcherRoot { new_task_label: format!("New task in {name}").into(), empty_id: target.element_id("folder-empty"), folder_position: usize::MAX, + folder_start: usize::MAX, + folder_end: usize::MAX, target, name, } @@ -766,6 +781,7 @@ impl Sidebar { || top.offset_in_item != this.hover_scroll_top.offset_in_item { this.set_hovered_entry(None, cx); + this.set_folder_drop(None, cx); } }) .ok(); @@ -790,6 +806,9 @@ impl Sidebar { vim_active: false, list, entries: Vec::new(), + folder_rows: Vec::new(), + folder_dragging: false, + folder_drop: None, heading_gaps: Vec::new(), entry_sections: Vec::new(), hovered_section: None, @@ -1578,14 +1597,18 @@ impl Sidebar { .enumerate() .map(|(index, root)| (root.root.as_str(), index)) .collect(); - // Each root's newest activity, pinned tasks included, and - // its unpinned rows. - let mut folders = vec![(None, Vec::new()); self.switcher_roots.len()]; + // Project metadata has no creation timestamp. Use each folder's + // earliest loaded task creation and newest activity, pinned + // tasks included, alongside its unpinned rows. + let mut folders = vec![(None, None, Vec::new()); self.switcher_roots.len()]; for (position, &ix) in pinned.iter().chain(&listed).enumerate() { let session = &self.sessions[ix]; if let Some(&folder) = folder_of.get(session.project_root.as_str()) { - let (latest, rows) = &mut folders[folder]; + let (latest, earliest, rows) = &mut folders[folder]; *latest = (*latest).max(Some(session.updated_ms)); + *earliest = Some(earliest.map_or(session.created_ms, |created: i64| { + created.min(session.created_ms) + })); if position >= pinned.len() { rows.push(ix); } @@ -1594,7 +1617,7 @@ impl Sidebar { let mut folders: Vec<_> = folders .into_iter() .enumerate() - .filter(|(index, (_, rows))| { + .filter(|(index, (_, _, rows))| { let root = &self.switcher_roots[*index]; scoped.is_none_or(|scoped| scoped == root.root) && (!rows.is_empty() @@ -1616,8 +1639,43 @@ impl Sidebar { .unwrap_or(usize::MAX) }); } else { - // The folder with the latest activity leads. - folders.sort_by_key(|(_, (latest, _))| std::cmp::Reverse(*latest)); + let names: Vec<_> = self + .switcher_roots + .iter() + .map(|root| root.name.to_lowercase()) + .collect(); + let direction = |order: std::cmp::Ordering| { + if self.view.ascending { + order + } else { + order.reverse() + } + }; + let dated = |left: Option, right: Option| match (left, right) { + (Some(left), Some(right)) => direction(left.cmp(&right)), + // An empty folder has no task date. Keep it after dated + // folders in either direction, with stable name/root ties. + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + }; + folders.sort_by( + |(left, (updated_left, created_left, _)), + (right, (updated_right, created_right, _))| { + let order = match self.view.sort { + SidebarSort::Name => direction(names[*left].cmp(&names[*right])), + SidebarSort::Created => dated(*created_left, *created_right), + SidebarSort::Updated => dated(*updated_left, *updated_right), + }; + order + .then_with(|| names[*left].cmp(&names[*right])) + .then_with(|| { + self.switcher_roots[*left] + .root + .cmp(&self.switcher_roots[*right].root) + }) + }, + ); } for (position, (index, _)) in folders.iter().enumerate() { self.switcher_roots[*index].folder_position = position; @@ -1625,7 +1683,7 @@ impl Sidebar { sections.extend( folders .into_iter() - .map(|(index, (_, rows))| (SidebarSection::Folder(index), rows)), + .map(|(index, (_, _, rows))| (SidebarSection::Folder(index), rows)), ); } } @@ -1649,6 +1707,7 @@ impl Sidebar { fn rebuild_entries(&mut self) { self.hovered_entry = None; self.hovered_section = None; + self.folder_drop = None; let mut entries = vec![SidebarEntry::NewTask, SidebarEntry::Search]; let empty_known = self.filter.is_empty() && self.pages.non_archived_complete(); let pinned_roots: HashSet<&str> = self @@ -1783,6 +1842,28 @@ impl Sidebar { if self.computers.len() > 1 && self.project_filter.is_none() { entries = self.computer_entries(); } + // Cache whole-folder boundaries, including expanded/empty task rows. + // Drag feedback must sit between projects without changing row heights. + let mut folder = None; + self.folder_rows = entries + .iter() + .enumerate() + .map(|(row, entry)| { + match *entry { + SidebarEntry::SectionLabel(SidebarSection::Folder(index)) => { + folder = Some(index); + self.switcher_roots[index].folder_start = row; + } + SidebarEntry::FolderEmpty(index) => folder = Some(index), + SidebarEntry::Task(task) if !task.archived => {} + _ => folder = None, + } + if let Some(index) = folder { + self.switcher_roots[index].folder_end = row; + } + folder + }) + .collect(); for ix in 0..entries.len() { let last = !matches!(entries.get(ix + 1), Some(SidebarEntry::Task(task)) if !task.archived); @@ -2101,6 +2182,85 @@ impl Sidebar { && self.rename.is_none() } + fn set_folder_drop(&mut self, next: Option, cx: &mut Context) { + if self.folder_drop != next { + self.folder_drop = next; + cx.notify(); + } + } + + fn finish_folder_drag(&mut self, cx: &mut Context) { + if self.folder_dragging || self.folder_drop.is_some() { + self.folder_dragging = false; + self.folder_drop = None; + cx.notify(); + } + } + + pub(super) fn cancel_folder_drag( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if !self.folder_dragging { + return false; + } + cx.stop_active_drag(window); + self.finish_folder_drag(cx); + true + } + + fn update_folder_drop( + &mut self, + row: usize, + position: gpui::Point, + bounds: gpui::Bounds, + drag: &DraggedFolder, + cx: &mut Context, + ) { + let next = if drag.sidebar == cx.entity_id() + && self.can_reorder_folders() + && bounds.contains(&position) + { + self.folder_rows + .get(row) + .copied() + .flatten() + .and_then(|index| { + let root = &self.switcher_roots[index]; + let source = self + .switcher_roots + .iter() + .find(|root| root.target == drag.target)?; + let header = matches!( + self.entries.get(row), + Some(SidebarEntry::SectionLabel(SidebarSection::Folder(_))) + ); + let after = !header || position.y >= bounds.center().y; + let insertion = root.folder_position + usize::from(after); + if root.target == drag.target + || insertion == source.folder_position + || insertion == source.folder_position + 1 + { + return None; + } + Some(FolderDrop { + target: root.target.clone(), + after, + hover_row: row, + line_row: if after { + root.folder_end + } else { + root.folder_start + }, + }) + }) + } else { + None + }; + self.set_folder_drop(next, cx); + } + fn load_folder_order(&mut self, cx: &mut Context) { if self.folder_order_loaded || self.folder_order_loading { return; @@ -2190,6 +2350,7 @@ impl Sidebar { &mut self, source: &ProjectTarget, target: &ProjectTarget, + after: bool, cx: &mut Context, ) { if !self.can_reorder_folders() @@ -2218,8 +2379,13 @@ impl Sidebar { else { return; }; + let insertion = to + usize::from(after); + let insertion = insertion - usize::from(from < insertion); + if insertion == from { + return; + } let moved = self.folder_order.remove(from); - self.folder_order.insert(to, moved); + self.folder_order.insert(insertion, moved); self.close_popups(cx); self.save_folder_order(cx); self.rebuild_sections(); @@ -2249,7 +2415,7 @@ impl Sidebar { .and_then(|position| roots.get(position)) }; if let Some(adjacent) = adjacent { - self.reorder_folder(target, adjacent, cx); + self.reorder_folder(target, adjacent, forward, cx); } } @@ -3189,7 +3355,7 @@ impl Sidebar { Some(SidebarEntry::ProjectsHeader) => self.render_projects_header(window, cx), Some(SidebarEntry::FolderEmpty(index)) => { let id = self.switcher_roots[index].empty_id.clone(); - return div() + div() .id(id.clone()) .w_full() .pl( @@ -3201,7 +3367,7 @@ impl Sidebar { .text_sm() .text_color(gpui::rgb(theme::text_muted())) .child(div().child("No tasks")) - .into_any_element(); + .into_any_element() } Some(SidebarEntry::Task(task)) => { let application_selected = self.vim_selects_row(ix); @@ -3358,10 +3524,60 @@ impl Sidebar { .application_vim_enabled .then(|| self.vim_target(ix).cloned()) .flatten(); + let folder_drop_enabled = + self.can_reorder_folders() && self.folder_rows.get(ix).copied().flatten().is_some(); + let insertion = self + .folder_drop + .as_ref() + .filter(|drop| self.folder_dragging && cx.has_active_drag() && drop.line_row == ix) + .map(|drop| drop.after); + let sidebar_id = cx.entity_id(); let chat = self.chat.clone(); div() .id(("sidebar-entry", ix as u64)) + .relative() .w_full() + .when(folder_drop_enabled, |row| { + row.on_drag_move(cx.listener( + move |this, event: &gpui::DragMoveEvent, _, cx| { + if event.bounds.contains(&event.event.position) { + let drag = event.drag(cx).clone(); + this.update_folder_drop( + ix, + event.event.position, + event.bounds, + &drag, + cx, + ); + } else if this + .folder_drop + .as_ref() + .is_some_and(|drop| drop.hover_row == ix) + { + this.set_folder_drop(None, cx); + } + }, + )) + .can_drop(move |value, _, _| { + value + .downcast_ref::() + .is_some_and(|drag| drag.sidebar == sidebar_id) + }) + .on_drop(cx.listener( + move |this, drag: &DraggedFolder, window, cx| { + if drag.sidebar != cx.entity_id() { + return; + } + if let Some(bounds) = this.list.bounds_for_item(ix) { + this.update_folder_drop(ix, window.mouse_position(), bounds, drag, cx); + if let Some(drop) = this.folder_drop.take() { + this.reorder_folder(&drag.target, &drop.target, drop.after, cx); + } + } + this.finish_folder_drag(cx); + }, + )) + }) .on_hover(cx.listener(move |this, hovered: &bool, window, cx| { if *hovered { this.set_hovered_entry(Some(ix), cx); @@ -3390,6 +3606,18 @@ impl Sidebar { .border_color(gpui::rgb(theme::accent())) }) .child(entry) + .when_some(insertion, |row, after| { + row.child( + div() + .absolute() + .left_2() + .right_2() + .h(px(2.)) + .when(after, |line| line.bottom_0()) + .when(!after, |line| line.top_0()) + .bg(gpui::rgb(theme::accent())), + ) + }) .into_any_element() } @@ -3719,7 +3947,11 @@ impl Sidebar { }) .when(hover, |row| { row.hover(|style| { - let style = style.bg(gpui::rgb(theme::bg_sidebar_row_hover())); + let style = if self.folder_dragging { + style + } else { + style.bg(gpui::rgb(theme::bg_sidebar_row_hover())) + }; if reorderable { style.cursor_move() } else { @@ -3731,9 +3963,6 @@ impl Sidebar { let sidebar_id = cx.entity_id(); let owner = cx.weak_entity(); let target = root.target.clone(); - let highlight_target = target.clone(); - let drop_target = target.clone(); - let root_target = target.clone(); row.on_drag( DraggedFolder { sidebar: sidebar_id, @@ -3741,32 +3970,23 @@ impl Sidebar { name: root.name.clone(), }, move |drag, _, _, cx| { + let preview = cx.new(|_| drag.clone()); owner .update(cx, |this, cx| { + this.folder_dragging = true; + this.folder_drop = None; this.close_popups(cx); this.set_hovered_entry(None, cx); + cx.observe_release(&preview, |this, _, cx| { + this.finish_folder_drag(cx); + }) + .detach(); + cx.notify(); }) .ok(); - cx.new(|_| drag.clone()) + preview }, ) - .can_drop(move |value, _, _| { - value.downcast_ref::().is_some_and(|drag| { - drag.sidebar == sidebar_id && drag.target != drop_target - }) - }) - .drag_over::(move |style, drag, _, _| { - if drag.sidebar == sidebar_id && drag.target != highlight_target { - style.bg(gpui::rgb(theme::bg_sidebar_row_selected_hover())) - } else { - style - } - }) - .on_drop(cx.listener(move |this, drag: &DraggedFolder, _, cx| { - if drag.sidebar == cx.entity_id() { - this.reorder_folder(&drag.target, &root_target, cx); - } - })) }) .on_click(cx.listener(move |this, _event, _window, cx| { this.toggle_section_expanded(section, cx); @@ -3796,7 +4016,7 @@ impl Sidebar { .child(root.name.clone()), ), }) - .when(hover || menu_open, |row| { + .when((hover || menu_open) && !self.folder_dragging, |row| { row.child( hover_actions(&root.row_group, theme::bg_sidebar_row_hover()) .when(menu_open, |strip| { @@ -4113,7 +4333,7 @@ impl Sidebar { .text_color(gpui::rgb(theme::text_sidebar())) // 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(!renaming && !self.folder_dragging, |el| { el.hover(|style| style.bg(gpui::rgb(hover_bg)).cursor_pointer()) }) .on_click(cx.listener(move |_this, _event, _window, cx| { @@ -4174,7 +4394,7 @@ impl Sidebar { .child(icon("pin", px(13.), theme::accent())), ) }) - .when(!renaming, |el| { + .when(!renaming && !self.folder_dragging, |el| { el.child( hover_actions(&row.group, hover_bg) .when(is_pinned, |overlay| overlay.right(px(26.))) From 00136a40d3f75e09c74289fe1ecaac546c33110c Mon Sep 17 00:00:00 2001 From: marks Date: Mon, 26 Oct 2026 23:21:50 -0500 Subject: [PATCH 07/29] Agent: date-sort empty projects using saved metadata --- apps/maple-agent/app/src/ui/chat/sidebar.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index 1adc5e289..f42c09e07 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -1614,6 +1614,18 @@ impl Sidebar { } } } + // Empty projects can use their saved registration/restoration + // time. For Created this is still an approximation, not a true + // project or filesystem creation timestamp. + for (index, (latest, earliest, _)) in folders.iter_mut().enumerate() { + let fallback = self + .project_metadata + .get(&self.switcher_roots[index].root) + .and_then(|project| i64::try_from(project.last_used_ms).ok()) + .filter(|timestamp| *timestamp > 0); + *latest = (*latest).or(fallback); + *earliest = (*earliest).or(fallback); + } let mut folders: Vec<_> = folders .into_iter() .enumerate() From 65a79efd908ed6fe4bae0a69fdcc312695779cd5 Mon Sep 17 00:00:00 2001 From: marks Date: Tue, 27 Oct 2026 00:16:52 -0500 Subject: [PATCH 08/29] Agent: unify sidebar grouping and manual task order --- apps/maple-agent/README.md | 9 +- apps/maple-agent/app/src/backend.rs | 8 +- apps/maple-agent/app/src/backend/local.rs | 138 +- apps/maple-agent/app/src/backend/paging.rs | 21 +- apps/maple-agent/app/src/desktop.rs | 53 +- apps/maple-agent/app/src/remote/update.rs | 16 +- apps/maple-agent/app/src/remote/wire.rs | 45 +- apps/maple-agent/app/src/settings.rs | 200 ++- apps/maple-agent/app/src/ui/chat/commands.rs | 2 +- apps/maple-agent/app/src/ui/chat/mod.rs | 58 +- .../maple-agent/app/src/ui/chat/navigation.rs | 15 +- apps/maple-agent/app/src/ui/chat/sidebar.rs | 1508 +++++------------ .../app/src/ui/chat/sidebar_computers.rs | 1015 +++++++---- .../app/src/ui/chat/sidebar_ordering.rs | 765 +++++++++ .../app/src/ui/chat/sidebar_paging.rs | 75 +- apps/maple-agent/app/src/ui/chat/tests.rs | 396 +++-- .../crates/maple-agent/src/acp/mod.rs | 1 + .../crates/maple-agent/src/store/mod.rs | 10 +- .../src/store/paging/navigation.rs | 403 ++++- .../src/store/paging/navigation_tests.rs | 13 + .../crates/maple-agent/src/store/tasks.rs | 38 +- .../maple-agent/crates/store-bench/src/run.rs | 3 + apps/maple-agent/docs/remote-access.md | 40 +- 23 files changed, 2963 insertions(+), 1869 deletions(-) create mode 100644 apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index a43b7deb3..b49c80dbb 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -126,7 +126,14 @@ Goose, with an independent dependency graph. Renaming from its desktop or a paired controller updates connected views and persists across restarts without changing the folder path. The owner supplies the default workspace label too. Personal pins remain desktop preferences. -- Sessions grouped by project, with rename, archive, and restore. +- Sidebar organization by Project, Connection, Status, or In one list, with + Last updated or Manual order for tasks. Connection becomes available after + another computer is added, including while it is offline. Project views keep + known empty projects visible. Project and connection order stays stable; + dragging rearranges the sidebar without changing a task's project, computer + or status. Dragging a task silently selects Manual order, which persists + across grouping changes and restarts; new tasks appear at the top. Tasks also + support rename, archive, and restore. - Settings: General (web tools, appearance, tool call details, desktop notifications, tool call summaries, composer Vim, application Vim, and the speech voice and speed), Keyboard Shortcuts, System prompt, Integrations diff --git a/apps/maple-agent/app/src/backend.rs b/apps/maple-agent/app/src/backend.rs index bf4c73062..ce69ac746 100644 --- a/apps/maple-agent/app/src/backend.rs +++ b/apps/maple-agent/app/src/backend.rs @@ -245,8 +245,8 @@ operations! { add_word(word: (String)) -> bool; get_remote_sidebars(user_id: (&str)) -> HashMap; set_remote_sidebar(user_id: (&str), computer: (String), patch: (serde_json::Value)) -> (); - get_sidebar_folder_order(user_id: (&str), computer: (Option)) -> Vec; - set_sidebar_folder_order(user_id: (&str), computer: (Option), order: (Vec)) -> (); + get_sidebar_order(user_id: (&str)) -> crate::settings::SidebarOrder; + set_sidebar_order(user_id: (&str), order: (crate::settings::SidebarOrder)) -> (); set_task_pinned(user_id: (&str), task: (&str), pinned: (bool)) -> (); login(email: (String), password: (String)) -> AuthSession; logout_and_clear(account_user: (&str)) -> (); @@ -523,8 +523,8 @@ impl AgentBackend { | "account" | "get_remote_sidebars" | "set_remote_sidebar" - | "get_sidebar_folder_order" - | "set_sidebar_folder_order" + | "get_sidebar_order" + | "set_sidebar_order" ) { &controller.client } else { diff --git a/apps/maple-agent/app/src/backend/local.rs b/apps/maple-agent/app/src/backend/local.rs index e730b1a60..b0aef845f 100644 --- a/apps/maple-agent/app/src/backend/local.rs +++ b/apps/maple-agent/app/src/backend/local.rs @@ -63,37 +63,69 @@ pub struct AuthSession { pub user_id: String, } -fn sidebar_folder_order_key(computer: &Option) -> Result { - if computer.as_ref().is_some_and(|computer| { - computer.is_empty() || computer.len() > 128 || computer.chars().any(char::is_control) - }) { - return Err("Invalid sidebar computer".into()); - } - serde_json::to_string(computer).map_err(|_| "Could not encode sidebar computer".into()) -} - -fn validate_sidebar_folder_order(order: &[String]) -> Result<(), String> { - const MAX_FOLDERS: usize = 4096; +fn validate_sidebar_order(order: &crate::settings::SidebarOrder) -> Result<(), String> { + const MAX_ITEMS: usize = 8192; + const MAX_CONNECTIONS: usize = 256; const MAX_ROOT_BYTES: usize = 32768; - const MAX_ORDER_BYTES: usize = 1024 * 1024; - if order.len() > MAX_FOLDERS { - return Err("Sidebar folder order is too large".into()); + const MAX_TASK_ID_BYTES: usize = 256; + const MAX_ORDER_BYTES: usize = 2 * 1024 * 1024; + if order.projects.len() > MAX_ITEMS + || order.tasks.len() > MAX_ITEMS + || order.connections.len() > MAX_CONNECTIONS + { + return Err("Sidebar order is too large".into()); + } + if order.created_before_ms < 0 { + return Err("Invalid sidebar order cutoff".into()); } let mut bytes = 0_usize; - let mut seen = HashSet::new(); - for root in order { - if root.is_empty() - || root.len() > MAX_ROOT_BYTES - || root.contains('\0') - || !seen.insert(root.as_str()) - { - return Err("Invalid sidebar folder order".into()); + let mut validate_computer = |computer: &Option| -> Result<(), String> { + if let Some(computer) = computer { + if computer.is_empty() || computer.len() > 128 || computer.chars().any(char::is_control) + { + return Err("Invalid sidebar computer".into()); + } + bytes = bytes.saturating_add(computer.len()); + } + Ok(()) + }; + for (items, maximum, is_task) in [ + (&order.projects, MAX_ROOT_BYTES, false), + (&order.tasks, MAX_TASK_ID_BYTES, true), + ] { + let mut seen = HashSet::new(); + for item in items { + validate_computer(&item.computer)?; + if item.id.is_empty() + || item.id.len() > maximum + || item.id.contains('\0') + || (is_task && item.id.chars().any(char::is_control)) + || !seen.insert(item) + { + return Err("Invalid sidebar order item".into()); + } } - bytes += root.len(); - if bytes > MAX_ORDER_BYTES { - return Err("Sidebar folder order is too large".into()); + } + let mut connections = HashSet::new(); + for computer in &order.connections { + validate_computer(computer)?; + if !connections.insert(computer) { + return Err("Duplicate sidebar connection".into()); } } + bytes = order + .projects + .iter() + .chain(&order.tasks) + .fold(bytes, |bytes, item| bytes.saturating_add(item.id.len())); + if bytes > MAX_ORDER_BYTES + || serde_json::to_vec(order) + .map_err(|_| "Could not encode sidebar order".to_string())? + .len() + > MAX_ORDER_BYTES + { + return Err("Sidebar order is too large".into()); + } Ok(()) } @@ -1346,68 +1378,52 @@ impl LocalBackend { .map_err(|_| "Could not save remote sidebar settings".to_string())? } - /// Folder order is account-owned controller presentation state, independent + /// Sidebar order is account-owned controller presentation state, independent /// of the execution host's project ordering and navigation cursors. - pub async fn get_sidebar_folder_order( + pub async fn get_sidebar_order( &self, user_id: &str, - computer: Option, - ) -> Result, String> { - let key = sidebar_folder_order_key(&computer)?; + ) -> Result { let (store, account) = self.presentation_account(user_id)?; tokio::task::spawn_blocking(move || { - let mut all: HashMap> = serde_json::from_value( - store - .account_setting(account, "sidebar.folder.order") - .map_err(|error| error.to_string())? - .unwrap_or_else(|| serde_json::json!({})), - ) - .map_err(|_| "Saved sidebar folder order is invalid".to_string())?; - let order = all.remove(&key).unwrap_or_default(); - validate_sidebar_folder_order(&order)?; + let order = match store + .account_setting(account, "sidebar.order") + .map_err(|error| error.to_string())? + { + Some(value) => serde_json::from_value(value) + .map_err(|_| "Saved sidebar order is invalid".to_string())?, + None => crate::settings::SidebarOrder::default(), + }; + validate_sidebar_order(&order)?; Ok(order) }) .await - .map_err(|_| "Could not read sidebar folder order".to_string())? + .map_err(|_| "Could not read sidebar order".to_string())? } - pub async fn set_sidebar_folder_order( + pub async fn set_sidebar_order( &self, user_id: &str, - computer: Option, - order: Vec, + order: crate::settings::SidebarOrder, ) -> Result<(), String> { - let key = sidebar_folder_order_key(&computer)?; - validate_sidebar_folder_order(&order)?; + validate_sidebar_order(&order)?; // Host dispatch already holds the account read gate. Reuse the // presentation writer gate without taking a nested account read lock. let _gate = self.ui_settings_gate.lock().await; let (store, account) = self.presentation_account(user_id)?; tokio::task::spawn_blocking(move || { - let mut all: HashMap> = serde_json::from_value( - store - .account_setting(account, "sidebar.folder.order") - .map_err(|error| error.to_string())? - .unwrap_or_else(|| serde_json::json!({})), - ) - .map_err(|_| "Saved sidebar folder order is invalid".to_string())?; - if order.is_empty() { - all.remove(&key); - } else { - all.insert(key, order); - } store .set_account_setting( account, - "sidebar.folder.order", - &serde_json::to_value(all) - .map_err(|_| "Could not encode sidebar folder order".to_string())?, + "sidebar.order", + &serde_json::to_value(order) + .map_err(|_| "Could not encode sidebar order".to_string())?, crate::settings::timestamp_ms(), ) .map_err(|error| error.to_string()) }) .await - .map_err(|_| "Could not save sidebar folder order".to_string())? + .map_err(|_| "Could not save sidebar order".to_string())? } pub async fn set_task_pinned( diff --git a/apps/maple-agent/app/src/backend/paging.rs b/apps/maple-agent/app/src/backend/paging.rs index 51eb20c1e..d4ab0c63c 100644 --- a/apps/maple-agent/app/src/backend/paging.rs +++ b/apps/maple-agent/app/src/backend/paging.rs @@ -12,9 +12,9 @@ use maple_agent::agent::{ use maple_agent::store::{AccountId, PageTransport, Store}; pub use maple_agent::store::{ AttachmentRangeReply, HistoryAnchorRequest, HistoryPage, HistoryPageRequest, - NavigationGetTasksReply, NavigationGroupPage, NavigationGroupQuery, NavigationProjectPage, - NavigationSearchQuery, NavigationTaskPage, NavigationTaskQuery, OpenTaskPageRequest, - ProjectSummary, RowRange, RowRangeRequest, TaskSummary, TimelineRow, + NavigationGetTasksReply, NavigationGroupPage, NavigationGroupQuery, NavigationManualOrder, + NavigationProjectPage, NavigationSearchQuery, NavigationTaskPage, NavigationTaskQuery, + OpenTaskPageRequest, ProjectSummary, RowRange, RowRangeRequest, TaskSummary, TimelineRow, }; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::Value; @@ -27,6 +27,8 @@ use crate::host::protocol::Request; pub struct PinnedTasksRequest { pub project: Option, pub cursor: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub manual: Option, #[serde(default = "default_limit")] pub limit: u16, } @@ -431,11 +433,16 @@ pub(super) async fn dispatch( let (store, account) = context(backend, &user).await?; encode( blocking(move || { - store.pinned_task_summaries( + store.list_task_summaries( account, - query.project.as_deref(), - query.cursor.as_deref(), - query.limit, + &NavigationTaskQuery { + section: maple_agent::store::TaskSection::Pinned, + project: query.project, + pins: maple_agent::store::PinFilter::Ignore, + cursor: query.cursor, + manual: query.manual, + limit: query.limit, + }, transport, ) }) diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index 7cb9bd000..053cc8c32 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -16,7 +16,7 @@ use crate::ui; use crate::ui::chat::computers::{ Computer, Destination, Navigate, NavigationChanged, ProjectRenameRequest, ProjectTarget, }; -use crate::ui::chat::{ChatScreen, LoggedOut}; +use crate::ui::chat::{ChatScreen, LoggedOut, SignOutFailed}; use crate::ui::login::{LoginScreen, LoginSucceeded}; use crate::ui::settings::{ AccountDeleted, Section, SettingsClosed, SettingsScreen, ShortcutSettingsChange, @@ -1159,7 +1159,9 @@ impl MapleApp { /// is reported twice (Settings button, then the backend) keeps the /// form the user may already be typing in. fn show_login(&mut self, cx: &mut Context) { - self.user_id = None; + if let Some(user_id) = self.user_id.take() { + crate::settings::retire_sidebar_account(&user_id); + } self.settings.remote_sidebars.clear(); self.set_remote_enabled(false, cx); self.parked_chat = None; @@ -1190,6 +1192,11 @@ impl MapleApp { /// Show the chat screen for a signed-in account and wire its events. fn open_chat(&mut self, user_id: String, cx: &mut Context) { + if self.user_id.as_deref() != Some(user_id.as_str()) + && let Some(previous) = self.user_id.as_deref() + { + crate::settings::retire_sidebar_account(previous); + } log::debug!( "startup: chat screen open at {} ms", crate::startup_elapsed() @@ -1785,17 +1792,22 @@ impl MapleApp { .detach(); cx.subscribe( &screen, - |app: &mut MapleApp, _emitter, _event: &SignOutRequested, cx| { - if let Some(chat) = app.local_chat.take() { + |app: &mut MapleApp, emitter, _event: &SignOutRequested, cx| { + if !app.is_current_settings_screen(&emitter) { + return; + } + if let Some(chat) = app.local_chat.clone() { chat.update(cx, |chat, cx| chat.sign_out(cx)); } - app.show_login(cx); }, ) .detach(); cx.subscribe( &screen, - |app: &mut MapleApp, _emitter, _event: &AccountDeleted, cx| { + |app: &mut MapleApp, emitter, _event: &AccountDeleted, cx| { + if !app.is_current_settings_screen(&emitter) { + return; + } // The runtime stopped and the credentials went before the // event; the parked chat has nothing left to sign out of. app.parked_chat = None; @@ -1836,8 +1848,33 @@ impl MapleApp { .detach(); cx.subscribe( chat, - |app: &mut MapleApp, _emitter, _event: &LoggedOut, cx| { - app.show_login(cx); + |app: &mut MapleApp, emitter, _event: &LoggedOut, cx| { + if app + .local_chat + .as_ref() + .is_some_and(|chat| chat.entity_id() == emitter.entity_id()) + { + app.show_login(cx); + } + }, + ) + .detach(); + cx.subscribe( + chat, + |app: &mut MapleApp, emitter, event: &SignOutFailed, cx| { + if app.user_id.as_deref() == Some(event.user_id.as_str()) + && app + .local_chat + .as_ref() + .is_some_and(|chat| chat.entity_id() == emitter.entity_id()) + { + app.present_store_attention( + event.message.clone(), + StoreAttentionKind::Persistence, + true, + cx, + ); + } }, ) .detach(); diff --git a/apps/maple-agent/app/src/remote/update.rs b/apps/maple-agent/app/src/remote/update.rs index a32cb82cc..cb5093a5c 100644 --- a/apps/maple-agent/app/src/remote/update.rs +++ b/apps/maple-agent/app/src/remote/update.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Deserializer, Serialize}; use std::sync::{Arc, Weak}; use uuid::Uuid; -pub const PROTOCOL_VERSION: &str = "7.1.0"; +pub const PROTOCOL_VERSION: &str = "8.0.0"; pub const PEER_INFO_CAPABILITY: &str = "peer_info_v1"; pub const INSTALL_CAPABILITY: &str = "remote_update_install_v1"; pub const REFRESH_CAPABILITY: &str = "remote_update_refresh_v1"; @@ -616,7 +616,7 @@ mod tests { #[test] fn responses_accept_future_fields_and_unknown_status_without_enabling_mutation() { let value = json!({ - "protocol":{"version":"7.1.0","capabilities":[INSTALL_CAPABILITY,"future_feature"],"future":true}, + "protocol":{"version":"8.1.0","capabilities":[INSTALL_CAPABILITY,"future_feature"],"future":true}, "running":{"version":"0.0.6","source_sha":"a".repeat(40),"platform":"linux-x86_64","future":true}, "update":{"state":"future_state","future":true}, "future":true @@ -626,10 +626,10 @@ mod tests { assert_eq!(info.update.state, UpdateState::Unknown); assert!(info.supports_remote_updates()); let mut next_major = info.clone(); - next_major.protocol.version = "8.0.0".into(); + next_major.protocol.version = "9.0.0".into(); assert!(!next_major.supports_remote_updates()); let mut previous_major = info.clone(); - previous_major.protocol.version = "6.2.0".into(); + previous_major.protocol.version = "7.0.0".into(); assert!(!previous_major.supports_remote_updates()); let mut no_capability = info; no_capability.protocol.capabilities.clear(); @@ -915,18 +915,18 @@ mod tests { fn project_folder_support_requires_its_capability_and_current_major() { let mut info = current_peer_for_compatibility(); assert!(info.supports_project_folders()); - for version in ["7.0.0", "7.1.0", "7.2.0"] { + for version in ["8.0.0", "8.1.0", "8.2.0"] { info.protocol.version = version.into(); assert!( info.supports_project_folders(), "explicit capability in {version}" ); } - for version in ["6.2.0", "8.0.0", "invalid"] { + for version in ["6.2.0", "7.0.0", "9.0.0", "invalid"] { info.protocol.version = version.into(); assert!(!info.supports_project_folders()); } - info.protocol.version = "7.0.0".into(); + info.protocol.version = "8.0.0".into(); info.protocol.capabilities = vec!["peer_info_v1".into(), "remote_update_install_v1".into()]; assert!( info.supports_remote_updates(), @@ -936,7 +936,7 @@ mod tests { !info.supports_project_folders(), "folder support still requires its capability" ); - info.protocol.version = "7.1.0".into(); + info.protocol.version = "8.1.0".into(); assert!( !info.supports_project_folders(), "a minor version alone is not a capability" diff --git a/apps/maple-agent/app/src/remote/wire.rs b/apps/maple-agent/app/src/remote/wire.rs index f502dd3af..344404be0 100644 --- a/apps/maple-agent/app/src/remote/wire.rs +++ b/apps/maple-agent/app/src/remote/wire.rs @@ -6,7 +6,7 @@ use zeroize::Zeroizing; // Keep the ALPN family stable so older peers receive the explicit version refusal. pub const ALPN: &[u8] = b"maple-agent/remote/1"; -pub const VERSION: u32 = 7; +pub const VERSION: u32 = 8; pub const MAX_FRAME: usize = 16 * 1024 * 1024; pub const MAX_PREFACE: usize = 4096; pub const MAX_MANAGEMENT_REPLY: usize = 16 * 1024; @@ -238,10 +238,7 @@ impl Method { ) } pub fn parse(value: &str) -> Result { - if matches!( - value, - "get_sidebar_folder_order" | "set_sidebar_folder_order" - ) { + if matches!(value, "get_sidebar_order" | "set_sidebar_order") { return Err("This operation is available only on the local computer.".into()); } serde_json::from_value(serde_json::Value::String(value.into())) @@ -571,7 +568,7 @@ mod tests { #[test] fn optional_management_keeps_the_shipped_control_ack_and_alpn() { assert_eq!(ALPN, b"maple-agent/remote/1"); - assert_eq!(VERSION, 7); + assert_eq!(VERSION, 8); assert_eq!( serde_json::to_string(&Ok::<(), Refusal>(())).unwrap(), r#"{"Ok":null}"# @@ -616,6 +613,38 @@ mod tests { } } + #[tokio::test] + async fn manual_paging_wire_upgrade_refuses_seven_before_decoding_requests() { + let previous: serde_json::Value = + serde_json::from_str(include_str!("wire_v7.json")).unwrap(); + for kind in ["hello", "pair"] { + let (mut tx, mut rx) = tokio::io::duplex(4096); + write(&mut tx, &previous[kind], MAX_PREFACE).await.unwrap(); + let refusal = match read_hello(&mut rx).await { + Err(refusal) => refusal, + Ok(_) => panic!("wire 7 must update before manual paged queries are sent"), + }; + assert!(is_protocol_incompatibility_refusal(&refusal.into_error())); + } + } + + #[test] + fn released_wire_seven_refuses_eight_before_request_admission() { + // Preserve the released policy independently of the current constant. + let mut current: serde_json::Value = + serde_json::from_str(include_str!("wire_v7.json")).unwrap(); + for kind in ["hello", "pair"] { + current[kind]["version"] = serde_json::json!(VERSION); + current[kind]["minimum_version"] = serde_json::json!(VERSION); + let version = current[kind]["version"].as_u64().unwrap(); + let minimum = current[kind]["minimum_version"].as_u64().unwrap(); + let refused = minimum == 0 || minimum > version || minimum > 7 || version < 7; + assert!(refused, "released wire 7 must refuse the new baseline"); + let hello: Hello = serde_json::from_value(current[kind].clone()).unwrap(); + assert!(hello.check_version().is_ok()); + } + } + #[tokio::test] async fn released_wire_six_refuses_seven_before_reading_any_request() { // Freeze the released reader's version policy and refusal shape. Do not @@ -715,7 +744,7 @@ mod tests { ); } #[test] - fn wire_seven_examples_are_stable() { + fn frozen_wire_seven_examples_keep_their_payload_shapes() { let examples: serde_json::Value = serde_json::from_str(include_str!("wire_v7.json")).unwrap(); fn check(value: &serde_json::Value) { @@ -788,7 +817,7 @@ mod tests { "fixture must exercise a forwarded event" ); } - assert_eq!(examples["hello"]["version"], VERSION); + assert_eq!(examples["hello"]["version"], 7); } #[test] diff --git a/apps/maple-agent/app/src/settings.rs b/apps/maple-agent/app/src/settings.rs index 2955bfd87..fd4481819 100644 --- a/apps/maple-agent/app/src/settings.rs +++ b/apps/maple-agent/app/src/settings.rs @@ -19,35 +19,41 @@ pub struct RemoteSidebarSettings { pub struct SidebarView { pub group: SidebarGroup, pub sort: SidebarSort, - /// Use saved folder order; task sorting stays unchanged. - #[serde(default)] - pub manual_folders: bool, - pub ascending: bool, - /// The project line under each title. `None` shows it except when - /// grouped by folder, whose header already names the project. - pub metadata: Option, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "lowercase")] pub enum SidebarGroup { - /// Active, settled: the task ladder. - Status, - #[serde(alias = "none")] - Recent, #[default] - #[serde(other)] - Folder, + Project, + Connection, + Status, + List, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "lowercase")] pub enum SidebarSort { - Created, - Name, #[default] - #[serde(other)] Updated, + Manual, +} + +/// Stable presentation identity; a remote task or project belongs to its computer. +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub struct SidebarItemKey { + pub computer: Option, + pub id: String, +} + +/// Account-owned controller state. Project paths never enter global UI settings. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(default)] +pub struct SidebarOrder { + pub projects: Vec, + pub connections: Vec>, + pub tasks: Vec, + pub created_before_ms: i64, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -85,7 +91,7 @@ pub struct AppSettings { #[serde(default)] pub remote_sidebars: std::collections::HashMap, #[serde(default)] - pub sidebar_view: SidebarView, + pub sidebar_layout: SidebarView, /// Whether run completion and questions raise desktop notifications /// while the window is not focused. #[serde(default = "default_desktop_notifications")] @@ -285,7 +291,7 @@ impl Default for AppSettings { pinned_tasks: Vec::new(), project_names: std::collections::HashMap::new(), remote_sidebars: std::collections::HashMap::new(), - sidebar_view: SidebarView::default(), + sidebar_layout: SidebarView::default(), desktop_notifications: default_desktop_notifications(), automatic_update_downloads: default_automatic_update_downloads(), password_notification_details: false, @@ -311,9 +317,9 @@ enum SettingsSink { } #[derive(Clone)] -struct PendingFolderOrder { +struct PendingSidebarOrder { backend: std::sync::Arc, - order: Vec, + order: SidebarOrder, } struct SettingsCache { @@ -321,8 +327,10 @@ struct SettingsCache { value: AppSettings, pending: serde_json::Map, words: std::collections::BTreeSet, + /// Shared account snapshots keep cached screens from replaying stale order. + sidebar_orders: std::collections::BTreeMap, /// Account-owned writes stay in memory and never enter global UI settings. - pending_folder_orders: std::collections::BTreeMap<(String, Option), PendingFolderOrder>, + pending_sidebar_orders: std::collections::BTreeMap, sink: Option, } @@ -333,7 +341,8 @@ static CACHE: std::sync::LazyLock> = value: AppSettings::default(), pending: Default::default(), words: Default::default(), - pending_folder_orders: Default::default(), + sidebar_orders: Default::default(), + pending_sidebar_orders: Default::default(), sink: None, }) }); @@ -357,7 +366,8 @@ pub fn clear_decrypted_cache() { cache.value = AppSettings::default(); // Account-owned paths cannot survive losing the verified account/store // context. Normal navigation and Quit flush them before this boundary. - cache.pending_folder_orders.clear(); + cache.pending_sidebar_orders.clear(); + cache.sidebar_orders.clear(); // Retain unsynchronized edits privately for this generation, but stop // retries until an Open snapshot supplies a fresh host attachment. cache.sink = None; @@ -371,7 +381,8 @@ pub fn discard_decrypted_cache() { cache.value = AppSettings::default(); cache.pending.clear(); cache.words.clear(); - cache.pending_folder_orders.clear(); + cache.pending_sidebar_orders.clear(); + cache.sidebar_orders.clear(); cache.sink = None; cache.generation = cache.generation.wrapping_add(1); } @@ -461,19 +472,46 @@ pub fn update_settings_in_background(update: impl FnOnce(&mut AppSettings) + Sen queue_flush(None); } -/// Queue an account-owned folder preference through the same Quit barrier as +pub(crate) fn cached_sidebar_order(user: &str) -> Option { + CACHE + .lock() + .unwrap_or_else(|e| e.into_inner()) + .sidebar_orders + .get(user) + .cloned() +} + +/// A late host reply cannot replace ordering already edited by another screen. +pub(crate) fn install_sidebar_order(user: String, order: SidebarOrder) -> SidebarOrder { + CACHE + .lock() + .unwrap_or_else(|e| e.into_inner()) + .sidebar_orders + .entry(user) + .or_insert(order) + .clone() +} + +/// Retire only this account's decrypted presentation state and queued writes. +pub(crate) fn retire_sidebar_account(user: &str) { + let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + cache.sidebar_orders.remove(user); + cache.pending_sidebar_orders.remove(user); +} + +/// Queue account-owned ordering through the same Quit barrier as /// ordinary settings. The captured account/backend are never rebound on retry. -pub(crate) fn save_sidebar_folder_order( +pub(crate) fn save_sidebar_order( backend: std::sync::Arc, user: String, - computer: Option, - order: Vec, + order: SidebarOrder, ) -> tokio::sync::oneshot::Receiver> { - CACHE - .lock() - .unwrap_or_else(|e| e.into_inner()) - .pending_folder_orders - .insert((user, computer), PendingFolderOrder { backend, order }); + let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + cache.sidebar_orders.insert(user.clone(), order.clone()); + cache + .pending_sidebar_orders + .insert(user, PendingSidebarOrder { backend, order }); + drop(cache); let (sender, receiver) = tokio::sync::oneshot::channel(); queue_flush(Some(SettingsAck::Async(sender))); receiver @@ -567,11 +605,11 @@ fn queue_flush(ack: Option) { } fn synchronize_once() -> Result<(), String> { - let (generation, sink, patch, words, folder_orders) = { + let (generation, sink, patch, words, sidebar_orders) = { let cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); if cache.pending.is_empty() && cache.words.is_empty() - && cache.pending_folder_orders.is_empty() + && cache.pending_sidebar_orders.is_empty() { return Ok(()); } @@ -580,7 +618,7 @@ fn synchronize_once() -> Result<(), String> { cache.sink.clone(), cache.pending.clone(), cache.words.clone(), - cache.pending_folder_orders.clone(), + cache.pending_sidebar_orders.clone(), ) }; match sink.ok_or("Maple host is unavailable; settings edits remain pending")? { @@ -615,10 +653,10 @@ fn synchronize_once() -> Result<(), String> { { let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); if cache.generation != generation { - return if folder_orders.is_empty() { + return if sidebar_orders.is_empty() { Ok(()) } else { - Err("Maple settings state changed; folder order edits remain pending".into()) + Err("Maple settings state changed; sidebar order edits remain pending".into()) }; } for (key, value) in patch { @@ -631,12 +669,12 @@ fn synchronize_once() -> Result<(), String> { } } let mut first_error = None; - for ((user, computer), pending) in folder_orders { + for (user, pending) in sidebar_orders { { let cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); if cache.generation != generation || cache.sink.is_none() { first_error.get_or_insert_with(|| { - "Maple settings state changed; folder order edits remain pending".to_string() + "Maple settings state changed; sidebar order edits remain pending".to_string() }); break; } @@ -644,29 +682,26 @@ fn synchronize_once() -> Result<(), String> { let result = pending.backend.runtime_handle().block_on(async { tokio::time::timeout( std::time::Duration::from_secs(5), - pending.backend.set_sidebar_folder_order( - &user, - computer.clone(), - pending.order.clone(), - ), + pending + .backend + .set_sidebar_order(&user, pending.order.clone()), ) .await - .map_err(|_| "Maple folder order synchronization timed out".to_string())? + .map_err(|_| "Maple sidebar order synchronization timed out".to_string())? }); match result { Ok(()) => { let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); - let key = (user, computer); if cache.generation == generation && cache - .pending_folder_orders - .get(&key) + .pending_sidebar_orders + .get(&user) .is_some_and(|current| { current.order == pending.order && std::sync::Arc::ptr_eq(¤t.backend, &pending.backend) }) { - cache.pending_folder_orders.remove(&key); + cache.pending_sidebar_orders.remove(&user); } } Err(error) => { @@ -907,7 +942,8 @@ pub(crate) fn install_test_settings(value: AppSettings) { value, pending: Default::default(), words: Default::default(), - pending_folder_orders: Default::default(), + sidebar_orders: Default::default(), + pending_sidebar_orders: Default::default(), sink: None, }; } @@ -922,7 +958,8 @@ pub(crate) fn install_test_store(store: std::sync::Arc(serde_json::json!({ + "sidebar_layout": {"group": group} + })) + .is_err() + ); + } + for sort in ["invalid", "unknown"] { + assert!( + serde_json::from_value::(serde_json::json!({ + "sidebar_layout": {"sort": sort} + })) + .is_err() + ); } } @@ -1122,13 +1162,10 @@ mod tests { update_settings_in_background(|settings| settings.tts_voice = "neutral_male".into()); update_settings_in_background(|settings| settings.tts_speed = 1.5); let view = SidebarView { - group: SidebarGroup::Recent, - sort: SidebarSort::Name, - manual_folders: false, - ascending: true, - metadata: Some(false), + group: SidebarGroup::List, + sort: SidebarSort::Manual, }; - update_settings_in_background(move |settings| settings.sidebar_view = view); + update_settings_in_background(move |settings| settings.sidebar_layout = view); update_settings_in_background(|settings| settings.automatic_update_downloads = false); update_settings_in_background(|settings| { settings @@ -1138,7 +1175,7 @@ mod tests { update_settings_and_wait(|_| {}); let reloaded = load_host_settings(&store, None).unwrap(); assert_eq!(reloaded.tts_voice, "neutral_male"); - assert_eq!(reloaded.sidebar_view, view); + assert_eq!(reloaded.sidebar_layout, view); assert!((reloaded.tts_speed - 1.5).abs() < 0.01); assert!(!reloaded.automatic_update_downloads); assert_eq!( @@ -1171,7 +1208,8 @@ mod tests { value: AppSettings::default(), pending: Default::default(), words: Default::default(), - pending_folder_orders: Default::default(), + sidebar_orders: Default::default(), + pending_sidebar_orders: Default::default(), sink: None, }; edit_cache(|settings| settings.theme = "dark".into()); diff --git a/apps/maple-agent/app/src/ui/chat/commands.rs b/apps/maple-agent/app/src/ui/chat/commands.rs index a904d4906..a1e6c351d 100644 --- a/apps/maple-agent/app/src/ui/chat/commands.rs +++ b/apps/maple-agent/app/src/ui/chat/commands.rs @@ -49,7 +49,7 @@ impl ChatScreen { ChatCommand::Escape | ChatCommand::ApplicationVim(ApplicationVimCommand::Escape) ) && self .sidebar - .update(cx, |sidebar, cx| sidebar.cancel_folder_drag(window, cx)) + .update(cx, |sidebar, cx| sidebar.cancel_sidebar_drag(window, cx)) { return; } diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 4badcb5ce..31e731111 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -94,6 +94,11 @@ pub struct PickQuestionOption { pub struct LoggedOut; +pub(crate) struct SignOutFailed { + pub(crate) user_id: String, + pub(crate) message: String, +} + /// Emitted when the user opens app settings from the chat header. pub struct OpenSettings; @@ -606,6 +611,7 @@ pub struct ChatScreen { unresolved_sends: HashMap, attachment_uploads: HashMap, accept_send_results: bool, + sign_out_pending: bool, discarded_sends: HashSet, checking_sends: bool, /// Source of `DraftAttachment::id`. @@ -1047,10 +1053,10 @@ impl ChatScreen { } #[cfg(test)] - fn switcher_root_paths(&self, cx: &gpui::App) -> Vec { + fn project_root_paths(&self, cx: &gpui::App) -> Vec { self.sidebar .read(cx) - .switcher_root_paths() + .project_root_paths() .into_iter() .map(str::to_owned) .collect() @@ -1363,6 +1369,7 @@ impl ChatScreen { unresolved_sends: HashMap::new(), attachment_uploads: HashMap::new(), accept_send_results: true, + sign_out_pending: false, discarded_sends: HashSet::new(), checking_sends: false, draft_counter: 0, @@ -5396,6 +5403,43 @@ impl ChatScreen { } pub(crate) fn sign_out(&mut self, cx: &mut Context) { + if self.sign_out_pending { + return; + } + self.sign_out_pending = true; + // Pending upload completions cannot admit a prompt while saving the + // account's final presentation edits before sign-out. + self.accept_send_results = false; + self.call( + async { + tokio::time::timeout( + std::time::Duration::from_secs(10), + crate::settings::flush_pending(), + ) + .await + .map_err(|_| "Saving changes before sign-out timed out. Try again.".to_string())? + .map_err(|message| format!("Could not save changes before signing out: {message}")) + }, + cx, + |this, result, cx| match result { + Ok(()) => this.sign_out_after_settings_flush(cx), + Err(message) => this.sign_out_failed(message, cx), + }, + ); + } + + fn sign_out_failed(&mut self, message: String, cx: &mut Context) { + self.sign_out_pending = false; + self.accept_send_results = true; + self.notice = Some(message.clone().into()); + cx.emit(SignOutFailed { + user_id: self.user_id.clone(), + message, + }); + cx.notify(); + } + + fn sign_out_after_settings_flush(&mut self, cx: &mut Context) { // A logout during upload must win before prompt admission, even while // the host is still shutting down an earlier run. self.accept_send_results = false; @@ -5420,8 +5464,13 @@ impl ChatScreen { backend.logout_and_clear(&user_id).await }, cx, - |_this, _result, cx| { - cx.emit(LoggedOut); + |this, result, cx| match result { + Ok(()) => { + this.sign_out_pending = false; + crate::settings::retire_sidebar_account(&this.user_id); + cx.emit(LoggedOut); + } + Err(message) => this.sign_out_failed(message, cx), }, ); } @@ -6496,6 +6545,7 @@ impl ChatScreen { impl EventEmitter for ChatScreen {} impl EventEmitter for ChatScreen {} +impl EventEmitter for ChatScreen {} impl EventEmitter for ChatScreen {} impl EventEmitter for ChatScreen {} diff --git a/apps/maple-agent/app/src/ui/chat/navigation.rs b/apps/maple-agent/app/src/ui/chat/navigation.rs index 40605362c..f23932cf2 100644 --- a/apps/maple-agent/app/src/ui/chat/navigation.rs +++ b/apps/maple-agent/app/src/ui/chat/navigation.rs @@ -34,7 +34,7 @@ pub(super) enum SidebarTarget { Search, Projects, /// A folder group's header, by project root. - Folder(String), + Project(String), Task(String), Archived, LoadMore(String), @@ -569,10 +569,17 @@ impl ChatScreen { let open = sidebar.header_menu_open(); sidebar.set_header_menu_open(!open, cx); }), - Some(SidebarTarget::Folder(root)) => self.sidebar.update(cx, |sidebar, cx| { - sidebar.set_folder_expanded(&root, None, cx); + Some(SidebarTarget::Project(root)) => self.sidebar.update(cx, |sidebar, cx| { + sidebar.set_project_expanded(&root, None, cx); }), - Some(SidebarTarget::Computer(request)) => cx.emit(request), + Some(SidebarTarget::Computer(request)) => { + let handled = self.sidebar.update(cx, |sidebar, cx| { + sidebar.toggle_computer_group(&request, cx) + }); + if !handled { + cx.emit(request); + } + } Some(SidebarTarget::Task(task_id)) => self.select_session(&task_id, cx), Some(SidebarTarget::Archived) => self.toggle_archived_visibility(cx), Some(SidebarTarget::LoadMore(id)) => self diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index f42c09e07..795131d21 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -12,6 +12,8 @@ #[path = "sidebar_computers.rs"] mod computers; +#[path = "sidebar_ordering.rs"] +mod ordering; #[path = "sidebar_paging.rs"] mod paging; use super::computers::{Computer, Destination, Navigate, ProjectRenameRequest, ProjectTarget}; @@ -52,6 +54,9 @@ pub(super) fn bootstrap_projects( /// Carry this small view state when swapping the per-computer chat screens. pub(crate) struct BrowseState { search: String, + view: SidebarView, + collapsed_projects: HashSet, + collapsed_computers: HashSet>, archived_expanded: bool, scroll: gpui::ListOffset, /// Application Vim's row. Other computers' sidebars name a task by its @@ -76,7 +81,7 @@ use super::commands::ChatCommand; use super::navigation::SidebarTarget; use super::{ChatScreen, section_label}; use crate::backend::AgentBackend; -use crate::settings::{SidebarGroup, SidebarSort, SidebarView}; +use crate::settings::{SidebarGroup, SidebarItemKey, SidebarOrder, SidebarSort, SidebarView}; use crate::ui::icons::{icon, spinner_with_id, wordmark}; use crate::ui::popup::{Menu, MenuItem, Placement, Popup}; use crate::ui::text_input::TextInput; @@ -201,12 +206,14 @@ pub(super) struct SidebarRow { pub(super) title: SharedString, /// Display name of the task's project, for metadata, search and accessibility. pub(super) project_name: SharedString, + connection_name: SharedString, + metadata: SharedString, /// Lower-cased title, matched against the sidebar filter. pub(super) search: String, } impl SidebarRow { - fn build(session: &AgentSessionSummary, project_name: &str) -> Self { + fn build(session: &AgentSessionSummary, project_name: &str, connection: &str) -> Self { let id = &session.id; Self { id: Arc::from(id.as_str()), @@ -226,6 +233,13 @@ 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()), + connection_name: connection.to_owned().into(), + metadata: if connection.is_empty() { + project_name.to_owned() + } else { + format!("{project_name} · {connection}") + } + .into(), search: session.title.to_lowercase(), } } @@ -236,14 +250,10 @@ const FOLDER_HEADER_PADDING: gpui::Rems = gpui::rems(1.); const FOLDER_HEADER_GAP: gpui::Rems = gpui::rems(0.5); const FOLDER_ICON_SIZE: gpui::Pixels = px(16.); -/// The switcher's "All projects" row. -const ALL_PROJECTS_ID: &str = "switcher-all-projects"; - /// The sidebar's popup menus. One is open at a time; a project's own menu -/// opens inside the switcher (see `Sidebar::project_popup`). +/// uses the project row's own popup (see `Sidebar::project_popup`). #[derive(Clone, Debug, PartialEq, Eq)] enum SidebarPopup { - Switcher, /// The View options menu: grouping, order, what rows show. View, /// A task row's overflow menu, by the row's shared id. @@ -254,7 +264,6 @@ enum SidebarPopup { enum ViewSubmenu { Group, Sort, - Show, } /// One row of the virtualized sidebar list, in display order. Rebuilt @@ -269,13 +278,13 @@ pub(super) enum SidebarEntry { LoadMore(PageKind), NewTask, Search, - /// The project switcher row above the sections. + /// The sidebar header above the grouped tasks. ProjectsHeader, /// A section label, shown only when the section has rows. SectionLabel(SidebarSection), Task(SidebarTaskEntry), /// An expanded folder whose non-archived task coverage is complete and empty. - FolderEmpty(usize), + ProjectEmpty(usize), ArchivedHeader, /// Placeholder row when no task row is listed. Empty(SidebarEmpty), @@ -374,10 +383,10 @@ pub(super) enum SidebarSection { Pinned, Active, Settled, - /// One flat list of recent tasks. - Recents, - /// A project's tasks, by index into the switcher roots. - Folder(usize), + /// One flat list of tasks in the selected order. + List, + /// A project's tasks, by index into the cached project rows. + Project(usize), } /// Hover follows section membership across independently rendered list rows. @@ -394,8 +403,8 @@ impl SidebarSection { Self::Pinned => "Pinned", Self::Active => "Active", Self::Settled => "Settled", - Self::Recents => "Recents", - Self::Folder(_) => "Tasks", + Self::List => "Tasks", + Self::Project(_) => "Tasks", } } @@ -404,8 +413,8 @@ impl SidebarSection { Self::Pinned => "section-toggle-pinned", Self::Active => "section-toggle-active", Self::Settled => "section-toggle-settled", - Self::Recents => "section-toggle-recents", - Self::Folder(_) => "section-toggle-tasks", + Self::List => "section-toggle-tasks", + Self::Project(_) => "section-toggle-tasks", } } } @@ -421,42 +430,64 @@ struct SidebarMenuItem { on_click: MenuAction, } -/// One project the switcher menu lists or a folder group heads: the root +/// A project header and its actions: the root /// path, its display name, and the element ids its rows and menus use. /// Built when the sections rebuild, never per frame. -pub(super) struct SwitcherRoot { +pub(super) struct ProjectRow { target: ProjectTarget, root: String, name: SharedString, - row_id: SharedString, - /// Hover group of the switcher row or the folder header. + /// Hover group shared by the project header and its actions. row_group: SharedString, menu_id: SharedString, folder_id: SharedString, new_task_id: SharedString, new_task_label: SharedString, empty_id: SharedString, - folder_position: usize, - folder_start: usize, - folder_end: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +enum SidebarDragItem { + Project(ProjectTarget), + Connection(Option), + Task(SidebarItemKey), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum TaskGroup { + Project(ProjectTarget), + Status(bool), + List, + Pinned, + Archived, +} + +#[derive(Clone, Default)] +struct ReorderRow { + project: Option, + connection: Option>, + task: Option, + task_group: Option, } #[derive(Clone, PartialEq, Eq)] -struct FolderDrop { - target: ProjectTarget, +struct SidebarDrop { + target: SidebarDragItem, after: bool, hover_row: usize, line_row: usize, } #[derive(Clone)] -struct DraggedFolder { +struct SidebarDrag { sidebar: gpui::EntityId, - target: ProjectTarget, + item: SidebarDragItem, + group: Option, name: SharedString, + icon: &'static str, } -impl Render for DraggedFolder { +impl Render for SidebarDrag { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { div() .flex() @@ -468,7 +499,7 @@ impl Render for DraggedFolder { .bg(gpui::rgb(theme::bg_sidebar_row_hover())) .text_sm() .text_color(gpui::rgb(theme::text_sidebar())) - .child(icon("folder", FOLDER_ICON_SIZE, theme::text_secondary())) + .child(icon(self.icon, FOLDER_ICON_SIZE, theme::text_secondary())) .child(self.name.clone()) } } @@ -498,9 +529,10 @@ pub(super) struct Sidebar { // The list and its sections. list: gpui::ListState, entries: Vec, - folder_rows: Vec>, - folder_dragging: bool, - folder_drop: Option, + reorder_rows: Vec, + drag_ranges: HashMap, + dragging: bool, + drop_target: Option, heading_gaps: Vec, entry_sections: Vec>, hovered_section: Option, @@ -514,18 +546,20 @@ pub(super) struct Sidebar { archived_rows: Vec, /// Folded sections other than folders, which fold by root below. collapsed: HashSet, - collapsed_roots: HashSet, + collapsed_projects: HashSet, + collapsed_computers: HashSet>, /// Whether the list under the Projects header shows. projects_expanded: bool, archived_expanded: bool, view: SidebarView, - folder_order: Vec, - folder_order_loaded: bool, - folder_order_loading: bool, - folder_order_reset_pending: bool, - switcher_roots: Vec, - computer_switcher_roots: Vec>, - scope_label: SharedString, + order: SidebarOrder, + order_loaded: bool, + order_loading: bool, + task_ranks: HashMap, HashMap>, + order_query_dirty: bool, + project_ranks: HashMap, HashMap>, + project_roots: Vec, + computer_project_roots: Vec>, project_filter: Option, // Menus. popup: Popup, @@ -581,20 +615,16 @@ impl ProjectTarget { } } -impl SwitcherRoot { +impl ProjectRow { fn new(target: ProjectTarget, name: SharedString) -> Self { Self { root: target.path.clone(), - row_id: target.element_id("switcher"), - row_group: target.element_id("switcher-project-row"), + row_group: target.element_id("project-row"), menu_id: target.element_id("menu-project"), folder_id: target.element_id("folder"), new_task_id: target.element_id("new-task-in"), new_task_label: format!("New task in {name}").into(), empty_id: target.element_id("folder-empty"), - folder_position: usize::MAX, - folder_start: usize::MAX, - folder_end: usize::MAX, target, name, } @@ -631,8 +661,8 @@ impl Sidebar { .unwrap_or_else(|| remote_root_display_name(&target.path)) } - fn rebuild_computer_switcher_roots(&mut self) { - self.computer_switcher_roots = self + fn rebuild_computer_project_roots(&mut self) { + self.computer_project_roots = self .computers .iter() .map(|computer| { @@ -644,7 +674,7 @@ impl Sidebar { computer: computer.key.clone(), path: project.root.clone(), }; - SwitcherRoot::new( + ProjectRow::new( target, self.computer_project_name(computer, project).into(), ) @@ -660,7 +690,7 @@ impl Sidebar { path: project.path.clone(), }; let name = self.project_name(&target).into(); - SwitcherRoot::new(target, name) + ProjectRow::new(target, name) }) .collect() } @@ -686,6 +716,9 @@ impl Sidebar { pub(crate) fn browse_state(&self, cx: &gpui::App) -> BrowseState { BrowseState { search: self.search_input.read(cx).text(), + view: self.view, + collapsed_projects: self.collapsed_projects.clone(), + collapsed_computers: self.collapsed_computers.clone(), archived_expanded: self.archived_expanded, scroll: self.list.logical_scroll_top(), vim_selected: self.vim_selected.clone().map(|target| match target { @@ -703,6 +736,18 @@ impl Sidebar { if state.computers.user_id != self.user_id { return; } + let mut sort_changed = self.order_query_dirty || self.view.sort != state.view.sort; + self.view = state.view; + self.collapsed_projects = state.collapsed_projects; + self.collapsed_computers = state.collapsed_computers; + if let Some(order) = crate::settings::cached_sidebar_order(&self.user_id) { + sort_changed |= self.view.sort == SidebarSort::Manual + && (self.order.tasks != order.tasks + || self.order.created_before_ms != order.created_before_ms); + self.order = order; + self.order_loaded = true; + self.rebuild_task_ranks(); + } let filter = state.search.trim().to_lowercase(); let scope_changed = self.filter != filter || self.archived_expanded != state.archived_expanded @@ -718,6 +763,9 @@ impl Sidebar { self.refresh_pages(cx); } self.restore_computer_browse_state(state.computers, cx); + if sort_changed && !scope_changed { + self.refresh_task_order(cx); + } self.rebuild_sections(); // Stay on the row the window had, usually the task just opened // here. This computer's own tasks are plain task rows. @@ -781,7 +829,7 @@ impl Sidebar { || top.offset_in_item != this.hover_scroll_top.offset_in_item { this.set_hovered_entry(None, cx); - this.set_folder_drop(None, cx); + this.set_drop_target(None, cx); } }) .ok(); @@ -806,9 +854,10 @@ impl Sidebar { vim_active: false, list, entries: Vec::new(), - folder_rows: Vec::new(), - folder_dragging: false, - folder_drop: None, + reorder_rows: Vec::new(), + drag_ranges: HashMap::new(), + dragging: false, + drop_target: None, heading_gaps: Vec::new(), entry_sections: Vec::new(), hovered_section: None, @@ -819,17 +868,19 @@ impl Sidebar { sections: Vec::new(), archived_rows: Vec::new(), collapsed: HashSet::new(), - collapsed_roots: HashSet::new(), + collapsed_projects: HashSet::new(), + collapsed_computers: HashSet::new(), projects_expanded: true, archived_expanded: false, - view: settings.sidebar_view, - folder_order: Vec::new(), - folder_order_loaded: false, - folder_order_loading: false, - folder_order_reset_pending: false, - switcher_roots: Vec::new(), - computer_switcher_roots: Vec::new(), - scope_label: "All projects".into(), + view: settings.sidebar_layout, + order: SidebarOrder::default(), + order_loaded: false, + order_loading: false, + task_ranks: HashMap::new(), + order_query_dirty: false, + project_ranks: HashMap::new(), + project_roots: Vec::new(), + computer_project_roots: Vec::new(), project_filter: None, popup: Popup::new(|this| &mut this.popup, cx), project_popup: Popup::new(|this| &mut this.project_popup, cx), @@ -858,7 +909,7 @@ impl Sidebar { vim_order: Vec::new(), bridged_tasks: RefCell::new(Vec::new()), }; - sidebar.load_folder_order(cx); + sidebar.load_order(cx); sidebar } @@ -1203,11 +1254,11 @@ impl Sidebar { /// The folder groups' project names, in display order. #[cfg(test)] - pub(super) fn folder_names(&self) -> Vec<&str> { + pub(super) fn grouped_project_names(&self) -> Vec<&str> { self.sections .iter() .filter_map(|(section, _)| match section { - SidebarSection::Folder(index) => Some(self.switcher_roots[*index].name.as_ref()), + SidebarSection::Project(index) => Some(self.project_roots[*index].name.as_ref()), _ => None, }) .collect() @@ -1242,18 +1293,8 @@ impl Sidebar { self.popup.open_key().is_some() || self.project_popup.open_key().is_some() } - /// The menu the Projects header opens: the switcher while paired - /// computers list their projects there, else the View options. - fn header_popup(&self) -> SidebarPopup { - if self.computers.len() > 1 { - SidebarPopup::Switcher - } else { - SidebarPopup::View - } - } - pub(super) fn header_menu_open(&self) -> bool { - self.popup.is_open(&self.header_popup()) + self.popup.is_open(&SidebarPopup::View) } #[cfg(test)] @@ -1332,10 +1373,11 @@ impl Sidebar { ..SidebarView::default() }; self.pinned_tasks.clear(); - self.folder_order.clear(); - self.folder_order_loaded = true; - self.folder_order_loading = false; - self.folder_order_reset_pending = false; + self.order = SidebarOrder::default(); + self.order_loaded = true; + self.order_loading = false; + self.task_ranks.clear(); + self.project_ranks.clear(); self.project_metadata.clear(); self.task_project_labels.clear(); self.removed_projects.clear(); @@ -1379,10 +1421,10 @@ impl Sidebar { self.search_input.read(cx).focus_handle(cx) } - /// Reveal the switcher root paths for tests. + /// Reveal the cached project paths for tests. #[cfg(test)] - pub(super) fn switcher_root_paths(&self) -> Vec<&str> { - self.switcher_roots + pub(super) fn project_root_paths(&self) -> Vec<&str> { + self.project_roots .iter() .map(|root| root.root.as_str()) .collect() @@ -1465,9 +1507,11 @@ impl Sidebar { /// sessions, roots, names, the filter, or the view change, never per frame. fn rebuild_sections(&mut self) { self.sync_rows(); - let filter = self.filter.as_str(); + let filter_text = self.filter.clone(); + let filter = filter_text.as_str(); let filtering = !filter.is_empty(); - let scoped = self.project_filter.as_deref(); + let project_scope = self.project_filter.clone(); + let scoped = project_scope.as_deref(); let pinned_ids: HashSet<&str> = self.pinned_tasks.iter().map(String::as_str).collect(); let mut pinned = Vec::new(); let mut listed = Vec::new(); @@ -1515,12 +1559,6 @@ impl Sidebar { listed.push(index); } } - self.scope_label = self - .project_filter - .as_deref() - .map(|root| self.root_name(root)) - .map(SharedString::from) - .unwrap_or_else(|| "All projects".into()); let recent_roots: HashSet<&str> = self.recent_roots.iter().map(String::as_str).collect(); let mut fresh_roots: Vec<&str> = session_roots .iter() @@ -1530,58 +1568,34 @@ impl Sidebar { .collect(); fresh_roots.sort_unstable(); fresh_roots.dedup(); - self.switcher_roots = self + self.project_roots = self .recent_roots .iter() .map(String::as_str) .chain(fresh_roots.iter().copied()) .filter(|root| !self.removed_projects.contains(*root)) - .map(|root| SwitcherRoot::new(self.project_target(root), self.root_name(root).into())) + .map(|root| ProjectRow::new(self.project_target(root), self.root_name(root).into())) .collect(); - if self.manual_folder_mode() && self.folder_order_loaded { - let known: HashSet<&str> = self.folder_order.iter().map(String::as_str).collect(); - let added: Vec = self - .switcher_roots - .iter() - .filter(|root| !known.contains(root.root.as_str())) - .map(|root| root.root.clone()) - .collect(); - if !added.is_empty() { - self.folder_order.extend(added); - self.persist_folder_order(); - } + self.sync_container_order(); + let computer = self.backend.remote_host().map(str::to_owned); + let mut keys = HashMap::new(); + for &ix in pinned.iter().chain(&listed).chain(&archived) { + let task = &self.sessions[ix]; + keys.insert( + ix, + self.task_order_key(&computer, &task.id, task.created_ms, task.updated_ms), + ); } let order = |a: &usize, b: &usize| { - let order = match if self.computers.len() > 1 { - SidebarSort::Updated - } else { - self.view.sort - } { - SidebarSort::Name => self.rows[*a].search.cmp(&self.rows[*b].search), - SidebarSort::Created => self.sessions[*a] - .created_ms - .cmp(&self.sessions[*b].created_ms), - SidebarSort::Updated => self.sessions[*a] - .updated_ms - .cmp(&self.sessions[*b].updated_ms), - }; - if self.computers.len() <= 1 && self.view.ascending { - order - } else { - order.reverse() - } + keys[a] + .cmp(&keys[b]) + .then_with(|| self.sessions[*a].id.cmp(&self.sessions[*b].id)) }; pinned.sort_by(order); listed.sort_by(order); - if self.computers.len() <= 1 { - archived.sort_by(order); - } + archived.sort_by(order); let mut sections = Vec::new(); - match if self.computers.len() > 1 { - SidebarGroup::Status - } else { - self.view.group - } { + match self.view.group { SidebarGroup::Status => { let (active, settled) = listed .into_iter() @@ -1589,120 +1603,46 @@ impl Sidebar { sections.push((SidebarSection::Active, active)); sections.push((SidebarSection::Settled, settled)); } - SidebarGroup::Recent => sections.push((SidebarSection::Recents, listed)), - SidebarGroup::Folder => { + SidebarGroup::List => sections.push((SidebarSection::List, listed)), + SidebarGroup::Project | SidebarGroup::Connection => { let folder_of: HashMap<&str, usize> = self - .switcher_roots + .project_roots .iter() .enumerate() .map(|(index, root)| (root.root.as_str(), index)) .collect(); - // Project metadata has no creation timestamp. Use each folder's - // earliest loaded task creation and newest activity, pinned - // tasks included, alongside its unpinned rows. - let mut folders = vec![(None, None, Vec::new()); self.switcher_roots.len()]; - for (position, &ix) in pinned.iter().chain(&listed).enumerate() { - let session = &self.sessions[ix]; - if let Some(&folder) = folder_of.get(session.project_root.as_str()) { - let (latest, earliest, rows) = &mut folders[folder]; - *latest = (*latest).max(Some(session.updated_ms)); - *earliest = Some(earliest.map_or(session.created_ms, |created: i64| { - created.min(session.created_ms) - })); - if position >= pinned.len() { - rows.push(ix); - } + let mut groups = vec![Vec::new(); self.project_roots.len()]; + for ix in listed { + if let Some(&group) = folder_of.get(self.sessions[ix].project_root.as_str()) { + groups[group].push(ix); } } - // Empty projects can use their saved registration/restoration - // time. For Created this is still an approximation, not a true - // project or filesystem creation timestamp. - for (index, (latest, earliest, _)) in folders.iter_mut().enumerate() { - let fallback = self - .project_metadata - .get(&self.switcher_roots[index].root) - .and_then(|project| i64::try_from(project.last_used_ms).ok()) - .filter(|timestamp| *timestamp > 0); - *latest = (*latest).or(fallback); - *earliest = (*earliest).or(fallback); - } - let mut folders: Vec<_> = folders + let mut groups: Vec<_> = groups .into_iter() .enumerate() - .filter(|(index, (_, _, rows))| { - let root = &self.switcher_roots[*index]; + .filter(|(index, rows)| { + let root = &self.project_roots[*index]; scoped.is_none_or(|scoped| scoped == root.root) && (!rows.is_empty() || !filtering || root.name.to_lowercase().contains(filter)) }) .collect(); - if self.manual_folder_mode() && self.folder_order_loaded { - let ranks: HashMap<&str, usize> = self - .folder_order - .iter() - .enumerate() - .map(|(position, root)| (root.as_str(), position)) - .collect(); - folders.sort_by_key(|(index, _)| { - ranks - .get(self.switcher_roots[*index].root.as_str()) - .copied() - .unwrap_or(usize::MAX) - }); - } else { - let names: Vec<_> = self - .switcher_roots - .iter() - .map(|root| root.name.to_lowercase()) - .collect(); - let direction = |order: std::cmp::Ordering| { - if self.view.ascending { - order - } else { - order.reverse() - } - }; - let dated = |left: Option, right: Option| match (left, right) { - (Some(left), Some(right)) => direction(left.cmp(&right)), - // An empty folder has no task date. Keep it after dated - // folders in either direction, with stable name/root ties. - (Some(_), None) => std::cmp::Ordering::Less, - (None, Some(_)) => std::cmp::Ordering::Greater, - (None, None) => std::cmp::Ordering::Equal, - }; - folders.sort_by( - |(left, (updated_left, created_left, _)), - (right, (updated_right, created_right, _))| { - let order = match self.view.sort { - SidebarSort::Name => direction(names[*left].cmp(&names[*right])), - SidebarSort::Created => dated(*created_left, *created_right), - SidebarSort::Updated => dated(*updated_left, *updated_right), - }; - order - .then_with(|| names[*left].cmp(&names[*right])) - .then_with(|| { - self.switcher_roots[*left] - .root - .cmp(&self.switcher_roots[*right].root) - }) - }, - ); - } - for (position, (index, _)) in folders.iter().enumerate() { - self.switcher_roots[*index].folder_position = position; - } + groups.sort_by_key(|(index, _)| { + self.project_order_rank(&self.project_roots[*index].target) + }); sections.extend( - folders + groups .into_iter() - .map(|(index, (_, _, rows))| (SidebarSection::Folder(index), rows)), + .map(|(index, rows)| (SidebarSection::Project(index), rows)), ); } } self.pinned_rows = pinned; self.sections = sections; self.archived_rows = archived; - self.rebuild_computer_switcher_roots(); + self.rebuild_computer_project_roots(); + self.sync_container_order(); self.rebuild_entries(); } @@ -1713,13 +1653,8 @@ impl Sidebar { self.running.contains(&session.id) || session.state != AgentTaskState::Settled } - /// Flatten the sections into list rows. The list splices only the - /// span that changed, so the scroll position and the measured - /// heights of untouched rows survive every rebuild. - fn rebuild_entries(&mut self) { - self.hovered_entry = None; - self.hovered_section = None; - self.folder_drop = None; + /// Project bounded local-cache rows through the selected grouping. + fn local_entries(&self) -> Vec { let mut entries = vec![SidebarEntry::NewTask, SidebarEntry::Search]; let empty_known = self.filter.is_empty() && self.pages.non_archived_complete(); let pinned_roots: HashSet<&str> = self @@ -1728,17 +1663,19 @@ impl Sidebar { .map(|&ix| self.sessions[ix].project_root.as_str()) .collect(); let tasks = |entries: &mut Vec, section: SidebarSection, rows: &[usize]| { - if rows.is_empty() && !matches!(section, SidebarSection::Folder(_)) { + if rows.is_empty() && !matches!(section, SidebarSection::Project(_)) { return; } - entries.push(SidebarEntry::SectionLabel(section)); + if section != SidebarSection::List { + entries.push(SidebarEntry::SectionLabel(section)); + } if self.section_expanded(section) { if rows.is_empty() && empty_known - && let SidebarSection::Folder(index) = section - && !pinned_roots.contains(self.switcher_roots[index].root.as_str()) + && let SidebarSection::Project(index) = section + && !pinned_roots.contains(self.project_roots[index].root.as_str()) { - entries.push(SidebarEntry::FolderEmpty(index)); + entries.push(SidebarEntry::ProjectEmpty(index)); } entries.extend(rows.iter().map(|&session| { SidebarEntry::Task(SidebarTaskEntry { @@ -1752,43 +1689,33 @@ impl Sidebar { })); } }; - if self.computers.len() > 1 { - entries.push(SidebarEntry::ProjectsHeader); - tasks(&mut entries, SidebarSection::Pinned, &self.pinned_rows); + // Pinned work sits above the Projects header, which folds the rest. + tasks(&mut entries, SidebarSection::Pinned, &self.pinned_rows); + if self.section_expanded(SidebarSection::Pinned) && self.pages.has_more(PageKind::Pinned) { + entries.push(SidebarEntry::LoadMore(PageKind::Pinned)); + } + entries.push(SidebarEntry::ProjectsHeader); + if self.projects_expanded { for (section, rows) in &self.sections { tasks(&mut entries, *section, rows); } - } else { - // Pinned work sits above the Projects header, which folds the rest. - tasks(&mut entries, SidebarSection::Pinned, &self.pinned_rows); - if self.section_expanded(SidebarSection::Pinned) - && self.pages.has_more(PageKind::Pinned) - { - entries.push(SidebarEntry::LoadMore(PageKind::Pinned)); - } - entries.push(SidebarEntry::ProjectsHeader); - if self.projects_expanded { - for (section, rows) in &self.sections { - tasks(&mut entries, *section, rows); - } - if self.pages.enabled { - for (kind, expanded) in [ - ( - PageKind::Active, - self.view.group != SidebarGroup::Status - || self.section_expanded(SidebarSection::Active), - ), - ( - PageKind::Settled, - self.view.group != SidebarGroup::Status - || self.section_expanded(SidebarSection::Settled), - ), - (PageKind::Search, !self.filter.is_empty()), - (PageKind::Projects, true), - ] { - if expanded && self.pages.has_more(kind) { - entries.push(SidebarEntry::LoadMore(kind)); - } + if self.pages.enabled { + for (kind, expanded) in [ + ( + PageKind::Active, + self.view.group != SidebarGroup::Status + || self.section_expanded(SidebarSection::Active), + ), + ( + PageKind::Settled, + self.view.group != SidebarGroup::Status + || self.section_expanded(SidebarSection::Settled), + ), + (PageKind::Search, !self.filter.is_empty()), + (PageKind::Projects, true), + ] { + if expanded && self.pages.has_more(kind) { + entries.push(SidebarEntry::LoadMore(kind)); } } } @@ -1807,43 +1734,15 @@ impl Sidebar { })); } } - if self.computers.len() > 1 && self.pages.enabled { - for (kind, expanded) in [ - ( - PageKind::Pinned, - self.section_expanded(SidebarSection::Pinned), - ), - ( - PageKind::Active, - self.section_expanded(SidebarSection::Active), - ), - ( - PageKind::Settled, - self.section_expanded(SidebarSection::Settled), - ), - (PageKind::Archived, self.archived_expanded), - (PageKind::Search, !self.filter.is_empty()), - ] { - if expanded && self.pages.has_more(kind) { - entries.push(SidebarEntry::LoadMore(kind)); - } - } - } else if self.archived_expanded && self.pages.has_more(PageKind::Archived) { + if self.archived_expanded && self.pages.has_more(PageKind::Archived) { entries.push(SidebarEntry::LoadMore(PageKind::Archived)); } // Folded sections still hold tasks; only an empty list says so. - let empty = if self.computers.len() > 1 { - !entries.iter().any(|entry| { - matches!(entry, SidebarEntry::Task(_)) - || (!self.pages.enabled && matches!(entry, SidebarEntry::ArchivedHeader)) - }) - } else { - self.pinned_rows.is_empty() - && self.archived_rows.is_empty() - && self.sections.iter().all(|(section, rows)| { - !matches!(section, SidebarSection::Folder(_)) && rows.is_empty() - }) - }; + let empty = self.pinned_rows.is_empty() + && self.archived_rows.is_empty() + && self.sections.iter().all(|(section, rows)| { + !matches!(section, SidebarSection::Project(_)) && rows.is_empty() + }); if empty && !self.pages.any_loading() { entries.push(SidebarEntry::Empty(if self.filter.is_empty() { SidebarEmpty::NoTasks @@ -1851,31 +1750,21 @@ impl Sidebar { SidebarEmpty::NoMatches })); } - if self.computers.len() > 1 && self.project_filter.is_none() { - entries = self.computer_entries(); - } - // Cache whole-folder boundaries, including expanded/empty task rows. - // Drag feedback must sit between projects without changing row heights. - let mut folder = None; - self.folder_rows = entries - .iter() - .enumerate() - .map(|(row, entry)| { - match *entry { - SidebarEntry::SectionLabel(SidebarSection::Folder(index)) => { - folder = Some(index); - self.switcher_roots[index].folder_start = row; - } - SidebarEntry::FolderEmpty(index) => folder = Some(index), - SidebarEntry::Task(task) if !task.archived => {} - _ => folder = None, - } - if let Some(index) = folder { - self.switcher_roots[index].folder_end = row; - } - folder - }) - .collect(); + entries + } + + /// Flatten the sections into list rows. The list splices only the + /// span that changed, so the scroll position and the measured + /// heights of untouched rows survive every rebuild. + fn rebuild_entries(&mut self) { + self.hovered_entry = None; + self.hovered_section = None; + self.drop_target = None; + let mut entries = if self.uses_computer_projection() { + self.computer_entries() + } else { + self.local_entries() + }; for ix in 0..entries.len() { let last = !matches!(entries.get(ix + 1), Some(SidebarEntry::Task(task)) if !task.archived); @@ -1914,6 +1803,7 @@ impl Sidebar { .collect(); let old_gaps = std::mem::replace(&mut self.heading_gaps, heading_gaps); let old = std::mem::replace(&mut self.entries, entries); + self.rebuild_reorder_rows(); if self.application_vim_enabled { self.rebuild_vim_targets(); } @@ -1964,16 +1854,10 @@ impl Sidebar { .get(task.session) .is_some_and(|session| session.id == *id)) }), - RenameTarget::Project(target) if self.computers.len() <= 1 => { - self.entries.iter().position(|entry| { - matches!(entry, SidebarEntry::SectionLabel(SidebarSection::Folder(index)) - if self.switcher_roots.get(*index).is_some_and(|root| root.target == *target)) - }) - } - RenameTarget::Project(_) => self - .entries - .iter() - .position(|entry| matches!(entry, SidebarEntry::ProjectsHeader)), + RenameTarget::Project(target) => self + .drag_ranges + .get(&SidebarDragItem::Project(target.clone())) + .map(|(first, _)| *first), }; if let Some(row) = row { self.list.remeasure_items(row..row + 1); @@ -1983,10 +1867,17 @@ impl Sidebar { /// Rebuild the per-session strings when the session list moved under /// them; a plain filter change reuses them. fn sync_rows(&mut self) { + let connection = self + .computers + .iter() + .find(|computer| computer.key.as_deref() == self.backend.remote_host()) + .filter(|_| self.uses_computer_projection()) + .map_or("", |computer| computer.label.as_ref()); let fresh = self.rows.len() == self.sessions.len() && self.rows.iter().zip(&self.sessions).all(|(row, session)| { *row.id == *session.id && row.title.as_ref() == session.title + && row.connection_name.as_ref() == connection && self.root_name_matches(&session.project_root, &row.project_name) }); if fresh { @@ -1995,7 +1886,9 @@ impl Sidebar { self.rows = self .sessions .iter() - .map(|session| SidebarRow::build(session, &self.root_name(&session.project_root))) + .map(|session| { + SidebarRow::build(session, &self.root_name(&session.project_root), connection) + }) .collect(); } @@ -2109,10 +2002,10 @@ impl Sidebar { /// Whether a collapsible section shows its rows. fn section_expanded(&self, section: SidebarSection) -> bool { match section { - SidebarSection::Folder(index) => self - .switcher_roots + SidebarSection::Project(index) => self + .project_roots .get(index) - .is_none_or(|root| !self.collapsed_roots.contains(&root.root)), + .is_none_or(|root| !self.collapsed_projects.contains(&root.target)), section => !self.collapsed.contains(§ion), } } @@ -2127,14 +2020,18 @@ impl Sidebar { expanded: bool, cx: &mut Context, ) { - if let SidebarSection::Folder(index) = section { - let Some(root) = self.switcher_roots.get(index).map(|root| root.root.clone()) else { + if let SidebarSection::Project(index) = section { + let Some(root) = self + .project_roots + .get(index) + .map(|root| root.target.clone()) + else { return; }; if expanded { - self.collapsed_roots.remove(&root); + self.collapsed_projects.remove(&root); } else { - self.collapsed_roots.insert(root); + self.collapsed_projects.insert(root); } } else if expanded { self.collapsed.remove(§ion); @@ -2146,20 +2043,16 @@ impl Sidebar { } /// Show or fold one folder group; `None` flips it. - pub(super) fn set_folder_expanded( + pub(super) fn set_project_expanded( &mut self, root: &str, expanded: Option, cx: &mut Context, ) { - let Some(index) = self - .switcher_roots - .iter() - .position(|item| item.root == root) - else { + let Some(index) = self.project_roots.iter().position(|item| item.root == root) else { return; }; - let section = SidebarSection::Folder(index); + let section = SidebarSection::Project(index); let expanded = expanded.unwrap_or(!self.section_expanded(section)); self.set_section_expanded(section, expanded, cx); } @@ -2173,341 +2066,22 @@ impl Sidebar { } } - /// Whether task rows show their project line. + /// Project context is supplied by a project header in grouped views. fn show_metadata(&self) -> bool { - self.view - .metadata - .unwrap_or(self.view.group != SidebarGroup::Folder) - } - - fn manual_folder_mode(&self) -> bool { - self.computers.len() <= 1 - && self.view.group == SidebarGroup::Folder - && self.view.manual_folders - } - - fn can_reorder_folders(&self) -> bool { - self.manual_folder_mode() - && self.folder_order_loaded - && self.filter.is_empty() - && self.project_filter.is_none() - && self.rename.is_none() - } - - fn set_folder_drop(&mut self, next: Option, cx: &mut Context) { - if self.folder_drop != next { - self.folder_drop = next; - cx.notify(); - } - } - - fn finish_folder_drag(&mut self, cx: &mut Context) { - if self.folder_dragging || self.folder_drop.is_some() { - self.folder_dragging = false; - self.folder_drop = None; - cx.notify(); - } - } - - pub(super) fn cancel_folder_drag( - &mut self, - window: &mut Window, - cx: &mut Context, - ) -> bool { - if !self.folder_dragging { - return false; - } - cx.stop_active_drag(window); - self.finish_folder_drag(cx); - true - } - - fn update_folder_drop( - &mut self, - row: usize, - position: gpui::Point, - bounds: gpui::Bounds, - drag: &DraggedFolder, - cx: &mut Context, - ) { - let next = if drag.sidebar == cx.entity_id() - && self.can_reorder_folders() - && bounds.contains(&position) - { - self.folder_rows - .get(row) - .copied() - .flatten() - .and_then(|index| { - let root = &self.switcher_roots[index]; - let source = self - .switcher_roots - .iter() - .find(|root| root.target == drag.target)?; - let header = matches!( - self.entries.get(row), - Some(SidebarEntry::SectionLabel(SidebarSection::Folder(_))) - ); - let after = !header || position.y >= bounds.center().y; - let insertion = root.folder_position + usize::from(after); - if root.target == drag.target - || insertion == source.folder_position - || insertion == source.folder_position + 1 - { - return None; - } - Some(FolderDrop { - target: root.target.clone(), - after, - hover_row: row, - line_row: if after { - root.folder_end - } else { - root.folder_start - }, - }) - }) - } else { - None - }; - self.set_folder_drop(next, cx); - } - - fn load_folder_order(&mut self, cx: &mut Context) { - if self.folder_order_loaded || self.folder_order_loading { - return; - } - self.folder_order_loading = true; - let backend = self.backend.clone(); - let user = self.user_id.clone(); - let computer = backend.remote_host().map(str::to_owned); - self.call( - async move { backend.get_sidebar_folder_order(&user, computer).await }, - cx, - |this, result, cx| { - if this.folder_order_loaded { - return; - } - this.folder_order_loading = false; - match result { - Ok(order) => { - let reset = std::mem::take(&mut this.folder_order_reset_pending); - this.folder_order = if reset { Vec::new() } else { order }; - this.folder_order_loaded = true; - let seed = this.manual_folder_mode() && this.folder_order.is_empty(); - if seed { - this.seed_folder_order(); - } - this.rebuild_sections(); - if seed || reset { - this.save_folder_order(cx); - } - cx.notify(); - } - Err(message) => cx.emit(SidebarEvent::Notice(message.into())), - } - }, - ); - } - - fn seed_folder_order(&mut self) { - self.folder_order = self - .sections - .iter() - .filter_map(|(section, _)| match section { - SidebarSection::Folder(index) => Some(self.switcher_roots[*index].root.clone()), - _ => None, - }) - .collect(); - } - - fn persist_folder_order(&self) { - if self.folder_order_loaded { - // The shared settings writer owns retries and the normal Quit - // barrier, so a screen change cannot discard this presentation edit. - drop(crate::settings::save_sidebar_folder_order( - self.backend.clone(), - self.user_id.clone(), - self.backend.remote_host().map(str::to_owned), - self.folder_order.clone(), - )); - } - } - - fn save_folder_order(&self, cx: &mut Context) { - let saved = crate::settings::save_sidebar_folder_order( - self.backend.clone(), - self.user_id.clone(), - self.backend.remote_host().map(str::to_owned), - self.folder_order.clone(), - ); - self.call( - async move { - saved - .await - .map_err(|_| "Could not save folder order".to_string())? - }, - cx, - |_, result, cx| { - if let Err(message) = result { - cx.emit(SidebarEvent::Notice(message.into())); - } - }, - ); - } - - /// Move only the two current folders' presentation slots. Resolve roots at - /// drop time so paging or a metadata refresh cannot retarget a stale index. - fn reorder_folder( - &mut self, - source: &ProjectTarget, - target: &ProjectTarget, - after: bool, - cx: &mut Context, - ) { - if !self.can_reorder_folders() - || source == target - || source.computer.as_deref() != self.backend.remote_host() - || target.computer.as_deref() != self.backend.remote_host() - || ![source, target].iter().all(|target| { - self.switcher_roots - .iter() - .any(|root| &root.target == *target) - }) - { - return; - } - let Some(from) = self - .folder_order - .iter() - .position(|root| root == &source.path) - else { - return; - }; - let Some(to) = self - .folder_order - .iter() - .position(|root| root == &target.path) - else { - return; - }; - let insertion = to + usize::from(after); - let insertion = insertion - usize::from(from < insertion); - if insertion == from { - return; - } - let moved = self.folder_order.remove(from); - self.folder_order.insert(insertion, moved); - self.close_popups(cx); - self.save_folder_order(cx); - self.rebuild_sections(); - cx.notify(); - } - - fn move_folder(&mut self, target: &ProjectTarget, forward: bool, cx: &mut Context) { - if !self.can_reorder_folders() { - return; - } - let roots: Vec<_> = self - .sections - .iter() - .filter_map(|(section, _)| match section { - SidebarSection::Folder(index) => Some(self.switcher_roots[*index].target.clone()), - _ => None, - }) - .collect(); - let Some(position) = roots.iter().position(|root| root == target) else { - return; - }; - let adjacent = if forward { - roots.get(position + 1) - } else { - position - .checked_sub(1) - .and_then(|position| roots.get(position)) - }; - if let Some(adjacent) = adjacent { - self.reorder_folder(target, adjacent, forward, cx); - } - } - - fn reset_view(&mut self, cx: &mut Context) { - self.folder_order.clear(); - self.folder_order_reset_pending = !self.folder_order_loaded; - self.view = SidebarView::default(); - self.rebuild_sections(); - self.list.remeasure_items(0..self.entries.len()); - self.save_folder_order(cx); - self.load_folder_order(cx); - crate::settings::update_settings_in_background(|settings| { - settings.sidebar_view = SidebarView::default(); - }); - cx.notify(); - } - - /// Change the view, keep it in the app settings, and rebuild what it - /// shapes. The project line changes row heights, so every row is - /// measured again. - pub(super) fn set_view(&mut self, view: SidebarView, cx: &mut Context) { - if self.view == view { - if self.manual_folder_mode() { - self.load_folder_order(cx); - if self.folder_order_loaded { - self.save_folder_order(cx); - } - } - return; - } - if view.group == SidebarGroup::Folder - && view.manual_folders - && self.folder_order_loaded - && self.folder_order.is_empty() - { - // Enter manual ordering without moving the folders already on screen. - self.seed_folder_order(); - } - self.view = view; - self.rebuild_sections(); - self.list.remeasure_items(0..self.entries.len()); - cx.notify(); - if self.manual_folder_mode() { - self.load_folder_order(cx); - if self.folder_order_loaded { - self.save_folder_order(cx); - } - } - crate::settings::update_settings_in_background(move |settings| { - settings.sidebar_view = view; - }); + matches!(self.view.group, SidebarGroup::Status | SidebarGroup::List) } /// Open or close the Projects header's menu from its button. pub(super) fn toggle_header_menu(&mut self, cx: &mut Context) { self.project_popup.close(cx); - self.popup.toggle(self.header_popup(), cx); + self.popup.toggle(SidebarPopup::View, cx); } - /// Open or close the Projects header's menu from the keyboard. - /// Opening the switcher highlights the current scope, like a native - /// popup button. + /// Keyboard and pointer open the same sidebar preferences menu. pub(super) fn set_header_menu_open(&mut self, open: bool, cx: &mut Context) { - if open == self.header_menu_open() { - return; - } self.project_popup.close(cx); - if open && self.header_popup() == SidebarPopup::View { + if open { self.popup.open(SidebarPopup::View, cx); - } else if open { - let current = self - .project_filter - .as_deref() - .and_then(|filter| self.switcher_roots.iter().find(|root| root.root == filter)) - .map_or_else( - || SharedString::from(ALL_PROJECTS_ID), - |root| root.row_id.clone(), - ); - self.popup - .open_highlighted(SidebarPopup::Switcher, current, cx); } else { self.popup.close(cx); } @@ -2517,9 +2091,7 @@ impl Sidebar { pub(super) fn set_project_filter(&mut self, root: Option, cx: &mut Context) { self.project_filter = root.filter(|root| !root.is_empty()); self.project_popup.close(cx); - if self.popup.is_open(&SidebarPopup::Switcher) { - self.popup.close(cx); - } + self.popup.close(cx); if self.pages.enabled { self.refresh_pages(cx); return; @@ -2554,8 +2126,6 @@ impl Sidebar { let chat = self.chat.clone(); let sidebar = cx.entity().downgrade(); let escape = sidebar.clone(); - // Switcher renames keep the menu's keyboard focus. - let in_switcher = self.renames_in_switcher(&target); let application_vim_enabled = self.application_vim_enabled; let input = cx.new(move |cx| { let mut input = TextInput::new("Name", cx) @@ -2567,27 +2137,16 @@ impl Sidebar { input.select_all_text(cx); input .on_application_escape(move |window, cx| { - if in_switcher { - let escape = escape.clone(); - cx.defer(move |cx| { - if let Some(sidebar) = escape.upgrade() { - sidebar.update(cx, |sidebar, cx| sidebar.cancel_rename(cx)); - } - }); - } else { - // Escape discards: cancel before focus moves, or - // the field losing focus would commit the rename. - let escape = escape.clone(); - let chat = chat.clone(); - window.defer(cx, move |window, cx| { - if let Some(sidebar) = escape.upgrade() { - sidebar.update(cx, |sidebar, cx| sidebar.cancel_rename(cx)); - } - if let Some(chat) = chat.upgrade() { - chat.update(cx, |chat, cx| chat.focus_application_vim(window, cx)); - } - }); - } + let escape = escape.clone(); + let chat = chat.clone(); + window.defer(cx, move |window, cx| { + if let Some(sidebar) = escape.upgrade() { + sidebar.update(cx, |sidebar, cx| sidebar.cancel_rename(cx)); + } + if let Some(chat) = chat.upgrade() { + chat.update(cx, |chat, cx| chat.focus_application_vim(window, cx)); + } + }); }) .on_enter(move |_text, _, cx| { let sidebar = sidebar.clone(); @@ -2608,13 +2167,6 @@ impl Sidebar { cx.notify(); } - /// Whether a rename's field sits in the switcher menu: a project's, - /// while paired computers list their projects there. A folder - /// header's field ends the way a task's does. - fn renames_in_switcher(&self, target: &RenameTarget) -> bool { - matches!(target, RenameTarget::Project(_)) && self.computers.len() > 1 - } - /// Drop the rename field. Reports whether one was open. pub(super) fn cancel_rename(&mut self, cx: &mut Context) -> bool { let Some(target) = self.rename.take() else { @@ -2624,14 +2176,11 @@ impl Sidebar { true } - /// Restore the row's height and, for switcher renames, menu focus. + /// Restore the inline row's height. fn end_rename(&mut self, target: &RenameTarget, cx: &mut Context) { self.rename_input = None; self.rename_focus_out = None; self.remeasure(target); - if self.renames_in_switcher(target) { - self.popup.refocus(cx); - } cx.notify(); } @@ -2939,7 +2488,7 @@ impl Sidebar { .map(|task| TaskRung::of(task).step().row_label) } - /// The menu of one project, on a switcher row or a folder header. + /// The actions of one project header, scoped to its execution computer. fn project_menu_items(&self, target: &ProjectTarget) -> Vec { let rename_target = target.clone(); let root = &target.path; @@ -2951,35 +2500,18 @@ impl Sidebar { this.begin_rename(RenameTarget::Project(rename_target.clone()), cx) }), }]; - if self.can_reorder_folders() - && let Some(root) = self - .switcher_roots - .iter() - .find(|root| &root.target == target) - { - for (forward, available, id, icon, label) in [ - ( - false, - root.folder_position > 0 && root.folder_position != usize::MAX, - "move-project-up", - "arrow-up", - "Move up", - ), - ( - true, - root.folder_position.saturating_add(1) < self.sections.len(), - "move-project-down", - "chevron-down", - "Move down", - ), + if self.can_reorder() { + for (forward, id, icon, label) in [ + (false, "move-project-up", "arrow-up", "Move up"), + (true, "move-project-down", "chevron-down", "Move down"), ] { - if available { + if self.project_move_available(target, forward) { let target = target.clone(); items.push(SidebarMenuItem { id: target.element_id(id), icon, label, - on_click: Box::new(move |this, cx| this.move_folder(&target, forward, cx)), + on_click: Box::new(move |this, cx| this.move_project(&target, forward, cx)), }); } } @@ -3058,13 +2590,13 @@ impl Sidebar { SidebarEntry::NewTask => Some(SidebarTarget::NewTask), SidebarEntry::Search => Some(SidebarTarget::Search), SidebarEntry::ProjectsHeader => Some(SidebarTarget::Projects), - SidebarEntry::SectionLabel(SidebarSection::Folder(index)) => self - .switcher_roots + SidebarEntry::SectionLabel(SidebarSection::Project(index)) => self + .project_roots .get(index) - .map(|root| SidebarTarget::Folder(root.root.clone())), + .map(|root| SidebarTarget::Project(root.root.clone())), SidebarEntry::SectionLabel(_) | SidebarEntry::Empty(_) - | SidebarEntry::FolderEmpty(_) => None, + | SidebarEntry::ProjectEmpty(_) => None, SidebarEntry::LoadMore(kind) => { Some(SidebarTarget::LoadMore(format!("page:{}", kind.label()))) } @@ -3181,12 +2713,9 @@ impl Sidebar { /// Fold or unfold the selected target where that means something. pub(super) fn vim_set_expanded(&mut self, expanded: bool, cx: &mut Context) { match self.vim_selected.clone() { - Some(SidebarTarget::Projects) if self.computers.len() > 1 => { - self.set_header_menu_open(expanded, cx) - } Some(SidebarTarget::Projects) => self.set_projects_expanded(expanded, cx), - Some(SidebarTarget::Folder(root)) => { - self.set_folder_expanded(&root, Some(expanded), cx) + Some(SidebarTarget::Project(root)) => { + self.set_project_expanded(&root, Some(expanded), cx) } Some(SidebarTarget::Archived) => self.set_archived_expanded(expanded, cx), _ => {} @@ -3300,7 +2829,7 @@ impl Sidebar { | SidebarEntry::OtherTask(_) | SidebarEntry::Computer(_) | SidebarEntry::ComputerMore(_)), - ) => self.render_computer_entry(entry, self.vim_selects_row(ix), cx), + ) => self.render_computer_entry(entry, self.vim_selects_row(ix), window, cx), Some(SidebarEntry::LoadMore(kind)) => self.render_load_more(kind, cx), Some(SidebarEntry::Search) if self.search_visible => self.render_search(cx), Some(entry @ (SidebarEntry::NewTask | SidebarEntry::Search)) => { @@ -3365,8 +2894,8 @@ impl Sidebar { .into_any_element() } Some(SidebarEntry::ProjectsHeader) => self.render_projects_header(window, cx), - Some(SidebarEntry::FolderEmpty(index)) => { - let id = self.switcher_roots[index].empty_id.clone(); + Some(SidebarEntry::ProjectEmpty(index)) => { + let id = self.project_roots[index].empty_id.clone(); div() .id(id.clone()) .w_full() @@ -3389,8 +2918,8 @@ impl Sidebar { .child(row) .into_any_element() } - Some(SidebarEntry::SectionLabel(SidebarSection::Folder(index))) => { - self.render_folder_header(index, window, cx) + Some(SidebarEntry::SectionLabel(SidebarSection::Project(index))) => { + self.render_local_project_header(index, window, cx) } Some(SidebarEntry::SectionLabel(section)) => { let expanded = self.section_expanded(section); @@ -3464,13 +2993,13 @@ impl Sidebar { } Some(SidebarEntry::ArchivedHeader) => { let expanded = self.archived_expanded; - let count = if self.computers.len() > 1 && self.project_filter.is_none() { + let count = if self.uses_computer_projection() && self.project_filter.is_none() { self.computer_archived_count } else { self.archived_rows.len() }; let count_label = if self.pages.enabled && self.project_filter.is_some() - || (self.pages.enabled && self.computers.len() <= 1) + || (self.pages.enabled && !self.uses_computer_projection()) { if self.pages.has_more(PageKind::Archived) { format!("{count}+") @@ -3536,25 +3065,46 @@ impl Sidebar { .application_vim_enabled .then(|| self.vim_target(ix).cloned()) .flatten(); - let folder_drop_enabled = - self.can_reorder_folders() && self.folder_rows.get(ix).copied().flatten().is_some(); + let drag = self.drag_for_row(ix, cx.entity_id()); + let drop_row = self.reorder_rows.get(ix).cloned().unwrap_or_default(); let insertion = self - .folder_drop + .drop_target .as_ref() - .filter(|drop| self.folder_dragging && cx.has_active_drag() && drop.line_row == ix) + .filter(|drop| self.dragging && cx.has_active_drag() && drop.line_row == ix) .map(|drop| drop.after); let sidebar_id = cx.entity_id(); + let grouping = self.view.group; + let owner = cx.weak_entity(); let chat = self.chat.clone(); div() .id(("sidebar-entry", ix as u64)) .relative() .w_full() - .when(folder_drop_enabled, |row| { + .when_some(drag, |row, drag| { + row.on_drag(drag, move |drag, _, _, cx| { + let preview = cx.new(|_| drag.clone()); + owner + .update(cx, |this, cx| { + this.dragging = true; + this.drop_target = None; + this.close_popups(cx); + this.set_hovered_entry(None, cx); + cx.observe_release(&preview, |this, _, cx| { + this.finish_sidebar_drag(cx) + }) + .detach(); + cx.notify(); + }) + .ok(); + preview + }) + }) + .when(self.can_reorder(), |row| { row.on_drag_move(cx.listener( - move |this, event: &gpui::DragMoveEvent, _, cx| { + move |this, event: &gpui::DragMoveEvent, _, cx| { if event.bounds.contains(&event.event.position) { let drag = event.drag(cx).clone(); - this.update_folder_drop( + this.update_drop_target( ix, event.event.position, event.bounds, @@ -3562,31 +3112,31 @@ impl Sidebar { cx, ); } else if this - .folder_drop + .drop_target .as_ref() .is_some_and(|drop| drop.hover_row == ix) { - this.set_folder_drop(None, cx); + this.set_drop_target(None, cx); } }, )) .can_drop(move |value, _, _| { - value - .downcast_ref::() - .is_some_and(|drag| drag.sidebar == sidebar_id) + value.downcast_ref::().is_some_and(|drag| { + drag.sidebar == sidebar_id && drop_row.target(drag, grouping).is_some() + }) }) .on_drop(cx.listener( - move |this, drag: &DraggedFolder, window, cx| { + move |this, drag: &SidebarDrag, window, cx| { if drag.sidebar != cx.entity_id() { return; } if let Some(bounds) = this.list.bounds_for_item(ix) { - this.update_folder_drop(ix, window.mouse_position(), bounds, drag, cx); - if let Some(drop) = this.folder_drop.take() { - this.reorder_folder(&drag.target, &drop.target, drop.after, cx); + this.update_drop_target(ix, window.mouse_position(), bounds, drag, cx); + if let Some(drop) = this.drop_target.take() { + this.commit_reorder(drag, &drop.target, drop.after, cx); } } - this.finish_folder_drag(cx); + this.finish_sidebar_drag(cx); }, )) }) @@ -3693,9 +3243,6 @@ impl Sidebar { /// The Projects header: its label folds the list under it, and its /// buttons open the View options and add a project. fn render_projects_header(&self, window: &mut Window, cx: &mut Context) -> AnyElement { - if self.computers.len() > 1 { - return self.render_switcher_header(window, cx); - } let open = self.popup.is_open(&SidebarPopup::View); let menu = open.then(|| { self.popup @@ -3705,10 +3252,10 @@ impl Sidebar { cx.listener(|_this, _event, _window, cx| cx.emit(SidebarEvent::ChooseProject)), ); let expanded = self.projects_expanded; - let label = if self.view.group == SidebarGroup::Folder { - "Projects" - } else { - "Tasks" + let label = match self.view.group { + SidebarGroup::Project => "Projects", + SidebarGroup::Connection => "Connections", + SidebarGroup::Status | SidebarGroup::List => "Tasks", }; div() .group("projects-header-actions") @@ -3769,138 +3316,84 @@ impl Sidebar { }) } - /// The View options menu, with each set of choices in its own submenu. + /// Grouping and task order are independent sidebar preferences. fn view_menu(&self, window: &mut Window, cx: &mut Context) -> Menu { - use ViewSubmenu::{Group, Show, Sort}; - let view = self.view; - let manual = view.group == SidebarGroup::Folder && view.manual_folders; - let choice = |id: &'static str, label: &'static str, current: bool, next: SidebarView| { - MenuItem::new(id, label, move |this: &mut Self, _: &mut Window, cx| { - this.set_view(next, cx) - }) - .current(current) - }; - let mut group_menu = Menu::new("view-group-menu", px(220.)) - .label("Group by") + let mut groups = Menu::new("sidebar-group-menu", px(220.)) + .label("Group tasks by") .application_vim(self.application_vim_enabled); for (id, label, group) in [ - ("view-group-folder", "Folder", SidebarGroup::Folder), - ("view-group-status", "Status", SidebarGroup::Status), - ("view-group-recent", "Recent", SidebarGroup::Recent), + ("group-project", "Project", SidebarGroup::Project), + ("group-connection", "Connection", SidebarGroup::Connection), + ("group-status", "Status", SidebarGroup::Status), + ("group-list", "In one list", SidebarGroup::List), ] { - group_menu = group_menu.item(choice( - id, - label, - view.group == group, - SidebarView { group, ..view }, - )); + if group == SidebarGroup::Connection && !self.connection_grouping_available() { + continue; + } + groups = groups.item( + MenuItem::new(id, label, move |this: &mut Self, _, cx| { + this.set_view(SidebarView { group, ..view }, cx); + }) + .current(view.group == group), + ); } - let mut sort_menu = Menu::new("view-sort-menu", px(220.)) - .label("Sort by") + let mut sorts = Menu::new("sidebar-sort-menu", px(220.)) + .label("Sort tasks by") .application_vim(self.application_vim_enabled); for (id, label, sort) in [ - ("view-sort-updated", "Last updated", SidebarSort::Updated), - ("view-sort-created", "Date created", SidebarSort::Created), - ("view-sort-name", "Name", SidebarSort::Name), + ("sort-updated", "Last updated", SidebarSort::Updated), + ("sort-manual", "Manual order", SidebarSort::Manual), ] { - sort_menu = sort_menu.item(choice( - id, - label, - !manual && view.sort == sort, - SidebarView { - sort, - manual_folders: false, - ..view - }, - )); - } - if view.group == SidebarGroup::Folder { - sort_menu = sort_menu.item(choice( - "view-sort-manual", - "Manually", - manual, - SidebarView { - manual_folders: true, - ..view - }, - )); - } - if !manual { - sort_menu = sort_menu.separator(); - for (id, label, ascending) in [ - ("view-sort-descending", "Descending", false), - ("view-sort-ascending", "Ascending", true), - ] { - sort_menu = sort_menu.item(choice( - id, - label, - view.ascending == ascending, - SidebarView { ascending, ..view }, - )); - } + sorts = sorts.item( + MenuItem::new(id, label, move |this: &mut Self, _, cx| { + this.set_view(SidebarView { sort, ..view }, cx); + }) + .current(view.sort == sort) + .enabled(self.order_loaded || sort == SidebarSort::Updated), + ); } - let metadata = self.show_metadata(); - let show_menu = Menu::new("view-show-menu", px(220.)) - .label("Show") - .application_vim(self.application_vim_enabled) - .item(choice( - "view-show-metadata", - "Metadata", - metadata, - SidebarView { - metadata: Some(!metadata), - ..view - }, - )); - Menu::new("view-menu", px(220.)) - .label("View options") + Menu::new("sidebar-view-menu", px(240.)) + .label("Sidebar") .application_vim(self.application_vim_enabled) .item( self.view_popup - .submenu(Group, "view-group", "Group by", group_menu, window, cx) + .submenu( + ViewSubmenu::Group, + "sidebar-group", + "Group tasks by", + groups, + window, + cx, + ) .value(match view.group { - SidebarGroup::Folder => "Folder", + SidebarGroup::Project => "Project", + SidebarGroup::Connection => "Connection", SidebarGroup::Status => "Status", - SidebarGroup::Recent => "Recent", + SidebarGroup::List => "In one list", }), ) .item( self.view_popup - .submenu(Sort, "view-sort", "Sort by", sort_menu, window, cx) - .value(if manual { - "Manually" - } else { - match view.sort { - SidebarSort::Updated => "Last updated", - SidebarSort::Created => "Date created", - SidebarSort::Name => "Name", - } + .submenu( + ViewSubmenu::Sort, + "sidebar-sort", + "Sort tasks by", + sorts, + window, + cx, + ) + .value(match view.sort { + SidebarSort::Updated => "Last updated", + SidebarSort::Manual => "Manual order", }), ) - .item( - self.view_popup - .submenu(Show, "view-show", "Show", show_menu, window, cx), - ) - .separator() - .item( - MenuItem::new( - "view-reset", - "Reset to defaults", - |this: &mut Self, _, cx| this.reset_view(cx), - ) - .enabled( - view != SidebarView::default() - || !self.folder_order.is_empty() - || self.folder_order_reset_pending, - ), - ) } /// Add project stays visible until there is a project to browse. fn new_project_button(&self) -> gpui::Stateful
{ header_button("new-project", "folder-plus", "New project", false).when( - !self.switcher_roots.is_empty(), + !self.project_roots.is_empty(), |button| { button .opacity(0.) @@ -3911,17 +3404,34 @@ impl Sidebar { /// A folder group's header: the project's name, which folds its tasks, /// with its overflow menu and a new task in it on hover. - fn render_folder_header( + fn render_local_project_header( &self, index: usize, window: &mut Window, cx: &mut Context, ) -> AnyElement { - let Some(root) = self.switcher_roots.get(index) else { + let Some(root) = self.project_roots.get(index) else { return div().into_any_element(); }; - let section = SidebarSection::Folder(index); - let expanded = self.section_expanded(section); + self.render_project_header(root, window, cx) + } + + fn render_project_header( + &self, + root: &ProjectRow, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let expanded = !self.collapsed_projects.contains(&root.target); + let toggle_target = root.target.clone(); + let context = (self.view.group == SidebarGroup::Project && root.target.computer.is_some()) + .then(|| { + self.computers + .iter() + .find(|computer| computer.key == root.target.computer) + .map(|computer| computer.label.clone()) + }) + .flatten(); let rename_field = self.project_rename_field(&root.target).map(|field| { field.on_mouse_down_out(cx.listener(|this, _event, _window, cx| { this.leave_rename(cx); @@ -3931,7 +3441,7 @@ impl Sidebar { // While a sidebar menu is open, only its owning header shows actions. let menu_open = self.project_popup.is_open(&root.target); let hover = !renaming && !self.sidebar_menu_open(); - let reorderable = hover && self.can_reorder_folders(); + let reorderable = hover && self.can_reorder(); let new_task = root.target.clone(); let menu_target = root.target.clone(); let selector = root.folder_id.clone(); @@ -3959,7 +3469,7 @@ impl Sidebar { }) .when(hover, |row| { row.hover(|style| { - let style = if self.folder_dragging { + let style = if self.dragging { style } else { style.bg(gpui::rgb(theme::bg_sidebar_row_hover())) @@ -3971,38 +3481,18 @@ impl Sidebar { } }) }) - .when(reorderable, |row| { - let sidebar_id = cx.entity_id(); - let owner = cx.weak_entity(); - let target = root.target.clone(); - row.on_drag( - DraggedFolder { - sidebar: sidebar_id, - target, - name: root.name.clone(), - }, - move |drag, _, _, cx| { - let preview = cx.new(|_| drag.clone()); - owner - .update(cx, |this, cx| { - this.folder_dragging = true; - this.folder_drop = None; - this.close_popups(cx); - this.set_hovered_entry(None, cx); - cx.observe_release(&preview, |this, _, cx| { - this.finish_folder_drag(cx); - }) - .detach(); - cx.notify(); - }) - .ok(); - preview - }, - ) - }) .on_click(cx.listener(move |this, _event, _window, cx| { - this.toggle_section_expanded(section, cx); - this.hover_heading(SidebarEntry::SectionLabel(section), cx); + if !this.collapsed_projects.remove(&toggle_target) { + this.collapsed_projects.insert(toggle_target.clone()); + } + this.rebuild_entries(); + if let Some((row, _)) = this + .drag_ranges + .get(&SidebarDragItem::Project(toggle_target.clone())) + { + this.set_hovered_entry(Some(*row), cx); + } + cx.notify(); })) .when(!renaming, |row| { row.on_mouse_down( @@ -4028,7 +3518,15 @@ impl Sidebar { .child(root.name.clone()), ), }) - .when((hover || menu_open) && !self.folder_dragging, |row| { + .when_some(context, |row, context| { + row.child( + div() + .text_xs() + .text_color(gpui::rgb(theme::text_muted())) + .child(context), + ) + }) + .when((hover || menu_open) && !self.dragging, |row| { row.child( hover_actions(&root.row_group, theme::bg_sidebar_row_hover()) .when(menu_open, |strip| { @@ -4064,91 +3562,10 @@ impl Sidebar { header.into_any_element() } - /// The project switcher row of paired computers: names the scope the - /// task list shows and opens the menu that picks it. The overflow menu - /// of one project hangs off its row inside the switcher. - fn render_switcher_header(&self, window: &mut Window, cx: &mut Context) -> AnyElement { - let open = self.popup.is_open(&SidebarPopup::Switcher); - let menu = open.then(|| { - let menu = self.computer_switcher_menu(window, cx); - self.popup.render(menu, Placement::BelowStart, window, cx) - }); - let header = div() - .id("projects-header") - .role(gpui::Role::Button) - .aria_label("Projects") - .debug_selector(|| "projects-header".to_string()) - .flex() - .items_center() - .gap_1p5() - .w_auto() - .mx_2() - .my(px(1.)) - .pl_2() - // Room for the new-project button, which sits over this end. - .pr(px(38.)) - .py_1() - .rounded(ROW_RADIUS) - .text_sm() - .text_color(gpui::rgb(theme::text_sidebar())) - .hover(|style| { - style - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - .cursor_pointer() - }) - .child(icon("folder", px(16.), theme::text_secondary())) - .child( - div() - .flex_1() - .min_w_0() - .line_clamp(1) - .text_ellipsis() - .child(self.scope_label.clone()), - ) - .child(icon( - if open { - "chevron-down" - } else { - "chevron-right" - }, - px(14.), - theme::text_secondary(), - )); - div() - .relative() - .w_full() - .mb_3() - .child( - self.popup - .trigger(SidebarPopup::Switcher, header, cx, |this, _, cx| { - this.toggle_header_menu(cx) - }), - ) - .child( - // A sibling over the header's end, not a button inside a - // button. It blocks the pointer, so a press on it never - // reaches the header behind it. - header_button("new-project", "folder-plus", "New project", false) - .occlude() - .absolute() - .right_4() - .top_0() - .bottom_0() - .my_auto() - .on_click(cx.listener(|_this, _event, _window, cx| { - cx.stop_propagation(); - cx.emit(SidebarEvent::ChooseProject); - })), - ) - .children(menu) - .into_any_element() - } - - /// A switcher row's overflow button, with the project's menu under it - /// while open. + /// A project's actions share the normal popup hierarchy. fn project_menu_trigger( &self, - root: &SwitcherRoot, + root: &ProjectRow, window: &mut Window, cx: &mut Context, ) -> Div { @@ -4156,7 +3573,6 @@ impl Sidebar { let menu = open.then(|| { let menu = Menu::new(root.target.element_id("project-menu"), px(180.)) .label("Project options") - .level(2) .application_vim(self.application_vim_enabled) .items( self.project_menu_items(&root.target) @@ -4180,7 +3596,7 @@ impl Sidebar { "Project options", open, ) - .when(self.computers.len() > 1 && !open, |button| { + .when(!open, |button| { button.tooltip(widgets::tooltip("Project options", None)) }), cx, @@ -4287,9 +3703,10 @@ impl Sidebar { archived, .. } = task; - let folder_task = self.computers.len() <= 1 - && self.view.group == SidebarGroup::Folder - && !is_pinned + let folder_task = matches!( + self.view.group, + SidebarGroup::Project | SidebarGroup::Connection + ) && !is_pinned && !archived; let row = &self.rows[index]; let is_selected = selected == Some(&*row.id); @@ -4319,7 +3736,7 @@ impl Sidebar { .role(gpui::Role::ListBoxOption) .accessibility_id(row.id.to_string()) .aria_label(row.title.clone()) - .aria_description(row.project_name.clone()) + .aria_description(row.metadata.clone()) .aria_selected(is_selected) .when(application_selected, |row| row.aria_active_descendant()) .group(row.group.clone()) @@ -4345,7 +3762,7 @@ impl Sidebar { .text_color(gpui::rgb(theme::text_sidebar())) // 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 && !self.folder_dragging, |el| { + .when(!renaming && !self.dragging, |el| { el.hover(|style| style.bg(gpui::rgb(hover_bg)).cursor_pointer()) }) .on_click(cx.listener(move |_this, _event, _window, cx| { @@ -4363,23 +3780,20 @@ impl Sidebar { None => column .child(div().line_clamp(1).text_ellipsis().child(row.title.clone())), }) - .when( - self.computers.len() <= 1 && self.show_metadata(), - |column| { - column.child( - div() - .text_xs() - .text_color(gpui::rgb(if is_selected { - theme::text_secondary() - } else { - theme::text_muted() - })) - .line_clamp(1) - .text_ellipsis() - .child(row.project_name.clone()), - ) - }, - ), + .when(self.show_metadata() || is_pinned || archived, |column| { + column.child( + div() + .text_xs() + .text_color(gpui::rgb(if is_selected { + theme::text_secondary() + } else { + theme::text_muted() + })) + .line_clamp(1) + .text_ellipsis() + .child(row.metadata.clone()), + ) + }), ) .when_some(activity, |row_element, activity| { row_element.child(activity_indicator(&row.spinner_id, activity)) @@ -4406,7 +3820,7 @@ impl Sidebar { .child(icon("pin", px(13.), theme::accent())), ) }) - .when(!renaming && !self.folder_dragging, |el| { + .when(!renaming && !self.dragging, |el| { el.child( hover_actions(&row.group, hover_bg) .when(is_pinned, |overlay| overlay.right(px(26.))) @@ -4453,35 +3867,17 @@ impl Render for Sidebar { // First, so the rename field's focus request below wins over a menu // handing focus back. self.sync_popup_focus(window, cx); - // Paired project renames end with their switcher. - if self - .rename - .as_ref() - .is_some_and(|target| self.renames_in_switcher(target)) - && !self.header_menu_open() - { - self.rename = None; - self.rename_input = None; - } if self.rename_focus_pending { self.rename_focus_pending = false; if let Some(input) = self.rename_input.clone() { let handle = input.read(cx).focus_handle(cx); window.focus(&handle, cx); - // Inline fields commit on blur; switcher fields end with - // their menu. Defer so the subscription is not dropped here. - if self - .rename - .as_ref() - .is_some_and(|target| !self.renames_in_switcher(target)) - { - self.rename_focus_out = - Some( - cx.on_focus_out(&handle, window, |_this, _event, window, cx| { - cx.defer_in(window, |this, _window, cx| this.leave_rename(cx)); - }), - ); - } + self.rename_focus_out = + Some( + cx.on_focus_out(&handle, window, |_this, _event, window, cx| { + cx.defer_in(window, |this, _window, cx| this.leave_rename(cx)); + }), + ); } } let entity = cx.entity().downgrade(); 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 b386f4a47..efef86f32 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs @@ -1,5 +1,5 @@ -//! The normal sidebar list, grouped by computer and folder when paired hosts -//! exist. This is a summary projection, never a second task store. +//! The sidebar's bounded projection across execution computers. Organization +//! changes presentation only; every project and task keeps its owning host. use super::*; use crate::backend::ProjectPageRequest; use crate::ui::chat::computers::{Computer, Destination, Navigate}; @@ -38,6 +38,8 @@ impl ComputerPages { page.groups .iter() .flat_map(|group| group.tasks.iter()) + .chain(page.active.iter()) + .chain(page.settled.iter()) .chain(page.search.iter()) .chain(page.archived.iter()) .map(|task| task.uuid.clone()) @@ -49,9 +51,13 @@ impl ComputerPages { struct ComputerHostPage { projects: Vec, groups: Vec, + active: Vec, + settled: Vec, search: Vec, archived: Vec, next_groups: Option, + next_active: Option, + next_settled: Option, next_search: Option, next_archived: Option, next_projects: Option, @@ -61,6 +67,7 @@ struct ComputerHostPage { loading: HashSet, errors: HashMap, loaded: HashSet, + retained_heads: HashSet, } impl ComputerHostPage { @@ -68,6 +75,8 @@ impl ComputerHostPage { /// never use continuation cursors from before the refresh. fn invalidate(&mut self) { self.next_groups = None; + self.next_active = None; + self.next_settled = None; self.next_search = None; self.next_archived = None; self.next_projects = None; @@ -77,12 +86,15 @@ impl ComputerHostPage { self.loading.clear(); self.loaded.clear(); self.errors.clear(); + self.retained_heads.clear(); } fn cursor_is_current(&self, kind: &ComputerPageKind, cursor: &Option) -> bool { let expected = match kind { ComputerPageKind::Projects => self.next_projects.as_ref(), ComputerPageKind::Groups => self.next_groups.as_ref(), + ComputerPageKind::Tasks { settled: false } => self.next_active.as_ref(), + ComputerPageKind::Tasks { settled: true } => self.next_settled.as_ref(), ComputerPageKind::Search => self.next_search.as_ref(), ComputerPageKind::Archived => self.next_archived.as_ref(), ComputerPageKind::Project { root, settled } => self @@ -115,6 +127,7 @@ struct ComputerMore { enum ComputerPageKind { Projects, Groups, + Tasks { settled: bool }, Search, Project { root: String, settled: bool }, Archived, @@ -125,6 +138,7 @@ impl ComputerPageKind { match self { Self::Projects => "projects".into(), Self::Groups => "groups".into(), + Self::Tasks { settled } => format!("tasks:{settled}"), Self::Search => "search".into(), Self::Project { root, settled } => format!("project:{settled}:{root}"), Self::Archived => "archived".into(), @@ -229,6 +243,16 @@ fn snapshot_page(computer: &Computer, query: &str, archived_expanded: bool) -> C .collect(), ..ComputerHostPage::default() }; + page.groups = page + .projects + .iter() + .cloned() + .map(|project| NavigationProjectGroup { + project, + tasks: Vec::new(), + more: Default::default(), + }) + .collect(); for session in &computer.sessions { if session.message_count == 0 && !computer.running.contains(&session.id) { continue; @@ -266,6 +290,10 @@ fn snapshot_page(computer: &Computer, query: &str, archived_expanded: bool) -> C if archived_expanded { page.archived.push(task.clone()); } + } else if state == "settled" { + page.settled.push(task.clone()); + } else { + page.active.push(task.clone()); } if !query.is_empty() { if session.title.to_lowercase().contains(query) @@ -306,7 +334,9 @@ fn snapshot_page(computer: &Computer, query: &str, archived_expanded: bool) -> C } pub(super) struct ProjectHeading { - request: Navigate, + pub(super) request: Navigate, + pub(super) target: ProjectTarget, + pub(super) name: SharedString, id: SharedString, label: SharedString, remote: bool, @@ -314,20 +344,26 @@ pub(super) struct ProjectHeading { pub(super) struct OtherTask { pub(super) request: Navigate, pub(super) archived: bool, + pub(super) session: AgentSessionSummary, + pub(super) metadata: SharedString, id: SharedString, title: SharedString, activity: Option, - pinned: bool, + pub(super) pinned: bool, } impl Sidebar { + pub(super) fn uses_computer_projection(&self) -> bool { + self.computers.iter().any(|computer| computer.key.is_some()) + } + pub(in crate::ui::chat) fn set_computers( &mut self, computers: Arc>, cx: &mut Context, ) { - let scope_changed = self.computers.len() > 1 - && computers.len() <= 1 + let scope_changed = self.uses_computer_projection() + && !computers.iter().any(|computer| computer.key.is_some()) && self.project_filter.take().is_some(); let changed = computer_revision(&self.computers) != computer_revision(&computers) || existing_snapshot_changed(&self.computers, &computers); @@ -341,6 +377,13 @@ impl Sidebar { }); } self.computers = computers; + if self.view.group == SidebarGroup::Connection && !self.connection_grouping_available() { + self.view.group = SidebarGroup::Project; + let view = self.view; + crate::settings::update_settings_in_background(move |settings| { + settings.sidebar_layout = view; + }); + } if scope_changed && self.pages.enabled { self.refresh_pages(cx); } else if changed { @@ -414,6 +457,8 @@ impl Sidebar { page.groups .iter() .flat_map(|group| group.tasks.iter()) + .chain(page.active.iter()) + .chain(page.settled.iter()) .chain(page.search.iter()) .chain(page.archived.iter()) .map(|task| { @@ -439,7 +484,7 @@ impl Sidebar { .unwrap_or_default(); self.merge_page_sessions(own_tasks); self.ensure_computer_pages(cx); - self.rebuild_computer_switcher_roots(); + self.rebuild_computer_project_roots(); } pub(super) fn revalidate_computer_pages(&mut self, cx: &mut Context) { @@ -452,14 +497,20 @@ impl Sidebar { pub(super) fn refresh_computer_pages(&mut self, cx: &mut Context) { self.group_pages.generation = self.group_pages.generation.wrapping_add(1); - self.group_pages.hosts.clear(); + // Ordering edits invalidate cursors, not the summaries already loaded + // beyond page one. A scope/owner change still discards them separately. + for page in self.group_pages.hosts.values_mut() { + let mut retained = page.retained_heads.clone(); + retained.extend(page.loaded.iter().cloned()); + page.invalidate(); + page.retained_heads = retained; + } self.group_pages.more.clear(); - self.group_pages.scope = None; self.ensure_computer_pages(cx); } pub(super) fn ensure_computer_pages(&mut self, cx: &mut Context) { - if self.computers.len() <= 1 { + if !self.uses_computer_projection() { return; } let scope = ( @@ -505,28 +556,35 @@ impl Sidebar { if self.project_filter.is_some() { continue; } - let page = self.group_pages.hosts.entry(computer.clone()).or_default(); - let kind = if self.filter.is_empty() { - ComputerPageKind::Groups + let kinds = if !self.filter.is_empty() { + vec![ComputerPageKind::Search] + } else if matches!(self.view.group, SidebarGroup::Status | SidebarGroup::List) { + vec![ + ComputerPageKind::Tasks { settled: false }, + ComputerPageKind::Tasks { settled: true }, + ] } else { - ComputerPageKind::Search + vec![ComputerPageKind::Groups] }; - let key = kind.key(); - if !page.loaded.contains(&key) - && !page.loading.contains(&key) - && !page.errors.contains_key(&key) - { - self.load_computer_page( - ComputerMore { - computer: computer.clone(), - generation: self.group_pages.generation, - kind, - cursor: None, - label: "Loading tasks…".into(), - loading: false, - }, - cx, - ); + for kind in kinds { + let page = self.group_pages.hosts.entry(computer.clone()).or_default(); + let key = kind.key(); + if !page.loaded.contains(&key) + && !page.loading.contains(&key) + && !page.errors.contains_key(&key) + { + self.load_computer_page( + ComputerMore { + computer: computer.clone(), + generation: self.group_pages.generation, + kind, + cursor: None, + label: "Loading tasks…".into(), + loading: false, + }, + cx, + ); + } } if self.archived_expanded && self.filter.is_empty() { let page = self.group_pages.hosts.entry(computer.clone()).or_default(); @@ -551,6 +609,10 @@ impl Sidebar { } fn load_computer_page(&mut self, action: ComputerMore, cx: &mut Context) { + if self.order_query_dirty { + self.refresh_task_order(cx); + return; + } if action.generation != self.group_pages.generation { return; } @@ -560,6 +622,7 @@ impl Sidebar { let Some(backend) = computer.backend.clone() else { return; }; + let manual = self.manual_task_order(&action.computer); let key = action.kind.key(); let page = self .group_pages @@ -620,6 +683,7 @@ impl Sidebar { limit_groups: 20, per_project: 20, cursor: request.cursor.clone(), + manual: manual.clone(), }, ) .await @@ -633,11 +697,13 @@ impl Sidebar { project_roots: matched_roots, cursor: request.cursor.clone(), limit: 100, + manual: manual.clone(), }, ) .await .map(ComputerPageReply::Tasks), - ComputerPageKind::Project { root, settled } => backend + ComputerPageKind::Tasks { settled } + | ComputerPageKind::Project { settled, .. } => backend .list_tasks( &user, NavigationTaskQuery { @@ -646,10 +712,14 @@ impl Sidebar { } else { TaskState::Active }), - project: Some(root.clone()), + project: match &request.kind { + ComputerPageKind::Project { root, .. } => Some(root.clone()), + _ => project, + }, pins: PinFilter::Ignore, cursor: request.cursor.clone(), limit: 50, + manual: manual.clone(), }, ) .await @@ -663,6 +733,7 @@ impl Sidebar { pins: PinFilter::Ignore, cursor: request.cursor.clone(), limit: 50, + manual, }, ) .await @@ -702,12 +773,13 @@ impl Sidebar { return; } }; + let retain_head = action.cursor.is_none() && page.retained_heads.remove(&key); page.loaded.insert(key); let mut own_tasks = Vec::new(); let mut own_labels = Vec::new(); match result { ComputerPageReply::Projects(reply) => { - if action.cursor.is_none() { + if action.cursor.is_none() && !retain_head { page.projects.clear(); } page.next_projects = reply.next_cursor; @@ -722,12 +794,26 @@ impl Sidebar { } } ComputerPageReply::Groups(reply) => { - if action.cursor.is_none() { + if action.cursor.is_none() && !retain_head { page.groups.clear(); } page.archived_count = reply.archived_count; page.next_groups = reply.next_cursor; for group in reply.groups { + // Group replies are authoritative project metadata too. + // Install it before the independent metadata page arrives + // so every visible header immediately has its actions. + if let Some(existing) = page + .projects + .iter_mut() + .find(|project| project.root == group.project.root) + { + if existing.name_revision <= group.project.name_revision { + *existing = group.project.clone(); + } + } else { + page.projects.push(group.project.clone()); + } if own { own_tasks.extend(group.tasks.iter().map(session_from_summary)); own_labels.extend(group.tasks.iter().map(|task| { @@ -755,15 +841,27 @@ impl Sidebar { })); } match action.kind { + ComputerPageKind::Tasks { settled } => { + let (tasks, next) = if settled { + (&mut page.settled, &mut page.next_settled) + } else { + (&mut page.active, &mut page.next_active) + }; + if action.cursor.is_none() && !retain_head { + tasks.clear(); + } + *next = reply.next_cursor; + merge_tasks(tasks, reply.tasks); + } ComputerPageKind::Search => { - if action.cursor.is_none() { + if action.cursor.is_none() && !retain_head { page.search.clear(); } page.next_search = reply.next_cursor; merge_tasks(&mut page.search, reply.tasks); } ComputerPageKind::Archived => { - if action.cursor.is_none() { + if action.cursor.is_none() && !retain_head { page.archived.clear(); } page.next_archived = reply.next_cursor; @@ -791,7 +889,7 @@ impl Sidebar { self.remember_task_project_labels(own_labels); self.merge_page_sessions(own_tasks); } - self.rebuild_computer_switcher_roots(); + self.rebuild_computer_project_roots(); self.rebuild_entries(); self.list.remeasure_items(0..self.entries.len()); cx.notify(); @@ -832,6 +930,202 @@ impl Sidebar { entries.push(SidebarEntry::ComputerMore(index)); } + /// Resolve a summary against a newer verified live snapshot before it + /// participates in ordering or status grouping. + fn current_computer_task(&self, computer: &Computer, task: &TaskSummary) -> TaskSummary { + let own = computer.key.as_deref() == self.backend.remote_host(); + let latest = computer + .sessions + .iter() + .chain(self.sessions.iter().filter(|_| own)) + .filter(|session| session.id == task.uuid && session.updated_ms >= task.updated_ms) + .max_by_key(|session| session.updated_ms); + let mut task = task.clone(); + if let Some(session) = latest { + task.title.clone_from(&session.title); + task.created_ms = session.created_ms; + task.updated_ms = session.updated_ms; + task.message_count = u64::try_from(session.message_count).unwrap_or(u64::MAX); + task.state = match session.state { + AgentTaskState::Active => "active", + AgentTaskState::Settled => "settled", + AgentTaskState::Archived => "archived", + } + .into(); + } + if computer.running.contains(&task.uuid) || (own && self.running.contains(&task.uuid)) { + task.state = "active".into(); + } + task + } + + fn computer_project_groups( + &self, + computer: &Computer, + page: &ComputerHostPage, + ) -> Vec { + let searching = !self.filter.is_empty(); + let own = computer.key.as_deref() == self.backend.remote_host(); + let mut groups = if searching { + Vec::new() + } else { + page.groups.clone() + }; + // Project metadata is independent of tasks. Keep empty projects visible + // even when their task page has not arrived, without claiming it empty. + for project in &page.projects { + let matches = !searching + || computer.label.to_lowercase().contains(&self.filter) + || self + .computer_project_name(computer, project) + .to_lowercase() + .contains(&self.filter); + if matches + && !groups + .iter() + .any(|group| group.project.root == project.root) + { + groups.push(NavigationProjectGroup { + project: project.clone(), + tasks: Vec::new(), + more: Default::default(), + }); + } + } + if searching { + for summary in &page.search { + let task = self.current_computer_task(computer, summary); + if task.state == "archived" { + continue; + } + if let Some(group) = groups + .iter_mut() + .find(|group| group.project.root == task.project_root) + { + group.tasks.push(task); + } else { + groups.push(NavigationProjectGroup { + project: ProjectSummary { + root: task.project_root.clone(), + display_name: task.project_display_name.clone(), + position: None, + last_used_ms: task.updated_ms, + name_revision: 0, + is_default_workspace: false, + truncated: false, + }, + tasks: vec![task], + more: Default::default(), + }); + } + } + } else { + for group in &mut groups { + group.tasks = group + .tasks + .iter() + .map(|task| self.current_computer_task(computer, task)) + .filter(|task| task.state != "archived") + .collect(); + } + } + groups.retain(|group| { + (!own || !self.removed_projects.contains(&group.project.root)) + && self + .project_filter + .as_ref() + .is_none_or(|root| own && root == &group.project.root) + }); + groups + } + + fn sort_computer_tasks(&self, tasks: &mut [(usize, TaskSummary)]) { + tasks.sort_by(|(left, a), (right, b)| { + let left = &self.computers[*left].key; + let right = &self.computers[*right].key; + self.task_order_key(left, &a.uuid, a.created_ms, a.updated_ms) + .cmp(&self.task_order_key(right, &b.uuid, b.created_ms, b.updated_ms)) + .then_with(|| left.cmp(right)) + .then_with(|| a.uuid.cmp(&b.uuid)) + }); + } + + fn push_computer_group( + &mut self, + entries: &mut Vec, + computer_index: usize, + computer: &Computer, + mut group: NavigationProjectGroup, + ) { + let target = ProjectTarget { + computer: computer.key.clone(), + path: group.project.root.clone(), + }; + self.push_computer_heading(entries, computer_index, computer, &group.project); + if self.collapsed_projects.contains(&target) { + return; + } + group.tasks.sort_by(|a, b| { + self.task_order_key(&computer.key, &a.uuid, a.created_ms, a.updated_ms) + .cmp(&self.task_order_key(&computer.key, &b.uuid, b.created_ms, b.updated_ms)) + .then_with(|| a.uuid.cmp(&b.uuid)) + }); + for task in &group.tasks { + self.push_computer_task(entries, computer_index, computer, task); + } + if self.filter.is_empty() { + for (settled, cursor) in [(false, group.more.active), (true, group.more.settled)] { + self.computer_more_entry( + entries, + &computer.key, + ComputerPageKind::Project { + root: target.path.clone(), + settled, + }, + cursor, + if settled { + "more settled tasks" + } else { + "more active tasks" + }, + ); + } + } + } + + fn push_computer_flat_more( + &mut self, + entries: &mut Vec, + computer_index: usize, + settled: Option, + ) { + let computer = self.computers[computer_index].clone(); + let Some(page) = self.group_pages.hosts.get(&computer.key) else { + return; + }; + let (kind, cursor) = if !self.filter.is_empty() { + (ComputerPageKind::Search, page.next_search.clone()) + } else if let Some(settled) = settled { + ( + ComputerPageKind::Tasks { settled }, + if settled { + page.next_settled.clone() + } else { + page.next_active.clone() + }, + ) + } else { + (ComputerPageKind::Groups, page.next_groups.clone()) + }; + self.computer_more_entry( + entries, + &computer.key, + kind, + cursor, + &format!("more from {}", computer.label), + ); + } + pub(super) fn computer_entries(&mut self) -> Vec { self.computer_projects.clear(); self.other_tasks.clear(); @@ -842,184 +1136,212 @@ impl Sidebar { SidebarEntry::Search, SidebarEntry::ProjectsHeader, ]; - let mut archived_entries = Vec::new(); - // These small clones let rendering append row projections without - // borrowing the per-host paging cache for the whole rebuild. + // A mounted view projects only bounded, owner-fenced summary pages. + // Drafts, transcripts and execution remain in their owning ChatScreen. let computers = self.computers.clone(); - for (computer_index, computer) in computers.iter().enumerate() { - let own = computer.key.as_deref() == self.backend.remote_host(); - if self.project_filter.is_some() && !own { - continue; - } + let mut hosts = (0..computers.len()) + .filter(|&index| { + self.project_filter.is_none() + || computers[index].key.as_deref() == self.backend.remote_host() + }) + .collect::>(); + hosts.sort_by_cached_key(|&index| { + let computer = &computers[index].key; + ( + self.order + .connections + .iter() + .position(|saved| saved == computer) + .unwrap_or(usize::MAX), + computer.clone(), + ) + }); + let mut groups = Vec::new(); + let mut tasks = Vec::new(); + let mut archived = Vec::new(); + let mut unavailable = Vec::new(); + let searching = !self.filter.is_empty(); + let flat = matches!(self.view.group, SidebarGroup::Status | SidebarGroup::List); + for &index in &hosts { + let computer = &computers[index]; let Some(page) = self.group_pages.hosts.get(&computer.key) else { - entries.push(SidebarEntry::Computer(computer_index)); + unavailable.push(index); continue; }; + if computer.backend.is_none() || computer.error.is_some() { + unavailable.push(index); + } self.computer_archived_count = self .computer_archived_count .saturating_add(usize::try_from(page.archived_count).unwrap_or(usize::MAX)); - let searching = !self.filter.is_empty(); - let mut groups = page.groups.clone(); - let search = page.search.clone(); - let archived = page.archived.clone(); - let next = if searching { - page.next_search.clone() + // A disconnected attachment has no verified owner handle. Do not + // promote its snapshot into actionable task/project rows. + if computer.backend.is_none() { + continue; + } + if flat { + let source = if searching { + page.search.iter().collect::>() + } else { + page.active.iter().chain(&page.settled).collect::>() + }; + let mut seen = HashSet::new(); + for summary in source { + let task = self.current_computer_task(computer, summary); + if task.state != "archived" && seen.insert(task.uuid.clone()) { + tasks.push((index, task)); + } + } } else { - page.next_groups.clone() + groups.extend( + self.computer_project_groups(computer, page) + .into_iter() + .map(|group| (index, group)), + ); + } + if self.archived_expanded { + let source = if searching { + &page.search + } else { + &page.archived + }; + for summary in source { + let task = self.current_computer_task(computer, summary); + if task.state == "archived" { + archived.push((index, task)); + } + } + } + } + groups.sort_by_cached_key(|(index, group)| { + let target = ProjectTarget { + computer: computers[*index].key.clone(), + path: group.project.root.clone(), }; - let next_archived = page.next_archived.clone(); - if searching { - groups.clear(); - for task in search { - if task.state == "archived" && !self.archived_expanded { - continue; + ( + self.project_order_rank(&target), + target.computer, + group.project.position.unwrap_or(i64::MAX), + target.path, + ) + }); + self.sort_computer_tasks(&mut tasks); + self.sort_computer_tasks(&mut archived); + if self.projects_expanded { + match self.view.group { + SidebarGroup::Project => { + for (index, group) in groups { + self.push_computer_group(&mut entries, index, &computers[index], group); } - if let Some(group) = groups - .iter_mut() - .find(|g| g.project.root == task.project_root) - { - group.tasks.push(task); - } else { - let project = computer - .projects - .iter() - .find(|p| p.path == task.project_root); - groups.push(NavigationProjectGroup { - project: maple_agent::store::ProjectSummary { - root: task.project_root.clone(), - display_name: computer - .project_names - .get(&task.project_root) - .cloned() - .or_else(|| task.project_display_name.clone()) - .or_else(|| project.map(|p| p.label.to_string())), - position: None, - last_used_ms: task.updated_ms, - name_revision: 0, - is_default_workspace: false, - truncated: false, - }, - tasks: vec![task], - more: Default::default(), - }); + for &index in &hosts { + self.push_computer_flat_more(&mut entries, index, None); } + entries.extend(unavailable.iter().copied().map(SidebarEntry::Computer)); } - } - if groups.is_empty() && (computer.backend.is_none() || computer.error.is_some()) { - entries.push(SidebarEntry::Computer(computer_index)); - } - for mut group in groups { - if own - && self - .project_filter - .as_ref() - .is_some_and(|root| root != &group.project.root) - { - continue; + SidebarGroup::Connection => { + for &index in &hosts { + entries.push(SidebarEntry::Computer(index)); + if self.collapsed_computers.contains(&computers[index].key) { + continue; + } + for (owner, group) in groups.iter().filter(|(owner, _)| *owner == index) { + self.push_computer_group( + &mut entries, + *owner, + &computers[*owner], + group.clone(), + ); + } + self.push_computer_flat_more(&mut entries, index, None); + } } - let root = group.project.root.clone(); - self.push_computer_heading(&mut entries, computer_index, computer, &group.project); - group.tasks.sort_by_key(|task| { - ( - task.state == "archived", - task.state == "settled", - task.pinned_ms.is_none(), - std::cmp::Reverse(task.pinned_ms), - std::cmp::Reverse(task.updated_ms), - ) - }); - for task in &group.tasks { - self.push_computer_task(&mut entries, computer_index, computer, task); + SidebarGroup::Status => { + for (section, settled) in [ + (SidebarSection::Active, false), + (SidebarSection::Settled, true), + ] { + let visible = tasks + .iter() + .filter(|(_, task)| (task.state == "settled") == settled); + if visible.clone().next().is_some() { + entries.push(SidebarEntry::SectionLabel(section)); + } + if !self.section_expanded(section) { + continue; + } + for (index, task) in visible { + self.push_computer_task(&mut entries, *index, &computers[*index], task); + } + if !searching { + for &index in &hosts { + self.push_computer_flat_more(&mut entries, index, Some(settled)); + } + } + } + if searching { + for &index in &hosts { + self.push_computer_flat_more(&mut entries, index, None); + } + } + entries.extend(unavailable.iter().copied().map(SidebarEntry::Computer)); } - if !searching { - self.computer_more_entry( - &mut entries, - &computer.key, - ComputerPageKind::Project { - root: root.clone(), - settled: false, - }, - group.more.active, - "more active tasks", - ); - self.computer_more_entry( - &mut entries, - &computer.key, - ComputerPageKind::Project { - root, - settled: true, - }, - group.more.settled, - "more settled tasks", - ); + SidebarGroup::List => { + for (index, task) in &tasks { + self.push_computer_task(&mut entries, *index, &computers[*index], task); + } + for &index in &hosts { + if searching { + self.push_computer_flat_more(&mut entries, index, None); + } else { + self.push_computer_flat_more(&mut entries, index, Some(false)); + self.push_computer_flat_more(&mut entries, index, Some(true)); + } + } + entries.extend(unavailable.iter().copied().map(SidebarEntry::Computer)); } } - self.computer_more_entry( - &mut entries, - &computer.key, - if searching { - ComputerPageKind::Search - } else { - ComputerPageKind::Groups - }, - next, - if searching { - "more matching tasks" - } else { - "more projects" - }, - ); - if self.archived_expanded && !searching { - let mut archived_groups: Vec<(ProjectSummary, Vec<&TaskSummary>)> = Vec::new(); - for task in &archived { - if let Some((_, tasks)) = archived_groups - .iter_mut() - .find(|(project, _)| project.root == task.project_root) - { - tasks.push(task); - } else { - archived_groups.push(( - ProjectSummary { - root: task.project_root.clone(), - display_name: task.project_display_name.clone(), - position: None, - last_used_ms: task.updated_ms, - name_revision: 0, - is_default_workspace: false, - truncated: false, - }, - vec![task], - )); - } + } + if self.computer_archived_count > 0 || !archived.is_empty() { + entries.push(SidebarEntry::ArchivedHeader); + if self.archived_expanded { + for (index, task) in archived { + self.push_computer_task(&mut entries, index, &computers[index], &task); } - for (project, tasks) in archived_groups { - self.push_computer_heading( - &mut archived_entries, - computer_index, - computer, - &project, - ); - for task in tasks { - self.push_computer_task( - &mut archived_entries, - computer_index, - computer, - task, + if !searching { + for &index in &hosts { + let computer = &computers[index]; + let next = self + .group_pages + .hosts + .get(&computer.key) + .and_then(|page| page.next_archived.clone()); + self.computer_more_entry( + &mut entries, + &computer.key, + ComputerPageKind::Archived, + next, + &format!("more archived tasks from {}", computer.label), ); } } - self.computer_more_entry( - &mut archived_entries, - &computer.key, - ComputerPageKind::Archived, - next_archived, - "more archived tasks", - ); } } - if self.computer_archived_count > 0 || !self.filter.is_empty() { - entries.push(SidebarEntry::ArchivedHeader); - entries.extend(archived_entries); + if entries.len() == 3 && self.projects_expanded { + entries.push(SidebarEntry::Empty(if searching { + SidebarEmpty::NoMatches + } else { + SidebarEmpty::NoTasks + })); + } + if !self.projects_expanded { + let archived = entries + .iter() + .position(|entry| *entry == SidebarEntry::ArchivedHeader); + let mut folded = entries[..3].to_vec(); + if let Some(start) = archived { + folded.extend_from_slice(&entries[start..]); + } + return folded; } entries } @@ -1027,20 +1349,30 @@ impl Sidebar { fn push_computer_heading( &mut self, entries: &mut Vec, - computer_index: usize, + _computer_index: usize, computer: &Computer, project: &ProjectSummary, ) { let heading = self.computer_projects.len(); let root = &project.root; let label = self.computer_project_name(computer, project); + let target = ProjectTarget { + computer: computer.key.clone(), + path: root.clone(), + }; self.computer_projects.push(ProjectHeading { request: Navigate { computer: computer.key.clone(), destination: Destination::Project(root.clone()), }, - id: format!("computer-{computer_index}-folder-{heading}").into(), - label: format!("{} · {}", computer.label, label).into(), + id: format!("computer-project-{:?}-{root}", computer.key).into(), + target, + name: label.clone().into(), + label: if self.view.group == SidebarGroup::Connection { + label.into() + } else { + format!("{} · {}", label, computer.label).into() + }, remote: computer.key.is_some(), }); entries.push(SidebarEntry::ComputerProject(heading)); @@ -1079,7 +1411,7 @@ impl Sidebar { fn push_computer_task( &mut self, entries: &mut Vec, - computer_index: usize, + _computer_index: usize, computer: &Computer, task: &TaskSummary, ) { @@ -1093,7 +1425,7 @@ impl Sidebar { entries.push(SidebarEntry::Task(SidebarTaskEntry { session: index, pinned: task.pinned_ms.is_some(), - settled: state == AgentTaskState::Settled, + settled: state == AgentTaskState::Settled && !self.running.contains(&task.uuid), archived: state == AgentTaskState::Archived, gap_after: false, })); @@ -1111,13 +1443,39 @@ impl Sidebar { return; } let index = self.other_tasks.len(); + let session = session_from_summary(task); + let metadata = if matches!(self.view.group, SidebarGroup::Status | SidebarGroup::List) + || archived + { + format!( + "{} · {}", + self.computer_project_name( + computer, + &ProjectSummary { + root: task.project_root.clone(), + display_name: task.project_display_name.clone(), + position: None, + last_used_ms: task.updated_ms, + name_revision: 0, + is_default_workspace: false, + truncated: false, + } + ), + computer.label, + ) + .into() + } else { + SharedString::default() + }; self.other_tasks.push(OtherTask { archived, + session, + metadata, request: Navigate { computer: computer.key.clone(), destination: Destination::Task(task.uuid.clone()), }, - id: format!("computer-{computer_index}-task-{}", task.uuid).into(), + id: format!("computer-task-{:?}-{}", computer.key, task.uuid).into(), title: latest .map_or_else(|| task.title.clone(), |session| session.title.clone()) .into(), @@ -1155,6 +1513,18 @@ impl Sidebar { Some(SidebarTarget::Computer(request)) } + pub(super) fn computer_more_scope( + &self, + index: usize, + ) -> Option<(Option, Option)> { + let action = self.group_pages.more.get(index)?; + let project = match &action.kind { + ComputerPageKind::Project { root, .. } => Some(root.clone()), + _ => None, + }; + Some((action.computer.clone(), project)) + } + pub(super) fn computer_more_id(&self, index: usize) -> String { let action = &self.group_pages.more[index]; format!( @@ -1182,10 +1552,38 @@ impl Sidebar { true } + pub(in crate::ui::chat) fn toggle_computer_group( + &mut self, + request: &Navigate, + cx: &mut Context, + ) -> bool { + match &request.destination { + Destination::Project(path) => { + let target = ProjectTarget { + computer: request.computer.clone(), + path: path.clone(), + }; + if !self.collapsed_projects.remove(&target) { + self.collapsed_projects.insert(target); + } + } + Destination::Overview if self.view.group == SidebarGroup::Connection => { + if !self.collapsed_computers.remove(&request.computer) { + self.collapsed_computers.insert(request.computer.clone()); + } + } + _ => return false, + } + self.rebuild_entries(); + cx.notify(); + true + } + pub(super) fn render_computer_entry( &self, entry: SidebarEntry, selected: bool, + window: &mut Window, cx: &mut Context, ) -> AnyElement { let hover_bg = if selected { @@ -1193,6 +1591,7 @@ impl Sidebar { } else { theme::bg_sidebar_row_hover() }; + let dragging = cx.has_active_drag(); match entry { SidebarEntry::ComputerMore(index) => { let action = self.group_pages.more[index].clone(); @@ -1226,11 +1625,21 @@ impl Sidebar { } SidebarEntry::ComputerProject(index) => { let project = &self.computer_projects[index]; + if let Some(root) = self + .computer_project_roots + .iter() + .flatten() + .find(|root| root.target == project.target) + { + return self.render_project_header(root, window, cx); + } let request = project.request.clone(); + let expanded = !self.collapsed_projects.contains(&project.target); div() .id(project.id.clone()) .role(gpui::Role::Button) .aria_label(project.label.clone()) + .aria_expanded(expanded) .mx_2() .my(px(1.)) .mt_3() @@ -1246,10 +1655,25 @@ impl Sidebar { row.aria_active_descendant() .bg(gpui::rgb(theme::bg_sidebar_row_hover())) }) - .hover(|s| s.bg(gpui::rgb(hover_bg)).cursor_pointer()) - .on_click(cx.listener(move |_, _, _, cx| { - cx.emit(SidebarEvent::Navigate(request.clone())) + .hover(move |s| { + if dragging { + s + } else { + s.bg(gpui::rgb(hover_bg)).cursor_pointer() + } + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.toggle_computer_group(&request, cx); })) + .child(icon( + if expanded { + "chevron-down" + } else { + "chevron-right" + }, + px(12.), + theme::text_muted(), + )) .child(icon( if project.remote { "globe" } else { "folder" }, px(14.), @@ -1287,7 +1711,13 @@ impl Sidebar { row.aria_active_descendant() .bg(gpui::rgb(theme::bg_sidebar_row_hover())) }) - .hover(|s| s.bg(gpui::rgb(hover_bg)).cursor_pointer()) + .hover(move |s| { + if dragging { + s + } else { + s.bg(gpui::rgb(hover_bg)).cursor_pointer() + } + }) .on_click(cx.listener(move |_, _, _, cx| { cx.emit(SidebarEvent::Navigate(request.clone())) })) @@ -1295,9 +1725,24 @@ impl Sidebar { div() .flex_1() .min_w_0() - .line_clamp(1) - .text_ellipsis() - .child(task.title.clone()), + .flex() + .flex_col() + .child( + div() + .line_clamp(1) + .text_ellipsis() + .child(task.title.clone()), + ) + .when(!task.metadata.is_empty(), |column| { + column.child( + div() + .text_xs() + .text_color(gpui::rgb(theme::text_secondary())) + .line_clamp(1) + .text_ellipsis() + .child(task.metadata.clone()), + ) + }), ) .when_some(task.activity, |row, activity| { row.child(activity_indicator(&task.id, activity)) @@ -1309,14 +1754,28 @@ impl Sidebar { } SidebarEntry::Computer(index) => { let computer = &self.computers[index]; + let collapsible = self.view.group == SidebarGroup::Connection; + let expanded = !self.collapsed_computers.contains(&computer.key); + let status = computer.error.clone().or_else(|| { + if computer.loading { + Some("Connecting…".into()) + } else if computer.backend.is_none() { + Some("Offline".into()) + } else if computer.projects.is_empty() { + Some("No projects yet".into()) + } else { + None + } + }); let request = Navigate { computer: computer.key.clone(), destination: Destination::Overview, }; div() - .id(SharedString::from(format!("computer-{index}"))) + .id(SharedString::from(format!("computer-{:?}", computer.key))) .role(gpui::Role::Button) .aria_label(computer.label.clone()) + .when(collapsible, |row| row.aria_expanded(expanded)) .mx_2() .my(px(1.)) .mt_3() @@ -1328,127 +1787,43 @@ impl Sidebar { .gap_1() .text_xs() .text_color(gpui::rgb(theme::text_secondary())) - .hover(|s| s.bg(gpui::rgb(hover_bg)).cursor_pointer()) - .on_click(cx.listener(move |_, _, _, cx| { - cx.emit(SidebarEvent::Navigate(request.clone())) - })) - .child(computer.label.clone()) - .child(computer.error.clone().unwrap_or_else(|| { - if computer.loading { - "Connecting…" + .hover(move |s| { + if dragging { + s } else { - "No projects yet" + s.bg(gpui::rgb(hover_bg)).cursor_pointer() + } + }) + .on_click(cx.listener(move |this, _, _, cx| { + if !this.toggle_computer_group(&request, cx) { + cx.emit(SidebarEvent::Navigate(request.clone())); } - .into() })) + .child( + div() + .flex() + .items_center() + .gap_2() + .when(collapsible, |row| { + row.child(icon( + if expanded { + "chevron-down" + } else { + "chevron-right" + }, + px(12.), + theme::text_muted(), + )) + }) + .child(icon("monitor", px(14.), theme::text_muted())) + .child(computer.label.clone()), + ) + .when_some(status, |row, status| row.child(status)) .into_any_element() } _ => div().into_any_element(), } } - - pub(super) fn computer_switcher_menu( - &self, - window: &mut Window, - cx: &mut Context, - ) -> Menu { - let mut menu = Menu::new("switcher-menu", px(330.)) - .label("Projects") - .max_height(px(440.)) - .application_vim(self.application_vim_enabled) - .item( - MenuItem::new(ALL_PROJECTS_ID, "All projects", |this: &mut Self, _, cx| { - this.set_project_filter(None, cx) - }) - .current(self.project_filter.is_none()), - ); - for (index, computer) in self.computers.iter().enumerate() { - menu = menu.child( - div() - .px_3() - .pt_2() - .pb_1() - .text_xs() - .text_color(gpui::rgb(theme::text_muted())) - .child(computer.label.clone()), - ); - let page = self.group_pages.hosts.get(&computer.key); - for root in &self.computer_switcher_roots[index] { - let target = root.target.clone(); - let own = target.computer.as_deref() == self.backend.remote_host(); - let group = root.row_group.clone(); - let mut item = MenuItem::new( - root.row_id.clone(), - root.name.clone(), - move |this: &mut Self, _, cx| { - if own { - this.set_project_filter(Some(target.path.clone()), cx); - } else { - cx.emit(SidebarEvent::Navigate(Navigate { - computer: target.computer.clone(), - destination: Destination::Project(target.path.clone()), - })); - } - }, - ) - .icon(if root.target.computer.is_some() { - "globe" - } else { - "folder" - }) - .current(own && self.project_filter.as_deref() == Some(root.root.as_str())) - .style(move |row| row.group(group)) - .trailing(self.project_menu_trigger(root, window, cx)); - if let Some(field) = self.project_rename_field(&root.target) { - item = item.content(field); - } - menu = menu.item(item); - } - if let Some(page) = page { - let loading = page.loading.contains("projects"); - let failed = page.errors.contains_key("projects"); - if page.next_projects.is_some() || loading || failed { - let action = ComputerMore { - computer: computer.key.clone(), - generation: self.group_pages.generation, - kind: ComputerPageKind::Projects, - cursor: page.next_projects.clone(), - label: "Load more projects".into(), - loading, - }; - menu = menu.item( - MenuItem::new( - SharedString::from(format!("switcher-computer-{index}-more-projects")), - if loading { - "Loading projects…" - } else if failed { - "Could not load projects — retry" - } else { - "Load more projects" - }, - move |this: &mut Self, _, cx| { - this.load_computer_page(action.clone(), cx) - }, - ) - .enabled(!loading), - ); - } - } - let request = Navigate { - computer: computer.key.clone(), - destination: Destination::NewProject, - }; - menu = menu.item( - MenuItem::new( - SharedString::from(format!("switcher-computer-{index}-new-project")), - "New project…", - move |_: &mut Self, _, cx| cx.emit(SidebarEvent::Navigate(request.clone())), - ) - .icon("folder-plus"), - ); - } - menu - } } #[cfg(test)] diff --git a/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs b/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs new file mode 100644 index 000000000..8a8f59eea --- /dev/null +++ b/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs @@ -0,0 +1,765 @@ +//! Account-owned sidebar order and direct manipulation. The same identities +//! are used by each grouping; dragging never mutates a task's owner or state. +use super::*; + +impl ReorderRow { + pub(super) fn target( + &self, + drag: &SidebarDrag, + grouping: SidebarGroup, + ) -> Option { + match &drag.item { + SidebarDragItem::Project(source) => self + .project + .as_ref() + .filter(|target| { + grouping != SidebarGroup::Connection || source.computer == target.computer + }) + .cloned() + .map(SidebarDragItem::Project), + SidebarDragItem::Connection(_) => { + self.connection.clone().map(SidebarDragItem::Connection) + } + SidebarDragItem::Task(_) if self.task_group == drag.group => { + self.task.clone().map(SidebarDragItem::Task) + } + _ => None, + } + } +} + +impl Sidebar { + pub(super) fn connection_grouping_available(&self) -> bool { + self.computers.iter().any(|computer| computer.key.is_some()) + } + + pub(super) fn load_order(&mut self, cx: &mut Context) { + if self.order_loaded || self.order_loading { + return; + } + self.order_loading = true; + let backend = self.backend.clone(); + let user = self.user_id.clone(); + self.call( + async move { backend.get_sidebar_order(&user).await }, + cx, + |this, result, cx| { + this.order_loading = false; + match result { + Ok(order) => { + this.order = + crate::settings::install_sidebar_order(this.user_id.clone(), order); + this.order_loaded = true; + this.rebuild_task_ranks(); + this.rebuild_sections(); + this.refresh_task_order(cx); + } + Err(message) => cx.emit(SidebarEvent::Notice(message.into())), + } + cx.notify(); + }, + ); + } + + pub(super) fn save_order(&self, cx: &mut Context) { + if !self.order_loaded { + return; + } + let saved = crate::settings::save_sidebar_order( + self.backend.clone(), + self.user_id.clone(), + self.order.clone(), + ); + self.call( + async move { + saved + .await + .map_err(|_| "Could not save sidebar order".to_string())? + }, + cx, + |_, result, cx| { + if let Err(message) = result { + cx.emit(SidebarEvent::Notice(message.into())); + } + }, + ); + } + + pub(super) fn rebuild_task_ranks(&mut self) { + self.task_ranks.clear(); + for (rank, task) in self.order.tasks.iter().enumerate() { + self.task_ranks + .entry(task.computer.clone()) + .or_default() + .insert(task.id.clone(), rank); + } + self.project_ranks.clear(); + for (rank, project) in self.order.projects.iter().enumerate() { + self.project_ranks + .entry(project.computer.clone()) + .or_default() + .insert(project.id.clone(), rank); + } + } + + pub(super) fn task_order_key( + &self, + computer: &Option, + id: &str, + created: i64, + updated: i64, + ) -> (u8, i64) { + if self.view.sort == SidebarSort::Updated { + return (0, updated.saturating_neg()); + } + if let Some(rank) = self + .task_ranks + .get(computer) + .and_then(|ranks| ranks.get(id)) + { + (1, *rank as i64) + } else if created > self.order.created_before_ms { + (0, created.saturating_neg()) + } else { + (2, created.saturating_neg()) + } + } + + pub(super) fn manual_task_order( + &self, + computer: &Option, + ) -> Option { + (self.view.sort == SidebarSort::Manual && self.order_loaded).then(|| { + maple_agent::store::NavigationManualOrder { + created_before_ms: self.order.created_before_ms, + tasks: self + .order + .tasks + .iter() + .filter(|task| &task.computer == computer) + .map(|task| task.id.clone()) + .collect(), + } + }) + } + + pub(super) fn project_order_rank(&self, target: &ProjectTarget) -> usize { + self.project_ranks + .get(&target.computer) + .and_then(|ranks| ranks.get(&target.path)) + .copied() + .unwrap_or(usize::MAX) + } + + fn sync_cached_order(&mut self) { + if let Some(order) = crate::settings::cached_sidebar_order(&self.user_id) + && self.order != order + { + if self.view.sort == SidebarSort::Manual + && (self.order.tasks != order.tasks + || self.order.created_before_ms != order.created_before_ms) + { + self.order_query_dirty = true; + } + self.order = order; + self.rebuild_task_ranks(); + } + } + + pub(super) fn sync_container_order(&mut self) { + if !self.order_loaded { + return; + } + self.sync_cached_order(); + let mut changed = false; + let mut known = self.order.projects.iter().cloned().collect::>(); + let mut bytes = serde_json::to_vec(&self.order).map_or(usize::MAX, |value| value.len()); + let projects = self + .project_roots + .iter() + .map(|root| root.target.clone()) + .chain( + self.computer_project_roots + .iter() + .flatten() + .map(|root| root.target.clone()), + ) + .collect::>(); + for target in projects { + let key = SidebarItemKey { + computer: target.computer, + id: target.path, + }; + let extra = + serde_json::to_vec(&key).map_or(usize::MAX, |value| value.len().saturating_add(1)); + if !known.contains(&key) + && self.order.projects.len() < 8192 + && bytes.saturating_add(extra) <= 2 * 1024 * 1024 + { + bytes += extra; + known.insert(key.clone()); + self.order.projects.push(key); + changed = true; + } + } + for computer in self.computers.iter() { + let extra = serde_json::to_vec(&computer.key) + .map_or(usize::MAX, |value| value.len().saturating_add(1)); + if !self.order.connections.contains(&computer.key) + && self.order.connections.len() < 256 + && bytes.saturating_add(extra) <= 2 * 1024 * 1024 + { + bytes += extra; + self.order.connections.push(computer.key.clone()); + changed = true; + } + } + if changed { + self.rebuild_task_ranks(); + // The settings writer owns retries and the Quit barrier, even if + // this projection is replaced before its acknowledgement arrives. + drop(crate::settings::save_sidebar_order( + self.backend.clone(), + self.user_id.clone(), + self.order.clone(), + )); + } + } + + pub(super) fn set_view(&mut self, mut view: SidebarView, cx: &mut Context) { + self.sync_cached_order(); + if view.group == SidebarGroup::Connection && !self.connection_grouping_available() { + view.group = SidebarGroup::Project; + } + if self.view == view { + return; + } + if view.sort == SidebarSort::Manual && !self.order_loaded { + self.load_order(cx); + return; + } + if view.sort == SidebarSort::Manual && self.order.created_before_ms == 0 { + let previous = self.order.clone(); + self.seed_task_order(); + if !order_fits(&self.order) { + self.order = previous; + self.rebuild_task_ranks(); + cx.emit(SidebarEvent::Notice( + "This sidebar order is too large to save".into(), + )); + return; + } + self.save_order(cx); + } + let sort_changed = self.view.sort != view.sort; + self.view = view; + crate::settings::update_settings_in_background(move |settings| { + settings.sidebar_layout = view + }); + if sort_changed { + self.refresh_task_order(cx); + } + self.ensure_computer_pages(cx); + self.rebuild_sections(); + self.list.remeasure_items(0..self.entries.len()); + cx.notify(); + } + + pub(super) fn can_reorder(&self) -> bool { + self.order_loaded && self.filter.is_empty() && self.rename.is_none() + } + + fn task_group( + &self, + target: ProjectTarget, + id: &str, + state: AgentTaskState, + pinned: bool, + ) -> TaskGroup { + if pinned && !self.uses_computer_projection() { + TaskGroup::Pinned + } else if state == AgentTaskState::Archived { + TaskGroup::Archived + } else { + match self.view.group { + SidebarGroup::Project | SidebarGroup::Connection => TaskGroup::Project(target), + SidebarGroup::Status => { + let running = if target.computer.as_deref() == self.backend.remote_host() { + self.running.contains(id) + } else { + self.computers + .iter() + .find(|computer| computer.key == target.computer) + .is_some_and(|computer| computer.running.contains(id)) + }; + TaskGroup::Status(state == AgentTaskState::Settled && !running) + } + SidebarGroup::List => TaskGroup::List, + } + } + } + + pub(super) fn rebuild_reorder_rows(&mut self) { + let mut rows = Vec::with_capacity(self.entries.len()); + for entry in &self.entries { + let mut row = ReorderRow::default(); + match *entry { + SidebarEntry::SectionLabel(SidebarSection::Project(index)) + | SidebarEntry::ProjectEmpty(index) => { + row.project = self + .project_roots + .get(index) + .map(|root| root.target.clone()); + } + SidebarEntry::ComputerProject(index) => { + row.project = self + .computer_projects + .get(index) + .map(|root| root.target.clone()); + } + SidebarEntry::Computer(index) => { + if self.view.group == SidebarGroup::Connection { + row.connection = self + .computers + .get(index) + .map(|computer| computer.key.clone()); + } + } + SidebarEntry::Task(task) => { + if let Some(session) = self.sessions.get(task.session) { + let target = self.project_target(&session.project_root); + let key = SidebarItemKey { + computer: target.computer.clone(), + id: session.id.clone(), + }; + row.task_group = Some(self.task_group( + target.clone(), + &session.id, + session.state, + task.pinned, + )); + row.task = Some(key); + if matches!( + self.view.group, + SidebarGroup::Project | SidebarGroup::Connection + ) && !task.archived + { + row.project = Some(target); + } + } + } + SidebarEntry::OtherTask(index) => { + if let Some(task) = self.other_tasks.get(index) { + let target = ProjectTarget { + computer: task.request.computer.clone(), + path: task.session.project_root.clone(), + }; + row.task_group = Some(self.task_group( + target.clone(), + &task.session.id, + task.session.state, + task.pinned, + )); + row.task = Some(SidebarItemKey { + computer: target.computer.clone(), + id: task.session.id.clone(), + }); + if matches!( + self.view.group, + SidebarGroup::Project | SidebarGroup::Connection + ) && !task.archived + { + row.project = Some(target); + } + } + } + SidebarEntry::ComputerMore(index) => { + if let Some((computer, root)) = self.computer_more_scope(index) { + row.project = root.map(|path| ProjectTarget { + computer: computer.clone(), + path, + }); + if self.view.group == SidebarGroup::Connection { + row.connection = Some(computer); + } + } + } + _ => {} + } + if self.view.group == SidebarGroup::Connection && row.connection.is_none() { + row.connection = row.project.as_ref().map(|project| project.computer.clone()); + } + rows.push(row); + } + self.drag_ranges.clear(); + for (index, row) in rows.iter().enumerate() { + let items = row + .project + .clone() + .map(SidebarDragItem::Project) + .into_iter() + .chain(row.connection.clone().map(SidebarDragItem::Connection)) + .chain(row.task.clone().map(SidebarDragItem::Task)); + for item in items { + self.drag_ranges + .entry(item) + .and_modify(|range| range.1 = index) + .or_insert((index, index)); + } + } + self.reorder_rows = rows; + } + + pub(super) fn drag_for_row(&self, row: usize, sidebar: gpui::EntityId) -> Option { + if !self.can_reorder() { + return None; + } + let item = self.entries.get(row)?; + let (item, name, icon) = match *item { + SidebarEntry::SectionLabel(SidebarSection::Project(index)) + if self.project_filter.is_none() => + { + let root = self.project_roots.get(index)?; + ( + SidebarDragItem::Project(root.target.clone()), + root.name.clone(), + "folder", + ) + } + SidebarEntry::ComputerProject(index) if self.project_filter.is_none() => { + let root = self.computer_projects.get(index)?; + ( + SidebarDragItem::Project(root.target.clone()), + root.name.clone(), + "folder", + ) + } + SidebarEntry::Computer(index) + if self.view.group == SidebarGroup::Connection && self.project_filter.is_none() => + { + let computer = self.computers.get(index)?; + ( + SidebarDragItem::Connection(computer.key.clone()), + computer.label.clone(), + "monitor", + ) + } + SidebarEntry::Task(task) => { + let session = self.sessions.get(task.session)?; + ( + SidebarDragItem::Task(self.reorder_rows.get(row)?.task.clone()?), + session.title.clone().into(), + "message-square", + ) + } + SidebarEntry::OtherTask(index) => { + let task = self.other_tasks.get(index)?; + ( + SidebarDragItem::Task(self.reorder_rows.get(row)?.task.clone()?), + task.session.title.clone().into(), + "message-square", + ) + } + _ => return None, + }; + Some(SidebarDrag { + sidebar, + item, + group: self.reorder_rows.get(row)?.task_group.clone(), + name, + icon, + }) + } + + fn drag_sequence(&self, drag: &SidebarDrag) -> Vec { + let mut seen = HashSet::new(); + self.reorder_rows + .iter() + .filter_map(|row| row.target(drag, self.view.group)) + .filter(|item| seen.insert(item.clone())) + .collect() + } + + pub(super) fn set_drop_target(&mut self, next: Option, cx: &mut Context) { + if self.drop_target != next { + self.drop_target = next; + cx.notify(); + } + } + + pub(super) fn update_drop_target( + &mut self, + row: usize, + position: gpui::Point, + bounds: gpui::Bounds, + drag: &SidebarDrag, + cx: &mut Context, + ) { + let next = (|| { + if drag.sidebar != cx.entity_id() || !self.can_reorder() || !bounds.contains(&position) + { + return None; + } + let target = self.reorder_rows.get(row)?.target(drag, self.view.group)?; + let (first, last) = *self.drag_ranges.get(&target)?; + let after = if row == first { + position.y >= bounds.center().y + } else { + true + }; + let sequence = self.drag_sequence(drag); + let from = sequence.iter().position(|item| item == &drag.item)?; + let to = sequence.iter().position(|item| item == &target)? + usize::from(after); + if to == from || to == from + 1 { + return None; + } + Some(SidebarDrop { + target, + after, + hover_row: row, + line_row: if after { last } else { first }, + }) + })(); + self.set_drop_target(next, cx); + } + + pub(super) fn finish_sidebar_drag(&mut self, cx: &mut Context) { + if self.dragging || self.drop_target.is_some() { + self.dragging = false; + self.drop_target = None; + cx.notify(); + } + } + + pub(in crate::ui::chat) fn cancel_sidebar_drag( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> bool { + if !self.dragging { + return false; + } + cx.stop_active_drag(window); + self.finish_sidebar_drag(cx); + true + } + + fn seed_task_order(&mut self) { + let computer = self.backend.remote_host().map(str::to_owned); + let mut seen = HashSet::new(); + let visible = self + .entries + .iter() + .filter_map(|entry| match *entry { + SidebarEntry::Task(task) => { + self.sessions + .get(task.session) + .map(|session| SidebarItemKey { + computer: computer.clone(), + id: session.id.clone(), + }) + } + SidebarEntry::OtherTask(index) => { + self.other_tasks.get(index).map(|task| SidebarItemKey { + computer: task.request.computer.clone(), + id: task.session.id.clone(), + }) + } + _ => None, + }) + .filter(|key| seen.insert(key.clone())) + .collect::>(); + let remaining = self + .order + .tasks + .iter() + .filter(|key| !seen.contains(*key)) + .cloned() + .collect::>(); + self.order.tasks = visible.into_iter().chain(remaining).collect(); + self.order.created_before_ms = crate::settings::timestamp_ms(); + self.rebuild_task_ranks(); + } + + pub(super) fn commit_reorder( + &mut self, + drag: &SidebarDrag, + target: &SidebarDragItem, + after: bool, + cx: &mut Context, + ) { + if !self.can_reorder() { + return; + } + self.sync_cached_order(); + let sequence = self.drag_sequence(drag); + let Some(from) = sequence.iter().position(|item| item == &drag.item) else { + return; + }; + let Some(to) = sequence.iter().position(|item| item == target) else { + return; + }; + let insertion = to + usize::from(after); + if insertion == from || insertion == from + 1 { + return; + } + let previous_order = self.order.clone(); + let previous_view = self.view; + let task_move = matches!(drag.item, SidebarDragItem::Task(_)); + if task_move && self.order.created_before_ms == 0 { + self.seed_task_order(); + } + match (&drag.item, target) { + (SidebarDragItem::Project(source), SidebarDragItem::Project(target)) => { + let source = SidebarItemKey { + computer: source.computer.clone(), + id: source.path.clone(), + }; + let target = SidebarItemKey { + computer: target.computer.clone(), + id: target.path.clone(), + }; + move_item(&mut self.order.projects, &source, &target, after); + } + (SidebarDragItem::Connection(source), SidebarDragItem::Connection(target)) => { + move_item(&mut self.order.connections, source, target, after) + } + (SidebarDragItem::Task(source), SidebarDragItem::Task(target)) => { + let mut desired = sequence + .iter() + .filter_map(|item| match item { + SidebarDragItem::Task(key) => Some(key.clone()), + _ => None, + }) + .collect::>(); + move_item(&mut desired, source, target, after); + self.order.tasks = merge_task_subset(&self.order.tasks, &desired); + self.view.sort = SidebarSort::Manual; + self.rebuild_task_ranks(); + } + _ => return, + } + if !order_fits(&self.order) { + self.order = previous_order; + self.view = previous_view; + self.rebuild_task_ranks(); + cx.emit(SidebarEvent::Notice( + "This sidebar order is too large to save".into(), + )); + return; + } + if self.view != previous_view { + let view = self.view; + crate::settings::update_settings_in_background(move |settings| { + settings.sidebar_layout = view + }); + } + self.save_order(cx); + self.close_popups(cx); + self.rebuild_sections(); + if task_move { + self.refresh_task_order(cx); + } + cx.notify(); + } + + pub(super) fn project_move_available(&self, target: &ProjectTarget, forward: bool) -> bool { + let mut seen = HashSet::new(); + let projects = self + .reorder_rows + .iter() + .filter_map(|row| row.project.as_ref()) + .filter(|project| { + self.view.group != SidebarGroup::Connection || project.computer == target.computer + }) + .filter(|project| seen.insert((*project).clone())) + .collect::>(); + projects + .iter() + .position(|project| *project == target) + .is_some_and(|position| { + if forward { + position + 1 < projects.len() + } else { + position > 0 + } + }) + } + + pub(super) fn move_project( + &mut self, + target: &ProjectTarget, + forward: bool, + cx: &mut Context, + ) { + let drag = SidebarDrag { + sidebar: cx.entity_id(), + item: SidebarDragItem::Project(target.clone()), + group: None, + name: self.project_name(target).into(), + icon: "folder", + }; + let sequence = self.drag_sequence(&drag); + let Some(position) = sequence.iter().position(|item| item == &drag.item) else { + return; + }; + let adjacent = if forward { + sequence.get(position + 1) + } else { + position + .checked_sub(1) + .and_then(|index| sequence.get(index)) + }; + if let Some(adjacent) = adjacent { + self.commit_reorder(&drag, adjacent, forward, cx); + } + } +} + +fn move_item(items: &mut Vec, source: &T, target: &T, after: bool) { + let Some(from) = items.iter().position(|item| item == source) else { + return; + }; + let Some(to) = items.iter().position(|item| item == target) else { + return; + }; + let to = to + usize::from(after); + let to = to - usize::from(from < to); + if from != to { + let item = items.remove(from); + items.insert(to, item); + } +} + +fn order_fits(order: &SidebarOrder) -> bool { + order.tasks.len() <= 8192 + && order.projects.len() <= 8192 + && order.connections.len() <= 256 + && serde_json::to_vec(order).is_ok_and(|json| json.len() <= 2 * 1024 * 1024) +} + +/// Replace only the visible group's slots. Other groups and folded/unloaded +/// tasks keep their relative arrangement, even after switching grouping views. +fn merge_task_subset(saved: &[SidebarItemKey], desired: &[SidebarItemKey]) -> Vec { + let subset = desired.iter().collect::>(); + let mut slots = saved.iter().filter(|key| subset.contains(key)).count(); + if slots == 0 { + return desired.iter().chain(saved).cloned().collect(); + } + let mut remaining = desired.iter(); + let mut result = Vec::with_capacity(saved.len() + desired.len()); + for key in saved { + if subset.contains(key) { + if let Some(next) = remaining.next() { + result.push(next.clone()); + } + slots -= 1; + if slots == 0 { + result.extend(remaining.by_ref().cloned()); + } + } else { + result.push(key.clone()); + } + } + result +} 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 01332cacf..8455ae60e 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_paging.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_paging.rs @@ -108,7 +108,7 @@ mod tests { sidebar.pages.enabled = true; sidebar.set_application_vim_enabled(true, cx); sidebar.set_project_name_for_test("/empty", "Empty"); - let note = SidebarEntry::FolderEmpty(0); + let note = SidebarEntry::ProjectEmpty(0); assert!( !sidebar.entries.contains(¬e), "unrequested pages are unknown" @@ -146,9 +146,9 @@ mod tests { sidebar.rebuild_sections(); assert!(!sidebar.entries.contains(¬e)); sidebar.pages.pin_batches.clear(); - sidebar.set_folder_expanded("/empty", Some(false), cx); + sidebar.set_project_expanded("/empty", Some(false), cx); assert!(!sidebar.entries.contains(¬e)); - sidebar.set_folder_expanded("/empty", Some(true), cx); + sidebar.set_project_expanded("/empty", Some(true), cx); assert!(sidebar.entries.contains(¬e)); sidebar.set_projects_expanded(false, cx); assert!(!sidebar.entries.contains(¬e)); @@ -235,7 +235,7 @@ mod tests { assert_eq!(shown, ["b"]); assert!(sidebar.pinned_rows.is_empty()); assert!(sidebar.archived_rows.is_empty()); - assert!(sidebar.folder_names().is_empty()); + assert!(sidebar.grouped_project_names().is_empty()); assert_eq!(sidebar.active_rows(), [2]); assert!( sidebar @@ -252,7 +252,7 @@ mod tests { 2, "only live A tasks survive refresh" ); - assert_eq!(sidebar.folder_names(), ["a"]); + assert_eq!(sidebar.grouped_project_names(), ["a"]); sidebar.set_computers( Arc::new(vec![ Computer::pending("one".into(), "One".into(), None), @@ -294,11 +294,11 @@ mod tests { cx, ); sidebar.rebuild_sections(); - assert_eq!(sidebar.folder_names(), ["a"]); + assert_eq!(sidebar.grouped_project_names(), ["a"]); assert!( sidebar .entries - .contains(&SidebarEntry::SectionLabel(SidebarSection::Folder(0))) + .contains(&SidebarEntry::SectionLabel(SidebarSection::Project(0))) ); assert!( sidebar @@ -314,14 +314,14 @@ mod tests { cx, ); sidebar.rebuild_sections(); - assert_eq!(sidebar.folder_names(), ["a", "b"]); + assert_eq!(sidebar.grouped_project_names(), ["a", "b"]); assert_eq!( sidebar .entries .iter() .filter(|entry| matches!( entry, - SidebarEntry::SectionLabel(SidebarSection::Folder(_)) + SidebarEntry::SectionLabel(SidebarSection::Project(_)) )) .count(), 2 @@ -332,7 +332,7 @@ mod tests { .iter() .any(|entry| matches!(entry, SidebarEntry::Empty(_))) ); - for group in [SidebarGroup::Status, SidebarGroup::Recent] { + for group in [SidebarGroup::Status, SidebarGroup::List] { sidebar.view.group = group; sidebar.rebuild_sections(); assert!( @@ -341,7 +341,7 @@ mod tests { .contains(&SidebarEntry::Empty(SidebarEmpty::NoTasks)) ); } - sidebar.view.group = SidebarGroup::Folder; + sidebar.view.group = SidebarGroup::Project; sidebar.apply_task_page( PageKind::Active, vec![TaskSummary { @@ -351,11 +351,11 @@ mod tests { false, ); sidebar.rebuild_sections(); - assert_eq!(sidebar.folder_names(), ["b", "a"]); + assert_eq!(sidebar.grouped_project_names(), ["b", "a"]); sidebar.set_filter_for_test("a", cx); - assert_eq!(sidebar.folder_names(), ["a"]); + assert_eq!(sidebar.grouped_project_names(), ["a"]); sidebar.set_filter_for_test("unmatched", cx); - assert!(sidebar.folder_names().is_empty()); + assert!(sidebar.grouped_project_names().is_empty()); }); } @@ -409,7 +409,7 @@ mod tests { } sidebar.archived_expanded = true; sidebar.rebuild_sections(); - assert_eq!(sidebar.folder_names(), ["a", "b"]); + assert_eq!(sidebar.grouped_project_names(), ["a", "b"]); assert_eq!(sidebar.pinned_rows.len(), 1); assert_eq!(sidebar.archived_rows.len(), 1); sidebar.apply_task_page( @@ -418,7 +418,7 @@ mod tests { true, ); sidebar.rebuild_sections(); - assert_eq!(sidebar.folder_names(), ["a", "b", "c"]); + assert_eq!(sidebar.grouped_project_names(), ["a", "b", "c"]); assert_eq!( sidebar .sections @@ -463,10 +463,10 @@ mod tests { sidebar.toggle_section_expanded(SidebarSection::Active, cx); assert!(!more(sidebar).contains(&PageKind::Active)); assert!(more(sidebar).contains(&PageKind::Settled)); - sidebar.view.group = SidebarGroup::Recent; + sidebar.view.group = SidebarGroup::List; sidebar.rebuild_sections(); assert!(more(sidebar).contains(&PageKind::Active)); - assert_eq!(sidebar.section_rows(SidebarSection::Recents).len(), 4); + assert_eq!(sidebar.section_rows(SidebarSection::List).len(), 4); }); } @@ -587,6 +587,7 @@ pub(super) struct SidebarPages { deleted: HashSet, pin_batches: VecDeque>, project_generation: u64, + retain_loaded: bool, } impl SidebarPages { @@ -733,6 +734,34 @@ impl Sidebar { /// Also used after reconnect: deeper pages are discarded and visible first /// pages are re-authorized on the current host/account. + /// Ordering changes invalidate cursors, while retaining already visible + /// membership until the new ordered pages extend it. This keeps a drop in + /// a later loaded page visible and avoids discarding its scroll anchor. + pub(super) fn refresh_task_order(&mut self, cx: &mut Context) { + self.order_query_dirty = false; + if self.pages.enabled { + self.pages.generation = self.pages.generation.wrapping_add(1); + self.pages.next.clear(); + self.pages.loading.clear(); + self.pages.started.clear(); + self.pages.failed.clear(); + self.pages.ready.clear(); + self.pages.search = None; + self.pages.retain_loaded = true; + if self.filter.is_empty() { + self.load_page(PageKind::Active, cx); + self.load_page(PageKind::Settled, cx); + self.load_page(PageKind::Pinned, cx); + if self.archived_expanded { + self.load_page(PageKind::Archived, cx); + } + } else { + self.load_page(PageKind::Search, cx); + } + } + self.refresh_computer_pages(cx); + } + pub(super) fn refresh_pages(&mut self, cx: &mut Context) { self.refresh_pages_with_display(false, cx); } @@ -902,6 +931,10 @@ impl Sidebar { } pub(super) fn load_page(&mut self, kind: PageKind, cx: &mut Context) { + if self.order_query_dirty { + self.refresh_task_order(cx); + return; + } if !self.pages.enabled || self.pages.loading.contains(&kind) { return; } @@ -920,7 +953,7 @@ impl Sidebar { return; } let cursor = self.pages.next.get(&kind).cloned(); - let append = self.pages.started.contains(&kind); + let append = self.pages.retain_loaded || self.pages.started.contains(&kind); let generation = self.pages.generation; self.pages.failed.remove(&kind); self.pages.loading.insert(kind); @@ -929,6 +962,7 @@ impl Sidebar { let user = self.user_id.clone(); let project = self.project_filter.clone(); let filter = self.filter.clone(); + let manual = self.manual_task_order(&self.backend.remote_host().map(str::to_owned)); let search = if kind == PageKind::Search { if self.pages.search.is_none() { let roots = self @@ -938,6 +972,7 @@ impl Sidebar { .map(|(root, _)| root.clone()) .collect(); self.pages.search = Some(NavigationSearchQuery { + manual: manual.clone(), text: filter, project: project.clone(), project_roots: roots, @@ -967,6 +1002,7 @@ impl Sidebar { .pinned_tasks( &user, crate::backend::PinnedTasksRequest { + manual: manual.clone(), project, cursor, limit: 200, @@ -983,6 +1019,7 @@ impl Sidebar { .list_tasks( &user, NavigationTaskQuery { + manual, section, project, pins, diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 87685bfcc..da61042f0 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -240,12 +240,15 @@ mod state_tests { this.sidebar.update(cx, |sidebar, cx| { sidebar.set_view(crate::settings::SidebarView::default(), cx); sidebar.set_project_filter(Some("/tmp/proj".into()), cx); - assert!(sidebar.folder_names().is_empty()); + assert!(sidebar.grouped_project_names().is_empty()); }); let local = this.computer_snapshot("This computer".into(), cx); this.set_computers(Arc::new(vec![local]), cx); assert_eq!(this.sidebar.read(cx).project_filter(), None); - assert_eq!(this.sidebar.read(cx).folder_names(), ["proj", "other"]); + assert_eq!( + this.sidebar.read(cx).grouped_project_names(), + ["proj", "other"] + ); assert!(!this.sidebar.read(cx).entries().iter().any(|e| matches!( e, SidebarEntry::OtherTask(_) | SidebarEntry::ComputerProject(_) @@ -5374,7 +5377,7 @@ mod state_tests { this.sync_sidebar(cx); // Saved roots keep their order; roots only tasks know about append // alphabetically so their position never moves. - assert_eq!(this.switcher_root_paths(cx), ["/z", "/a", "/m"]); + assert_eq!(this.project_root_paths(cx), ["/z", "/a", "/m"]); }); } @@ -5436,7 +5439,7 @@ mod state_tests { assert_eq!(this.sidebar_active(cx), vec![1]); assert!(this.sidebar_settled(cx).is_empty()); // The draft's project still feeds the switcher. - assert_eq!(this.switcher_root_paths(cx), ["/a"]); + assert_eq!(this.project_root_paths(cx), ["/a"]); // A first run is visible before the cached count catches up. this.handle_run_event("s1", "run-1", AgentRunEvent::Started, cx); @@ -5467,12 +5470,11 @@ mod state_tests { }); } - /// Grouped by folder, each project heads its own tasks, the project - /// with the latest activity first. Pinned tasks stay on top and - /// archived ones in their own tail, and rows drop the project line the - /// header already shows. + /// Each project heads its own tasks in its initial stable container + /// order. Pinned tasks stay on top, archived ones stay in their own tail, + /// and project headings provide the context for their task rows. #[gpui::test] - fn test_folder_grouping_heads_each_project(cx: &mut TestAppContext) { + fn test_project_grouping_keeps_container_order(cx: &mut TestAppContext) { use crate::settings::SidebarView; use crate::ui::chat::sidebar::{SidebarEntry, SidebarSection}; cx.executor().allow_parking(); @@ -5497,10 +5499,10 @@ mod state_tests { assert!(sidebar.show_metadata_for_test()); sidebar.set_view(SidebarView::default(), cx); assert!(!sidebar.show_metadata_for_test()); - assert_eq!(sidebar.folder_names(), ["b", "a"]); + assert_eq!(sidebar.grouped_project_names(), ["a", "b"]); assert_eq!(sidebar.pinned_rows(), [3]); - assert_eq!(sidebar.section_rows(SidebarSection::Folder(1)), [1]); - assert_eq!(sidebar.section_rows(SidebarSection::Folder(0)), [2, 0]); + assert_eq!(sidebar.section_rows(SidebarSection::Project(1)), [1]); + assert_eq!(sidebar.section_rows(SidebarSection::Project(0)), [2, 0]); assert_eq!(sidebar.archived_rows(), [4]); let task = |session| { SidebarEntry::Task(crate::ui::chat::sidebar::SidebarTaskEntry { @@ -5519,31 +5521,32 @@ mod state_tests { SidebarEntry::SectionLabel(SidebarSection::Pinned), task(3), SidebarEntry::ProjectsHeader, - SidebarEntry::SectionLabel(SidebarSection::Folder(1)), - task(1), - SidebarEntry::SectionLabel(SidebarSection::Folder(0)), + SidebarEntry::SectionLabel(SidebarSection::Project(0)), task(2), task(0), + SidebarEntry::SectionLabel(SidebarSection::Project(1)), + task(1), SidebarEntry::ArchivedHeader, ] ); sidebar.set_pinned_tasks_for_test(Vec::new()); - assert_eq!(sidebar.folder_names(), ["b", "a"]); + assert_eq!(sidebar.grouped_project_names(), ["a", "b"]); sidebar.set_pinned_tasks_for_test(vec!["s4".into()]); - assert_eq!(sidebar.folder_names(), ["b", "a"]); + assert_eq!(sidebar.grouped_project_names(), ["a", "b"]); }); }); } - /// Metadata can be shown over Folder defaults; Recent is one sorted - /// list below Pinned, with its own heading and Archived at the tail. + /// Project headings supply task context; the flat list supplies it on + /// each row, with pinned work above and archived work at the tail. #[gpui::test] - fn test_metadata_and_recent_grouping(cx: &mut TestAppContext) { + fn test_project_and_list_grouping_choose_contextual_labels(cx: &mut TestAppContext) { use crate::settings::{SidebarGroup, SidebarView}; use crate::ui::chat::sidebar::{SidebarEntry, SidebarSection}; cx.executor().allow_parking(); let screen = screen(cx); screen.update(cx, |this, cx| { + this.apply_recent_roots(vec![recent_root("/empty")], cx); this.sessions = vec![ summary("s1", "One"), summary("s2", "Two"), @@ -5552,32 +5555,23 @@ mod state_tests { this.sessions[0].updated_ms = 1; this.sessions[1].updated_ms = 2; this.sessions[2].state = AgentTaskState::Archived; - this.apply_recent_roots(vec![recent_root("/empty")], cx); this.sync_sidebar(cx); this.sidebar.update(cx, |sidebar, cx| { sidebar.set_view(SidebarView::default(), cx); - assert_eq!(sidebar.folder_names(), ["proj", "empty"]); + assert_eq!(sidebar.grouped_project_names(), ["empty", "proj"]); sidebar.set_filter_for_test("one", cx); - assert_eq!(sidebar.folder_names(), ["proj"]); + assert_eq!(sidebar.grouped_project_names(), ["proj"]); sidebar.set_filter_for_test("", cx); assert!(!sidebar.show_metadata_for_test()); - sidebar.set_view( - SidebarView { - metadata: Some(true), - ..SidebarView::default() - }, - cx, - ); - assert!(sidebar.show_metadata_for_test()); sidebar.set_pinned_tasks_for_test(vec!["s1".into()]); sidebar.set_view( SidebarView { - group: SidebarGroup::Recent, + group: SidebarGroup::List, ..SidebarView::default() }, cx, ); - assert_eq!(sidebar.section_rows(SidebarSection::Recents), [1]); + assert_eq!(sidebar.section_rows(SidebarSection::List), [1]); assert_eq!(sidebar.pinned_rows(), [0]); assert!(sidebar.show_metadata_for_test()); assert!(matches!( @@ -5606,7 +5600,7 @@ mod state_tests { this.sidebar.update(cx, |sidebar, cx| { sidebar.set_pinned_tasks_for_test(vec!["s3".to_string()]); sidebar.set_view(SidebarView::default(), cx); - sidebar.set_folder_expanded("/a", Some(false), cx); + sidebar.set_project_expanded("/a", Some(false), cx); }); this.sessions.push(summary_at("s4", "Four", "/a")); this.sync_sidebar(cx); @@ -5626,7 +5620,7 @@ mod state_tests { this.sidebar .read(cx) .entries() - .contains(&SidebarEntry::SectionLabel(SidebarSection::Folder(0))) + .contains(&SidebarEntry::SectionLabel(SidebarSection::Project(0))) ); this.sidebar .update(cx, |sidebar, cx| sidebar.set_projects_expanded(false, cx)); @@ -5634,101 +5628,113 @@ mod state_tests { assert!( !this.sidebar.read(cx).entries().iter().any(|entry| matches!( entry, - SidebarEntry::SectionLabel(SidebarSection::Folder(_)) | SidebarEntry::Empty(_) + SidebarEntry::SectionLabel(SidebarSection::Project(_)) | SidebarEntry::Empty(_) )) ); }); } - /// Every sort key and direction orders Recent, Pinned and Archived, - /// preserving input order for equal keys, including case-insensitive names. + /// Manual task ordering snapshots all visible sections and stays stable + /// through activity changes; Last updated then restores activity order. #[gpui::test] - fn test_sort_and_direction_order_each_section(cx: &mut TestAppContext) { - use crate::settings::SidebarSort::{Created, Name, Updated}; - use crate::settings::{SidebarGroup, SidebarView}; + fn test_manual_task_order_survives_activity_changes(cx: &mut TestAppContext) { + use crate::settings::{SidebarGroup, SidebarSort, SidebarView}; use crate::ui::chat::sidebar::SidebarSection; cx.executor().allow_parking(); let screen = screen(cx); screen.update(cx, |this, cx| { - let at = |id, title, updated_ms, created_ms| AgentSessionSummary { + let at = |id, updated_ms, created_ms| AgentSessionSummary { updated_ms, created_ms, - ..summary(id, title) + ..summary(id, id) }; - let archived = |id, title, updated_ms, created_ms| AgentSessionSummary { + let archived = |id, updated_ms, created_ms| AgentSessionSummary { state: AgentTaskState::Archived, - ..at(id, title, updated_ms, created_ms) + ..at(id, updated_ms, created_ms) }; this.sessions = vec![ - at("s1", "Beta", 1, 3), - at("s2", "alpha", 2, 1), - at("s3", "Gamma", 3, 2), - at("s4", "BETA", 1, 3), - archived("s5", "Delta", 4, 5), - archived("s6", "epsilon", 5, 4), - archived("s7", "delta", 4, 5), - at("p1", "zeta", 6, 8), - at("p2", "ZETA", 6, 8), - at("p3", "eta", 7, 7), + at("s1", 1, 3), + at("s2", 2, 1), + at("s3", 3, 2), + at("s4", 1, 3), + archived("s5", 4, 5), + archived("s6", 5, 4), + archived("s7", 4, 5), + at("p1", 6, 8), + at("p2", 6, 8), + at("p3", 7, 7), ]; this.sync_sidebar(cx); this.sidebar.update(cx, |sidebar, cx| { sidebar.set_pinned_tasks_for_test(vec!["p1".into(), "p2".into(), "p3".into()]); - for (sort, ascending, listed, archived, pinned) in [ - (Updated, false, [2, 1, 0, 3], [5, 4, 6], [9, 7, 8]), - (Updated, true, [0, 3, 1, 2], [4, 6, 5], [7, 8, 9]), - (Created, false, [0, 3, 2, 1], [4, 6, 5], [7, 8, 9]), - (Created, true, [1, 2, 0, 3], [5, 4, 6], [9, 7, 8]), - (Name, false, [2, 0, 3, 1], [5, 4, 6], [7, 8, 9]), - (Name, true, [1, 0, 3, 2], [4, 6, 5], [9, 7, 8]), - ] { - sidebar.set_view( - SidebarView { - group: SidebarGroup::Recent, - sort, - ascending, - ..SidebarView::default() - }, - cx, - ); - assert_eq!( - sidebar.section_rows(SidebarSection::Recents), - listed, - "Recent: {sort:?}, ascending={ascending}" - ); - assert_eq!( - sidebar.archived_rows(), - archived, - "Archived: {sort:?}, ascending={ascending}" - ); - assert_eq!( - sidebar.pinned_rows(), - pinned, - "Pinned: {sort:?}, ascending={ascending}" - ); - } + sidebar.set_archived_expanded(true, cx); + sidebar.set_view( + SidebarView { + group: SidebarGroup::List, + sort: SidebarSort::Updated, + }, + cx, + ); + assert_eq!(sidebar.section_rows(SidebarSection::List), [2, 1, 0, 3]); + assert_eq!(sidebar.archived_rows(), [5, 4, 6]); + assert_eq!(sidebar.pinned_rows(), [9, 7, 8]); + sidebar.set_view( + SidebarView { + group: SidebarGroup::List, + sort: SidebarSort::Manual, + }, + cx, + ); + }); + this.sessions[0].updated_ms = 100; + this.sessions[4].updated_ms = 101; + this.sessions[7].updated_ms = 102; + this.sync_sidebar(cx); + this.sidebar.update(cx, |sidebar, cx| { + assert_eq!(sidebar.section_rows(SidebarSection::List), [2, 1, 0, 3]); + assert_eq!(sidebar.archived_rows(), [5, 4, 6]); + assert_eq!(sidebar.pinned_rows(), [9, 7, 8]); + sidebar.set_view( + SidebarView { + group: SidebarGroup::List, + sort: SidebarSort::Updated, + }, + cx, + ); + assert_eq!(sidebar.section_rows(SidebarSection::List), [0, 2, 1, 3]); + assert_eq!(sidebar.archived_rows(), [4, 5, 6]); + assert_eq!(sidebar.pinned_rows(), [7, 9, 8]); }); }); } - /// Equal-activity folders keep recent-root order, then path order, - /// even when the task list first encounters a different project. + /// Initial project order follows recent roots, then unseen roots by path, + /// and later task activity leaves those container positions intact. #[gpui::test] - fn test_equal_activity_folders_keep_root_order(cx: &mut TestAppContext) { + fn test_projects_keep_initial_root_order_after_activity(cx: &mut TestAppContext) { cx.executor().allow_parking(); let screen = screen(cx); screen.update(cx, |this, cx| { + this.apply_recent_roots(vec![recent_root("/z")], cx); this.sessions = vec![ summary_at("s1", "One", "/b"), summary_at("s2", "Two", "/a"), summary_at("s3", "Three", "/z"), ]; - this.apply_recent_roots(vec![recent_root("/z")], cx); this.sync_sidebar(cx); this.sidebar.update(cx, |sidebar, cx| { sidebar.set_view(crate::settings::SidebarView::default(), cx) }); - assert_eq!(this.sidebar.read(cx).folder_names(), ["z", "a", "b"]); + assert_eq!( + this.sidebar.read(cx).grouped_project_names(), + ["z", "a", "b"] + ); + this.sessions[0].updated_ms = 100; + this.sync_sidebar(cx); + assert_eq!( + this.sidebar.read(cx).grouped_project_names(), + ["z", "a", "b"] + ); }); } @@ -5752,8 +5758,8 @@ mod state_tests { }) }; sidebar.set_view(SidebarView::default(), cx); - sidebar.set_folder_expanded("/a", Some(false), cx); - sidebar.set_folder_expanded("/b", Some(false), cx); + sidebar.set_project_expanded("/a", Some(false), cx); + sidebar.set_project_expanded("/b", Some(false), cx); assert_eq!(empty(sidebar), None); sidebar.set_filter_for_test("two", cx); assert_eq!(empty(sidebar), None); @@ -6263,7 +6269,7 @@ mod state_tests { assert!(!sidebar.update(cx, |sidebar, _| sidebar.view_submenu_open())); assert_eq!( sidebar.update(cx, |sidebar, _| sidebar.popup_highlight()), - Some("view-group".into()) + Some("sidebar-group".into()) ); cx.simulate_keystrokes("right j enter"); assert_eq!( @@ -6279,18 +6285,20 @@ mod state_tests { sidebar.vim_select(SidebarTarget::Projects, cx) }); cx.simulate_keystrokes("enter"); - assert!(sidebar.update(cx, |sidebar, _| sidebar.header_menu_open())); + assert_eq!(open_menu(&chat, cx), "View"); + cx.simulate_keystrokes("j right k enter"); assert_eq!( - sidebar.update(cx, |sidebar, _| sidebar.popup_highlight()), - Some("switcher-all-projects".into()) + sidebar.update(cx, |sidebar, _| sidebar.view().group), + SidebarGroup::Connection ); - cx.simulate_keystrokes("escape"); + assert_eq!(open_menu(&chat, cx), ""); } - /// Picking a View option closes the chain; reset restores the defaults. + /// Sidebar exposes Group tasks by and Sort tasks by. Picking a choice + /// closes the submenu chain and preserves the independent preference. #[gpui::test] - fn test_the_view_menu_sets_and_resets_the_view(cx: &mut TestAppContext) { - use crate::settings::{SidebarGroup, SidebarSort, SidebarView}; + fn test_sidebar_menu_selects_grouping_and_task_order(cx: &mut TestAppContext) { + use crate::settings::{SidebarGroup, SidebarSort}; let (chat, cx) = chat_window(cx, |this, cx| { this.sessions = vec![summary("s1", "One")]; this.sync_sidebar(cx); @@ -6298,39 +6306,56 @@ mod state_tests { let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); let view = |cx: &mut gpui::VisualTestContext| sidebar.update(cx, |sidebar, _| sidebar.view()); - press(cx, "view-options"); - assert_eq!(open_menu(&chat, cx), "View"); let closed = |cx: &mut gpui::VisualTestContext| { assert_eq!(open_menu(&chat, cx), ""); assert!(!sidebar.update(cx, |sidebar, _| sidebar.view_submenu_open())); }; - press(cx, "view-sort"); - press(cx, "view-sort-name"); - assert_eq!(view(cx).sort, SidebarSort::Name); - closed(cx); - press(cx, "view-options"); - press(cx, "view-sort"); - press(cx, "view-sort-ascending"); - assert!(view(cx).ascending); - closed(cx); - press(cx, "view-options"); - press(cx, "view-reset"); - assert_eq!(view(cx), SidebarView::default()); - assert_eq!(view(cx).group, SidebarGroup::Folder); press(cx, "view-options"); - press(cx, "view-group"); - press(cx, "view-group-recent"); + assert_eq!(open_menu(&chat, cx), "View"); + assert!(cx.debug_bounds("sidebar-group").is_some()); + assert!(cx.debug_bounds("sidebar-sort").is_some()); + press(cx, "sidebar-sort"); + assert!(cx.debug_bounds("sort-updated").is_some()); + assert!(cx.debug_bounds("sort-manual").is_some()); + press(cx, "sort-manual"); + assert_eq!(view(cx).sort, SidebarSort::Manual); closed(cx); - assert_eq!(view(cx).group, SidebarGroup::Recent); + for (selector, group) in [ + ("group-project", SidebarGroup::Project), + ("group-status", SidebarGroup::Status), + ("group-list", SidebarGroup::List), + ] { + press(cx, "view-options"); + press(cx, "sidebar-group"); + assert!(cx.debug_bounds("group-project").is_some()); + assert!(cx.debug_bounds("group-status").is_some()); + assert!(cx.debug_bounds("group-list").is_some()); + assert!(cx.debug_bounds("group-connection").is_none()); + press(cx, selector); + assert_eq!(view(cx).group, group); + assert_eq!(view(cx).sort, SidebarSort::Manual); + closed(cx); + } assert!(sidebar.update(cx, |sidebar, _| sidebar.entries().contains( &crate::ui::chat::sidebar::SidebarEntry::SectionLabel( - crate::ui::chat::sidebar::SidebarSection::Recents + crate::ui::chat::sidebar::SidebarSection::List ) ))); press(cx, "view-options"); - press(cx, "view-show"); - press(cx, "view-show-metadata"); - assert_eq!(view(cx).metadata, Some(false)); + press(cx, "sidebar-sort"); + press(cx, "sort-updated"); + assert_eq!(view(cx).group, SidebarGroup::List); + assert_eq!(view(cx).sort, SidebarSort::Updated); + closed(cx); + chat.update(cx, |this, cx| { + this.set_computers(identical_path_project_catalog(), cx) + }); + press(cx, "view-options"); + press(cx, "sidebar-group"); + assert!(cx.debug_bounds("group-connection").is_some()); + press(cx, "group-connection"); + assert_eq!(view(cx).group, SidebarGroup::Connection); + assert_eq!(view(cx).sort, SidebarSort::Updated); closed(cx); } @@ -6453,7 +6478,7 @@ mod state_tests { } } - /// Reordering, filtering and folding invalidate a stationary pointer's + /// Activity, filtering and folding invalidate a stationary pointer's /// old row index instead of assigning its hover to the rebuilt entry. #[gpui::test] fn test_section_chevrons_clear_on_stationary_rebuilds(cx: &mut TestAppContext) { @@ -6470,7 +6495,7 @@ mod state_tests { }); }); let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); - for change in ["reorder", "filter", "fold"] { + for change in ["activity", "filter", "fold"] { cx.simulate_mouse_move( gpui::point(px(600.), px(400.)), None, @@ -6485,7 +6510,7 @@ mod state_tests { assert!(!sidebar.section_hover_is_clear_for_test()); }); match change { - "reorder" => chat.update(cx, |this, cx| { + "activity" => chat.update(cx, |this, cx| { this.sessions[0].updated_ms = 3; this.sync_sidebar(cx); }), @@ -6494,7 +6519,7 @@ mod state_tests { cx.notify(); }), "fold" => sidebar.update(cx, |sidebar, cx| { - sidebar.set_folder_expanded("/a", Some(false), cx); + sidebar.set_project_expanded("/a", Some(false), cx); }), _ => unreachable!(), } @@ -6621,9 +6646,9 @@ mod state_tests { SidebarTarget::NewTask, SidebarTarget::Search, SidebarTarget::Projects, - SidebarTarget::Folder("/b".into()), + SidebarTarget::Project("/b".into()), task("s2"), - SidebarTarget::Folder("/a".into()), + SidebarTarget::Project("/a".into()), task("s1"), task("s3"), ] @@ -6638,7 +6663,7 @@ mod state_tests { ); }); sidebar.update(cx, |sidebar, cx| { - sidebar.vim_select(SidebarTarget::Folder("/a".into()), cx); + sidebar.vim_select(SidebarTarget::Project("/a".into()), cx); sidebar.vim_set_expanded(false, cx); assert!(!sidebar.vim_targets_for_test().contains(&task("s1"))); sidebar.vim_set_expanded(true, cx); @@ -6795,14 +6820,7 @@ mod state_tests { open.push(format!("{popup:?}")); } if sidebar.header_menu_open() { - open.push( - if this.computers.len() > 1 { - "Switcher" - } else { - "View" - } - .to_string(), - ); + open.push("View".to_string()); } if let Some(task) = sidebar.task_menu() { open.push(format!("Task({task})")); @@ -7529,7 +7547,7 @@ mod state_tests { let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); sidebar.update(cx, |sidebar, cx| { sidebar.close_popups(cx); - sidebar.set_folder_expanded("/tmp/proj", Some(true), cx); + sidebar.set_project_expanded("/tmp/proj", Some(true), cx); }); // A menu opened without the pointer closes the open one too: it @@ -7805,12 +7823,14 @@ mod state_tests { ) } + /// A scoped paired view keeps its selected organization instead of + /// forcing status sections; changing it preserves pinned membership. #[gpui::test] - fn test_paired_scoped_sidebar_keeps_status_sections_and_switcher_rename( - cx: &mut TestAppContext, - ) { - use sidebar::{SidebarEntry, SidebarSection}; - let (chat, cx) = chat_window(cx, |this, cx| { + fn test_paired_scoped_sidebar_preserves_selected_grouping(cx: &mut TestAppContext) { + use crate::settings::{SidebarGroup, SidebarView}; + use sidebar::SidebarSection; + let chat = screen(cx); + chat.update(cx, |this, cx| { this.sessions = vec![ summary_at("pinned", "Pinned", "/shared"), summary_at("active", "Active", "/shared"), @@ -7824,68 +7844,32 @@ mod state_tests { this.sidebar.update(cx, |sidebar, cx| { sidebar.set_pinned_tasks_for_test(vec!["pinned".into()]); sidebar.set_project_filter(Some("/shared".into()), cx); - sidebar.set_view(crate::settings::SidebarView::default(), cx); + sidebar.set_view( + SidebarView { + group: SidebarGroup::Status, + ..SidebarView::default() + }, + cx, + ); + assert_eq!(sidebar.active_rows(), [1]); + assert_eq!(sidebar.settled_rows(), [2]); + assert_eq!(sidebar.pinned_rows(), [0]); + assert!(sidebar.grouped_project_names().is_empty()); + sidebar.set_view(SidebarView::default(), cx); + assert_eq!(sidebar.grouped_project_names(), ["Local project"]); + assert_eq!(sidebar.section_rows(SidebarSection::Project(0)), [1, 2]); + assert_eq!(sidebar.pinned_rows(), [0]); + sidebar.set_view( + SidebarView { + group: SidebarGroup::List, + ..SidebarView::default() + }, + cx, + ); + assert_eq!(sidebar.section_rows(SidebarSection::List), [1, 2]); + assert_eq!(sidebar.pinned_rows(), [0]); }); }); - let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); - sidebar.update(cx, |sidebar, _| { - let entries = sidebar.entries(); - assert_eq!( - entries[0..4], - [ - SidebarEntry::NewTask, - SidebarEntry::Search, - SidebarEntry::ProjectsHeader, - SidebarEntry::SectionLabel(SidebarSection::Pinned) - ] - ); - let sections: Vec<_> = entries - .iter() - .filter_map(|entry| match entry { - SidebarEntry::SectionLabel(section) => Some(*section), - _ => None, - }) - .collect(); - assert_eq!( - sections, - [ - SidebarSection::Pinned, - SidebarSection::Active, - SidebarSection::Settled - ] - ); - assert_eq!(sidebar.active_rows(), [1]); - assert_eq!(sidebar.settled_rows(), [2]); - assert!(sidebar.folder_names().is_empty()); - }); - press(cx, "projects-header"); - assert_eq!(open_menu(&chat, cx), "Switcher"); - let options = cx.debug_bounds("menu-project-/shared").unwrap(); - cx.simulate_mouse_move(options.center(), None, gpui::Modifiers::default()); - cx.run_until_parked(); - cx.executor() - .advance_clock(std::time::Duration::from_secs(1)); - cx.run_until_parked(); - assert!( - cx.debug_bounds("tooltip").is_some(), - "paired project options retains its tooltip" - ); - press(cx, "menu-project-/shared"); - press(cx, "rename-project-/shared"); - cx.run_until_parked(); - assert_eq!( - sidebar.update(cx, |sidebar, _| sidebar.rename_target()), - Some(RenameTarget::Project(ProjectTarget { - computer: None, - path: "/shared".into() - })) - ); - assert!(cx.debug_bounds("rename-field").is_some()); - cx.simulate_input("Renamed project"); - let field = sidebar.update(cx, |sidebar, _| sidebar.inputs().last().unwrap().clone()); - assert_eq!(field.update(cx, |field, _| field.text()), "Renamed project"); - cx.simulate_keystrokes("escape"); - assert!(sidebar.update(cx, |sidebar, _| sidebar.rename_target().is_none())); } #[gpui::test] diff --git a/apps/maple-agent/crates/maple-agent/src/acp/mod.rs b/apps/maple-agent/crates/maple-agent/src/acp/mod.rs index 06cf1ae46..c1b08d379 100644 --- a/apps/maple-agent/crates/maple-agent/src/acp/mod.rs +++ b/apps/maple-agent/crates/maple-agent/src/acp/mod.rs @@ -890,6 +890,7 @@ impl AcpConnectionContext { let mut listed = Vec::new(); loop { let query = crate::store::NavigationTaskQuery { + manual: None, section: crate::store::TaskSection::Recent, project: project.clone(), pins: crate::store::PinFilter::Ignore, diff --git a/apps/maple-agent/crates/maple-agent/src/store/mod.rs b/apps/maple-agent/crates/maple-agent/src/store/mod.rs index 88101a738..7e8f1e887 100644 --- a/apps/maple-agent/crates/maple-agent/src/store/mod.rs +++ b/apps/maple-agent/crates/maple-agent/src/store/mod.rs @@ -58,11 +58,11 @@ pub use error::{DamageReason, RefusalReason, StoreError, StoreResult}; pub use gc::{CollectionProgress, CollectionStage, DeletedTask}; pub use paging::{ AttachmentRangeReply, HistoryAnchorRequest, HistoryPage, HistoryPageRequest, - NavigationGetTasksReply, NavigationGroupPage, NavigationGroupQuery, NavigationProjectGroup, - NavigationProjectPage, NavigationSearchQuery, NavigationTaskPage, NavigationTaskQuery, - OpenTaskPage, OpenTaskPageRequest, PageTransport, ProjectSummary, ProjectTaskCursors, RowRange, - RowRangeRequest, RowReader, TaskFactsReply, TaskHistoryStamp, TaskSummary, TimelineRow, - TransportCaps, + NavigationGetTasksReply, NavigationGroupPage, NavigationGroupQuery, NavigationManualOrder, + NavigationProjectGroup, NavigationProjectPage, NavigationSearchQuery, NavigationTaskPage, + NavigationTaskQuery, OpenTaskPage, OpenTaskPageRequest, PageTransport, ProjectSummary, + ProjectTaskCursors, RowRange, RowRangeRequest, RowReader, TaskFactsReply, TaskHistoryStamp, + TaskSummary, TimelineRow, TransportCaps, }; pub use paths::{FilesystemKind, StorePaths}; pub use persistence::{PersistenceFailure, PersistenceFailureReason, PersistenceStatus}; diff --git a/apps/maple-agent/crates/maple-agent/src/store/paging/navigation.rs b/apps/maple-agent/crates/maple-agent/src/store/paging/navigation.rs index 0f8f35e81..16a5767e3 100644 --- a/apps/maple-agent/crates/maple-agent/src/store/paging/navigation.rs +++ b/apps/maple-agent/crates/maple-agent/src/store/paging/navigation.rs @@ -14,6 +14,7 @@ use crate::store::{AccountId, PinFilter, Store, StoreError, StoreResult, TaskSec const MAX_ROWS: u16 = 200; const MAX_TEXT_BYTES: usize = 16 * 1024; +const MAX_MANUAL_TASKS: usize = 8192; // SQLite bounds even anomalously long model/title values before Rust allocates. const TASK_COLUMNS: &str = "id,uuid,project_id,substr(title,1,4096),state,kind,pinned_ms,web_enabled,\ substr(model,1,1024),message_count,created_ms,updated_ms,\ @@ -53,6 +54,15 @@ pub struct ProjectSummary { pub truncated: bool, } +/// A bounded owner-UUID order. Tasks created after the snapshot lead the saved +/// order; unranked older history follows it by creation time, never activity. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct NavigationManualOrder { + pub created_before_ms: i64, + pub tasks: Vec, +} + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct NavigationTaskQuery { @@ -60,6 +70,8 @@ pub struct NavigationTaskQuery { pub project: Option, #[serde(default = "default_pin_filter")] pub pins: PinFilter, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub manual: Option, pub cursor: Option, #[serde(default = "default_task_limit")] pub limit: u16, @@ -80,6 +92,8 @@ pub struct NavigationSearchQuery { pub project: Option, #[serde(default)] pub project_roots: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub manual: Option, pub cursor: Option, #[serde(default = "default_search_limit")] pub limit: u16, @@ -107,6 +121,8 @@ pub struct NavigationGroupQuery { pub limit_groups: u16, #[serde(default = "default_group_limit")] pub per_project: u16, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub manual: Option, pub cursor: Option, } @@ -159,6 +175,100 @@ struct ProjectRow { summary: ProjectSummary, } +struct PreparedManualOrder { + cutoff: i64, + ranks: HashMap, + encoded_tasks: String, + fingerprint: Value, +} + +impl PreparedManualOrder { + fn new(order: &NavigationManualOrder) -> StoreResult { + if order.created_before_ms < 0 || order.tasks.len() > MAX_MANUAL_TASKS { + return Err(StoreError::InvalidInput); + } + let mut ranks = HashMap::with_capacity(order.tasks.len()); + let mut tasks = Vec::with_capacity(order.tasks.len()); + for (index, task) in order.tasks.iter().enumerate() { + validate_uuid(task)?; + let task = task.to_ascii_lowercase(); + if ranks.insert(task.clone(), index as i64).is_some() { + return Err(StoreError::InvalidInput); + } + tasks.push(task); + } + Ok(Self { + cutoff: order.created_before_ms, + ranks, + encoded_tasks: serde_json::to_string(&tasks).map_err(|_| StoreError::InvalidInput)?, + fingerprint: cursor_scope(order)?, + }) + } + + fn position(&self, row: &SummaryRow) -> TaskPosition { + let rank = self + .ranks + .get(&row.summary.uuid.to_ascii_lowercase()) + .copied(); + TaskPosition::Manual { + band: if rank.is_some() { + 1 + } else if row.summary.created_ms > self.cutoff { + 0 + } else { + 2 + }, + rank: rank.unwrap_or(0), + created: if rank.is_some() { + 0 + } else { + row.summary.created_ms + }, + uuid: row.summary.uuid.clone(), + } + } + + fn compare(&self, left: &SummaryRow, right: &SummaryRow) -> std::cmp::Ordering { + let TaskPosition::Manual { + band: left_band, + rank: left_rank, + created: left_created, + uuid: left_uuid, + } = self.position(left) + else { + unreachable!() + }; + let TaskPosition::Manual { + band: right_band, + rank: right_rank, + created: right_created, + uuid: right_uuid, + } = self.position(right) + else { + unreachable!() + }; + left_band + .cmp(&right_band) + .then_with(|| left_rank.cmp(&right_rank)) + .then_with(|| right_created.cmp(&left_created)) + .then_with(|| left_uuid.cmp(&right_uuid)) + } +} + +#[derive(Clone)] +enum TaskPosition { + Updated { + updated: i64, + key: i64, + }, + Manual { + band: i64, + rank: i64, + created: i64, + uuid: String, + }, +} + impl Store { pub fn list_task_summaries( &self, @@ -170,13 +280,19 @@ impl Store { if let Some(root) = &query.project { validate_root(root)?; } + let manual = query + .manual + .as_ref() + .map(PreparedManualOrder::new) + .transpose()?; let scope = cursor_scope(&task_scope( query.section, query.project.as_deref(), query.pins, + manual.as_ref(), ))?; let position = cursor_position(query.cursor.as_deref(), "tasks", account, &scope)?; - let position = read_task_position(position.as_ref())?; + let position = read_task_position(position.as_ref(), manual.is_some())?; self.read(|tx| { let project = resolve_project(tx, account, query.project.as_deref())?; if query.project.is_some() && project.is_none() { @@ -188,11 +304,20 @@ impl Store { query.section, project, query.pins, + manual.as_ref(), position, usize::from(query.limit) + 1, )?; populate_roots(tx, account, &mut rows)?; - finish_task_page(account, &scope, "tasks", rows, query.limit, transport) + finish_task_page( + account, + &scope, + "tasks", + rows, + query.limit, + transport, + manual.as_ref(), + ) }) } @@ -210,6 +335,7 @@ impl Store { section: TaskSection::Pinned, project: project.map(str::to_owned), pins: PinFilter::Ignore, + manual: None, cursor: cursor.map(str::to_owned), limit, }, @@ -293,44 +419,63 @@ impl Store { return Err(StoreError::InvalidInput); } let fold = query.text.to_lowercase(); - let scope = - cursor_scope(&json!({"text":fold,"project_roots":roots,"project":query.project}))?; + let manual = query + .manual + .as_ref() + .map(PreparedManualOrder::new) + .transpose()?; + let mut scope = json!({"text":fold,"project_roots":roots,"project":query.project}); + if let Some(manual) = &manual { + scope["manual"] = manual.fingerprint.clone(); + } + let scope = cursor_scope(&scope)?; let position = cursor_position(query.cursor.as_deref(), "search", account, &scope)?; - let position = read_task_position(position.as_ref())?; + let position = read_task_position(position.as_ref(), manual.is_some())?; let roots = serde_json::to_string(&roots).map_err(|_| StoreError::InvalidInput)?; self.read(|tx| { let project = resolve_project(tx, account, query.project.as_deref())?; if query.project.is_some() && project.is_none() { return Ok(NavigationTaskPage::default()); } - let mut sql = format!( - "SELECT {TASK_COLUMNS} FROM tasks INDEXED BY tasks_recent - WHERE account_id=? AND provisional=0 AND deleted_ms IS NULL AND message_count>0 + let (mut sql, mut values) = task_select("tasks_recent", account, manual.as_ref()); + sql.push_str( + " AND provisional=0 AND deleted_ms IS NULL AND message_count>0 AND (instr(title_fold,?)>0 OR project_id IN (SELECT id FROM projects WHERE account_id=? AND (root IN (SELECT value FROM json_each(?)) OR - instr(maple_project_label_fold(root,display_name),?)>0)))" + instr(maple_project_label_fold(root,display_name),?)>0)))", ); - let mut values = vec![ - account.0.into(), + values.extend([ fold.clone().into(), account.0.into(), roots.clone().into(), fold.clone().into(), - ]; + ]); if let Some(project) = project { sql.push_str(" AND project_id=?"); values.push(project.into()); } - append_task_position(&mut sql, &mut values, position); - sql.push_str(" ORDER BY updated_ms DESC,id DESC LIMIT ?"); - values.push((i64::from(query.limit) + 1).into()); + append_task_order( + &mut sql, + &mut values, + position, + manual.is_some(), + usize::from(query.limit) + 1, + )?; let mut statement = tx.prepare_cached(&sql)?; let mut rows = statement .query_map(params_from_iter(values), read_summary)? .collect::>>()?; populate_roots(tx, account, &mut rows)?; - finish_task_page(account, &scope, "search", rows, query.limit, transport) + finish_task_page( + account, + &scope, + "search", + rows, + query.limit, + transport, + manual.as_ref(), + ) }) } @@ -427,7 +572,16 @@ impl Store { ) -> StoreResult { validate_limit(query.limit_groups, 50)?; validate_limit(query.per_project, 50)?; - let scope = cursor_scope(&json!({"view":"groups","per_project":query.per_project}))?; + let manual = query + .manual + .as_ref() + .map(PreparedManualOrder::new) + .transpose()?; + let mut scope = json!({"view":"groups","per_project":query.per_project}); + if let Some(manual) = &manual { + scope["manual"] = manual.fingerprint.clone(); + } + let scope = cursor_scope(&scope)?; let position = cursor_position(query.cursor.as_deref(), "groups", account, &scope)?; let position = read_project_position(position.as_ref())?; self.read(|tx| { @@ -459,11 +613,13 @@ impl Store { TaskSection::State(TaskState::Active), Some(&project.summary.root), PinFilter::Ignore, + manual.as_ref(), ))?; let settled_scope = cursor_scope(&task_scope( TaskSection::State(TaskState::Settled), Some(&project.summary.root), PinFilter::Ignore, + manual.as_ref(), ))?; let budget = usize::from(query.per_project).min(usize::from(MAX_ROWS) - flat_count); // Read at most the remaining task budget plus one lookahead. @@ -473,6 +629,7 @@ impl Store { TaskSection::State(TaskState::Active), Some(project.key), PinFilter::Ignore, + manual.as_ref(), None, budget + 1, )?; @@ -482,6 +639,7 @@ impl Store { TaskSection::State(TaskState::Settled), Some(project.key), PinFilter::Ignore, + manual.as_ref(), None, budget + 1, )?; @@ -525,11 +683,17 @@ impl Store { } let mut active_taken = 0; let mut settled_taken = 0; - for (is_active, row) in active + let mut tasks: Vec<_> = active .into_iter() .map(|r| (true, r)) .chain(settled.into_iter().map(|r| (false, r))) - { + .collect(); + if let Some(manual) = &manual { + // Merge the independently paged sections by the same + // immutable order before applying the shared row budget. + tasks.sort_by(|(_, left), (_, right)| manual.compare(left, right)); + } + for (is_active, row) in tasks { if group.tasks.len() == budget { break; } @@ -539,14 +703,24 @@ impl Store { active_taken += 1; group.more.active = (active_taken < active_len) .then(|| { - make_cursor("tasks", account, &active_scope, task_position(&row)) + make_cursor( + "tasks", + account, + &active_scope, + task_position(&row, manual.as_ref()), + ) }) .transpose()?; } else { settled_taken += 1; group.more.settled = (settled_taken < settled_len) .then(|| { - make_cursor("tasks", account, &settled_scope, task_position(&row)) + make_cursor( + "tasks", + account, + &settled_scope, + task_position(&row, manual.as_ref()), + ) }) .transpose()?; } @@ -571,7 +745,8 @@ fn task_rows( section: TaskSection, project: Option, pins: PinFilter, - position: Option<(i64, i64)>, + manual: Option<&PreparedManualOrder>, + position: Option, limit: usize, ) -> StoreResult> { let index = match (section, project) { @@ -581,11 +756,8 @@ fn task_rows( (TaskSection::State(_), Some(_)) => "tasks_project_section", (TaskSection::State(_), None) => "tasks_section", }; - let mut sql = format!( - "SELECT {TASK_COLUMNS} FROM tasks INDEXED BY {index} - WHERE account_id=? AND provisional=0 AND deleted_ms IS NULL" - ); - let mut values = vec![rusqlite::types::Value::Integer(account.0)]; + let (mut sql, mut values) = task_select(index, account, manual); + sql.push_str(" AND provisional=0 AND deleted_ms IS NULL"); match section { TaskSection::State(state) => { sql.push_str(" AND state=? AND message_count>0"); @@ -603,28 +775,94 @@ fn task_rows( sql.push_str(" AND project_id=?"); values.push(project.into()); } - append_task_position(&mut sql, &mut values, position); - sql.push_str(" ORDER BY updated_ms DESC,id DESC LIMIT ?"); - values.push( - i64::try_from(limit) - .map_err(|_| StoreError::InvalidInput)? - .into(), - ); + append_task_order(&mut sql, &mut values, position, manual.is_some(), limit)?; let mut statement = tx.prepare_cached(&sql)?; Ok(statement .query_map(params_from_iter(values), read_summary)? .collect::>>()?) } -fn append_task_position( +fn task_select( + index: &str, + account: AccountId, + manual: Option<&PreparedManualOrder>, +) -> (String, Vec) { + if let Some(manual) = manual { + // Materialize at most 8192 UUID ranks once; SQLite can index the + // materialized rank relation rather than scanning JSON per task. + return ( + format!( + "WITH manual_ranks AS MATERIALIZED ( + SELECT value AS rank_uuid,CAST(key AS INTEGER) AS rank_index FROM json_each(?) + ), manual_tasks AS ( + SELECT {TASK_COLUMNS}, + CASE WHEN rank_index IS NOT NULL THEN 1 WHEN created_ms>? THEN 0 ELSE 2 END AS manual_band, + coalesce(rank_index,0) AS manual_rank, + CASE WHEN rank_index IS NULL THEN created_ms ELSE 0 END AS manual_created + FROM tasks INDEXED BY {index} + LEFT JOIN manual_ranks ON manual_ranks.rank_uuid=lower(tasks.uuid) + WHERE account_id=?" + ), + vec![manual.encoded_tasks.clone().into(), manual.cutoff.into(), account.0.into()], + ); + } + ( + format!("SELECT {TASK_COLUMNS} FROM tasks INDEXED BY {index} WHERE account_id=?"), + vec![account.0.into()], + ) +} + +fn append_task_order( sql: &mut String, values: &mut Vec, - position: Option<(i64, i64)>, -) { - if let Some((updated, key)) = position { - sql.push_str(" AND (updated_ms,id)<(?,?)"); - values.extend([updated.into(), key.into()]); + position: Option, + manual: bool, + limit: usize, +) -> StoreResult<()> { + if manual { + sql.push_str(") SELECT * FROM manual_tasks WHERE 1=1"); + if let Some(position) = position { + let TaskPosition::Manual { + band, + rank, + created, + uuid, + } = position + else { + return Err(StoreError::InvalidInput); + }; + sql.push_str( + " AND ((manual_band,manual_rank)>(?,?) OR + ((manual_band,manual_rank)=(?,?) AND + (manual_created?))))", + ); + values.extend([ + band.into(), + rank.into(), + band.into(), + rank.into(), + created.into(), + created.into(), + uuid.into(), + ]); + } + sql.push_str(" ORDER BY manual_band,manual_rank,manual_created DESC,uuid ASC LIMIT ?"); + } else { + if let Some(position) = position { + let TaskPosition::Updated { updated, key } = position else { + return Err(StoreError::InvalidInput); + }; + sql.push_str(" AND (updated_ms,id)<(?,?)"); + values.extend([updated.into(), key.into()]); + } + sql.push_str(" ORDER BY updated_ms DESC,id DESC LIMIT ?"); } + values.push( + i64::try_from(limit) + .map_err(|_| StoreError::InvalidInput)? + .into(), + ); + Ok(()) } fn finish_task_page( @@ -634,13 +872,14 @@ fn finish_task_page( rows: Vec, limit: u16, transport: PageTransport, + manual: Option<&PreparedManualOrder>, ) -> StoreResult { let mut page = NavigationTaskPage::default(); let count = rows.len(); for (index, row) in rows.iter().take(usize::from(limit)).enumerate() { page.tasks.push(row.summary.clone()); page.next_cursor = (index + 1 < count) - .then(|| make_cursor(kind, account, scope, task_position(row))) + .then(|| make_cursor(kind, account, scope, task_position(row, manual))) .transpose()?; if serialized_len(&page)? > reply_limit(transport) { page.tasks.pop(); @@ -651,7 +890,7 @@ fn finish_task_page( kind, account, scope, - task_position(&rows[index - 1]), + task_position(&rows[index - 1], manual), )?); return Ok(page); } @@ -925,33 +1164,85 @@ fn read_summary(row: &Row<'_>) -> rusqlite::Result { }) } -fn task_scope(section: TaskSection, project: Option<&str>, pins: PinFilter) -> Value { +fn task_scope( + section: TaskSection, + project: Option<&str>, + pins: PinFilter, + manual: Option<&PreparedManualOrder>, +) -> Value { let section = match section { TaskSection::State(state) => state.as_str(), TaskSection::Pinned => "pinned", TaskSection::Recent => "recent", }; - json!({"section":section,"project":project,"pins":if pins==PinFilter::Exclude {"exclude"} else {"ignore"}}) + let mut scope = json!({"section":section,"project":project,"pins":if pins==PinFilter::Exclude {"exclude"} else {"ignore"}}); + if let Some(manual) = manual { + scope["manual"] = manual.fingerprint.clone(); + } + scope } -fn task_position(row: &SummaryRow) -> Value { +fn task_position(row: &SummaryRow, manual: Option<&PreparedManualOrder>) -> Value { + if let Some(manual) = manual { + let TaskPosition::Manual { + band, + rank, + created, + uuid, + } = manual.position(row) + else { + unreachable!() + }; + return json!({"band":band,"rank":rank,"created_ms":created,"uuid":uuid}); + } json!({"updated_ms":row.summary.updated_ms,"id":row.key}) } -fn read_task_position(value: Option<&Value>) -> StoreResult> { +fn read_task_position(value: Option<&Value>, manual: bool) -> StoreResult> { match value { None | Some(Value::Null) => Ok(None), Some(value) => { - let updated = value - .get("updated_ms") - .and_then(Value::as_i64) - .ok_or(StoreError::InvalidInput)?; - let key = value - .get("id") - .and_then(Value::as_i64) - .filter(|n| *n > 0) - .ok_or(StoreError::InvalidInput)?; - Ok(Some((updated, key))) + if manual { + let band = value + .get("band") + .and_then(Value::as_i64) + .filter(|band| (0..=2).contains(band)) + .ok_or(StoreError::InvalidInput)?; + let rank = value + .get("rank") + .and_then(Value::as_i64) + .filter(|rank| (0..MAX_MANUAL_TASKS as i64).contains(rank)) + .ok_or(StoreError::InvalidInput)?; + let created = value + .get("created_ms") + .and_then(Value::as_i64) + .ok_or(StoreError::InvalidInput)?; + if (band != 1 && rank != 0) || (band == 1 && created != 0) { + return Err(StoreError::InvalidInput); + } + let uuid = value + .get("uuid") + .and_then(Value::as_str) + .ok_or(StoreError::InvalidInput)?; + validate_uuid(uuid)?; + Ok(Some(TaskPosition::Manual { + band, + rank, + created, + uuid: uuid.to_owned(), + })) + } else { + let key = value + .get("id") + .and_then(Value::as_i64) + .filter(|n| *n > 0) + .ok_or(StoreError::InvalidInput)?; + let updated = value + .get("updated_ms") + .and_then(Value::as_i64) + .ok_or(StoreError::InvalidInput)?; + Ok(Some(TaskPosition::Updated { updated, key })) + } } } } diff --git a/apps/maple-agent/crates/maple-agent/src/store/paging/navigation_tests.rs b/apps/maple-agent/crates/maple-agent/src/store/paging/navigation_tests.rs index 41d800371..f2d52891a 100644 --- a/apps/maple-agent/crates/maple-agent/src/store/paging/navigation_tests.rs +++ b/apps/maple-agent/crates/maple-agent/src/store/paging/navigation_tests.rs @@ -52,6 +52,7 @@ fn task(store: &Store, account: AccountId, number: u64, project_root: &str, titl fn query(section: TaskSection, limit: u16) -> NavigationTaskQuery { NavigationTaskQuery { + manual: None, section, project: None, pins: PinFilter::Ignore, @@ -200,6 +201,7 @@ fn search_is_unicode_folded_and_cursor_binds_search_terms() { task(&store, alice, 2, &project, "äpfel two"); task(&store, alice, 3, &root("other"), "unrelated"); let mut request = NavigationSearchQuery { + manual: None, project: None, text: "Äpfel".to_owned(), project_roots: Vec::new(), @@ -217,6 +219,7 @@ fn search_is_unicode_folded_and_cursor_binds_search_terms() { assert_eq!(second.tasks.len(), 1); assert_ne!(first.tasks[0].uuid, second.tasks[0].uuid); let scoped = NavigationSearchQuery { + manual: None, text: "äpfel".to_owned(), project: Some(root("other")), project_roots: vec![project.clone()], @@ -261,6 +264,7 @@ fn group_flatten_cap_retains_empty_group_section_cursors() { .list_project_group_summaries( alice, &NavigationGroupQuery { + manual: None, limit_groups: 5, per_project: 50, cursor: None, @@ -283,6 +287,7 @@ fn group_flatten_cap_retains_empty_group_section_cursors() { .list_task_summaries( alice, &NavigationTaskQuery { + manual: None, section: TaskSection::State(TaskState::Active), project: Some(last.project.root.clone()), pins: PinFilter::Ignore, @@ -316,6 +321,7 @@ fn mixed_group_sections_keep_independent_continuations() { .list_project_group_summaries( alice, &NavigationGroupQuery { + manual: None, limit_groups: 1, per_project: 1, cursor: None, @@ -329,6 +335,7 @@ fn mixed_group_sections_keep_independent_continuations() { .list_task_summaries( alice, &NavigationTaskQuery { + manual: None, section: TaskSection::State(TaskState::Active), project: Some(project.clone()), pins: PinFilter::Ignore, @@ -342,6 +349,7 @@ fn mixed_group_sections_keep_independent_continuations() { .list_task_summaries( alice, &NavigationTaskQuery { + manual: None, section: TaskSection::State(TaskState::Settled), project: Some(project), pins: PinFilter::Ignore, @@ -376,6 +384,7 @@ fn remote_group_byte_cap_keeps_all_groups_and_unloaded_tasks_reachable() { } } let mut request = NavigationGroupQuery { + manual: None, limit_groups: 4, per_project: 50, cursor: None, @@ -404,6 +413,7 @@ fn remote_group_byte_cap_keeps_all_groups_and_unloaded_tasks_reachable() { .list_task_summaries( alice, &NavigationTaskQuery { + manual: None, section: TaskSection::State(TaskState::Active), project: Some(group.project.root.clone()), pins: PinFilter::Ignore, @@ -638,6 +648,7 @@ fn name_revisions_survive_reset_and_are_in_both_bounded_project_projections() { .list_project_group_summaries( alice, &NavigationGroupQuery { + manual: None, limit_groups: 1, per_project: 1, cursor: None, @@ -680,6 +691,7 @@ fn search_finds_unloaded_owner_project_names_with_unicode_and_bounded_cursors() let second_id = task(&store, alice, 100, &target, "Another unrelated title"); task(&store, bob, 200, &target, "Unrelated other account"); let mut request = NavigationSearchQuery { + manual: None, text: "äpfel".into(), project: None, project_roots: Vec::new(), @@ -728,6 +740,7 @@ fn search_resolves_owner_default_basename_and_workspace_label_without_client_hin let project = root("Ã…ngströmFolder"); let id = task(&store, alice, 1, &project, "Unrelated title"); let mut request = NavigationSearchQuery { + manual: None, text: "Ã¥ngströmfolder".into(), project: None, project_roots: Vec::new(), diff --git a/apps/maple-agent/crates/maple-agent/src/store/tasks.rs b/apps/maple-agent/crates/maple-agent/src/store/tasks.rs index 957490b47..2928dc708 100644 --- a/apps/maple-agent/crates/maple-agent/src/store/tasks.rs +++ b/apps/maple-agent/crates/maple-agent/src/store/tasks.rs @@ -16,6 +16,7 @@ use super::{AccountId, Durability, Store, StoreChange, StoreError, StoreResult}; const MAX_PAGE: u16 = 200; const MAX_TEXT_BYTES: usize = 16 * 1024; const MAX_SETTINGS_BYTES: usize = 256 * 1024; +const MAX_SIDEBAR_ORDER_BYTES: usize = 2 * 1024 * 1024; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct TaskKey(pub(super) i64); @@ -1131,12 +1132,23 @@ impl Store { pub fn account_setting(&self, account: AccountId, key: &str) -> StoreResult> { validate_setting_key(key)?; + let maximum = account_setting_byte_limit(key); self.read(|tx| { - read_setting( - tx, - "SELECT value FROM account_settings WHERE account_id = ?1 AND key = ?2", - params![account.0, key], - ) + let encoded: Option = tx + .query_row( + "SELECT value FROM account_settings WHERE account_id = ?1 AND key = ?2", + params![account.0, key], + |row| row.get(0), + ) + .optional()?; + encoded + .map(|encoded| { + if encoded.len() > maximum { + return Err(StoreError::InvalidInput); + } + Ok(parse_json(0, &encoded)?) + }) + .transpose() }) } @@ -1148,7 +1160,7 @@ impl Store { now_ms: i64, ) -> StoreResult<()> { validate_setting_key(key)?; - let value = encode_settings(value)?; + let value = encode_settings_with_limit(value, account_setting_byte_limit(key))?; self.write(Durability::Durable, |tx| { writable_account(tx, account)?; tx.execute("INSERT INTO account_settings (account_id,key,value,updated_ms) VALUES (?1,?2,?3,?4) @@ -1681,9 +1693,21 @@ fn validate_generation(generation: &str) -> StoreResult<()> { Ok(()) } +fn account_setting_byte_limit(key: &str) -> usize { + if key == "sidebar.order" { + MAX_SIDEBAR_ORDER_BYTES + } else { + MAX_SETTINGS_BYTES + } +} + fn encode_settings(value: &Value) -> StoreResult { + encode_settings_with_limit(value, MAX_SETTINGS_BYTES) +} + +fn encode_settings_with_limit(value: &Value, maximum: usize) -> StoreResult { let encoded = serde_json::to_string(value).map_err(|_| StoreError::InvalidInput)?; - if encoded.len() > MAX_SETTINGS_BYTES { + if encoded.len() > maximum { return Err(StoreError::InvalidInput); } Ok(encoded) diff --git a/apps/maple-agent/crates/store-bench/src/run.rs b/apps/maple-agent/crates/store-bench/src/run.rs index cbdb5487c..0c60a2e87 100644 --- a/apps/maple-agent/crates/store-bench/src/run.rs +++ b/apps/maple-agent/crates/store-bench/src/run.rs @@ -99,6 +99,7 @@ pub fn execute(mode: Mode, options: &Options) -> Result { store.bench_raw_connection_open() })?); let list = NavigationTaskQuery { + manual: None, section: TaskSection::State(TaskState::Active), project: None, pins: PinFilter::Ignore, @@ -121,11 +122,13 @@ pub fn execute(mode: Mode, options: &Options) -> Result { .map(|_| ()) })?); let groups = NavigationGroupQuery { + manual: None, limit_groups: 20, per_project: 10, cursor: None, }; let search = NavigationSearchQuery { + manual: None, text: "synthetic".into(), project: None, project_roots: Vec::new(), diff --git a/apps/maple-agent/docs/remote-access.md b/apps/maple-agent/docs/remote-access.md index d0c841d68..f5bdd3f7c 100644 --- a/apps/maple-agent/docs/remote-access.md +++ b/apps/maple-agent/docs/remote-access.md @@ -24,15 +24,25 @@ attempt. A failed attempt requires a new, locally opened window. Leaving Setting keeps the current window alive; returning shows the same code and remaining time, with Cancel available. Navigation never renews its deadline or attempt. -Paired computers appear alongside local projects in the sidebar and project -menus. Each group is **computer · folder**, with its tasks beneath it; a globe -marks remote folders. The sidebar only shows folders with visible tasks; empty -folders remain available in the project menus. Selecting a task or project also -selects its execution computer. Select a **This computer** project to return to -local work. Local-only installations retain the existing sidebar layout. +The sidebar's **View options** menu groups tasks by **Project**, **Connection**, +**Status**, or **In one list**. Connection becomes available after another +computer is added, including while it is offline; enabling remote access on +this computer alone does not add that choice. Project and Connection views keep +known empty projects visible. A globe marks remote projects. Selecting a task or +project also selects its execution computer; select a **This computer** project +to return to local work. + +**Sort tasks by** offers **Last updated** and **Manual order**, independently of +grouping. Project and connection positions stay stable in either mode and can +be rearranged by dragging. Dragging a task silently selects Manual order; this +changes only presentation order, preserving its project, execution computer and +status. The manual task order carries across grouping changes and restarts on +the viewing desktop. New tasks appear at the top; activity on existing tasks +does not reshuffle their manual order. These preferences belong to the viewing +desktop's signed-in account. Project display names belong to the execution computer and the signed-in account. -**Rename project** in the sidebar project switcher updates that one name from +**Rename project** in a sidebar project's menu updates that one name from either its desktop or a paired controller. Every computer's folders have this action, so renaming does not switch the current conversation or draft. Every connected desktop receives the change, including computers whose projects are @@ -181,18 +191,22 @@ Maple explicitly clears public address lookup and builds Iroh without its port-mapping feature. The Minimal preset alone is not the port-mapping policy. There is no second encryption tunnel or custom routing layer. The remote protocol keeps the `maple-agent/remote/1` ALPN family and currently advertises -wire version 7 with minimum version 7, independent of application builds. Its +wire version 8 with minimum version 8, independent of application builds. Its shared project names, bounded navigation/history pages, stable row identities, attachment ranges, and per-connection task subscriptions remain unchanged. Project-change events carry only changed metadata; snapshots and project pages provide authoritative names, revisions, and default-workspace flags. -Version 6 and earlier peers receive an explicit version refusal. Bounded frames +Version 7 and earlier peers receive an explicit version refusal. Bounded frames and an explicit task-operation allowlist apply. +Manual sidebar ordering adds bounded task-order inputs to navigation, search, +project-group, and continuation queries. Both computers must be updated for this +wire-8 paging contract; older peers are refused during the handshake before +requests are admitted, rather than accepting a partially working sidebar. Account operations and arbitrary whole-host administration cannot be forwarded. The narrow experimental update capability uses the host's own signed updater; it does not expose the ordinary host-stop command. -Optional discovery reports protocol version `7.0.0` and explicit capabilities +Optional discovery reports protocol version `8.0.0` and explicit capabilities over separate request streams. The acknowledgement shape and ALPN remain readable across the required version transition. Bounded folder browsing and single-folder creation require the `project_folders_v1` capability. The controller checks support before sending @@ -200,7 +214,7 @@ either folder command. A compatible host without that capability keeps ordinary project/task access and its existing remote-update support; only the folder browser reports that support is unavailable or an update is needed. -Wire 7 requires attachment-reference sends. Images, ordinary files and archives +Since wire 7, task sends require attachment references. Images, ordinary files and archives are copied into the execution daemon's session-owned plaintext file store; SQLite contains metadata. Local imports use a controller-local staged path. Remote imports send bounded metadata followed by raw bytes on a dedicated @@ -212,9 +226,9 @@ separate from ordinary requests, range reads and urgent replies; generic files are limited to 30 MiB each, while composer images retain their 10 MiB limit. See [local storage](local-store.md). -Both computers must use wire 7 for remote access, including remote update +Both computers must use wire 8 for remote access, including remote update management. Upgrade the execution computer first while the old pair is still -compatible, then update the controller locally. A wire-7 controller stops +compatible, then update the controller locally. A wire-8 controller stops automatic reconnection after an explicit version refusal and shows **Update required**, without treating ordinary offline errors as version mismatches. Released wire-6 controllers keep their shipped behavior during the gap: cold From f7e036cf2249e1d8719382933bda75fa4b707123 Mon Sep 17 00:00:00 2001 From: marks Date: Tue, 27 Oct 2026 01:00:13 -0500 Subject: [PATCH 09/29] Agent: fix project moves and separate removal action --- apps/maple-agent/app/src/ui/chat/sidebar.rs | 118 ++++++++++-------- .../app/src/ui/chat/sidebar_ordering.rs | 18 +-- 2 files changed, 73 insertions(+), 63 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index 795131d21..e50c8ae8d 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -430,6 +430,12 @@ struct SidebarMenuItem { on_click: MenuAction, } +/// Destructive project actions are rendered in their own popup section. +struct ProjectMenuItems { + actions: Vec, + remove: Option, +} + /// A project header and its actions: the root /// path, its display name, and the element ids its rows and menus use. /// Built when the sections rebuild, never per frame. @@ -746,7 +752,7 @@ impl Sidebar { || self.order.created_before_ms != order.created_before_ms); self.order = order; self.order_loaded = true; - self.rebuild_task_ranks(); + self.rebuild_order_ranks(); } let filter = state.search.trim().to_lowercase(); let scope_changed = self.filter != filter @@ -1317,8 +1323,11 @@ impl Sidebar { #[cfg(test)] pub(super) fn project_menu_labels_for_test(&self, target: &ProjectTarget) -> Vec<&'static str> { - self.project_menu_items(target) + let items = self.project_menu_items(target); + items + .actions .into_iter() + .chain(items.remove) .map(|item| item.label) .collect() } @@ -2489,10 +2498,11 @@ impl Sidebar { } /// The actions of one project header, scoped to its execution computer. - fn project_menu_items(&self, target: &ProjectTarget) -> Vec { + fn project_menu_items(&self, target: &ProjectTarget) -> ProjectMenuItems { let rename_target = target.clone(); + let owns_project = target.computer.as_deref() == self.backend.remote_host(); let root = &target.path; - let mut items = vec![SidebarMenuItem { + let mut actions = vec![SidebarMenuItem { id: target.element_id("rename-project"), icon: "pencil", label: "Rename project", @@ -2500,6 +2510,39 @@ impl Sidebar { this.begin_rename(RenameTarget::Project(rename_target.clone()), cx) }), }]; + // Native folder/trust/removal actions belong to the execution computer. + if owns_project { + let open_root = root.clone(); + actions.push(SidebarMenuItem { + id: target.element_id("open-project"), + icon: "folder-open", + label: "Open folder", + on_click: Box::new(move |this, cx| this.open_folder(&open_root, cx)), + }); + if let Some(status) = self + .menu_trust + .as_ref() + .filter(|status| status.available && &status.path == root) + { + let trusted = status.decision == Some(true); + let path = root.clone(); + actions.push(SidebarMenuItem { + id: SharedString::from(format!("trust-project-{root}")), + icon: if trusted { "shield-check" } else { "lock" }, + label: if trusted { + "Untrust project" + } else { + "Trust project" + }, + on_click: Box::new(move |_, cx| { + cx.emit(SidebarEvent::SetTrust { + path: path.clone(), + trusted: !trusted, + }) + }), + }); + } + } if self.can_reorder() { for (forward, id, icon, label) in [ (false, "move-project-up", "arrow-up", "Move up"), @@ -2507,7 +2550,7 @@ impl Sidebar { ] { if self.project_move_available(target, forward) { let target = target.clone(); - items.push(SidebarMenuItem { + actions.push(SidebarMenuItem { id: target.element_id(id), icon, label, @@ -2516,51 +2559,16 @@ impl Sidebar { } } } - // Other actions are still scoped to the active ChatScreen. Never offer - // them against a foreign folder or reveal its path on this computer. - if target.computer.as_deref() != self.backend.remote_host() { - return items; - } - let open_root = root.clone(); - let remove_root = root.clone(); - items.push(SidebarMenuItem { - id: target.element_id("open-project"), - icon: "folder-open", - label: "Open folder", - on_click: Box::new(move |this, cx| this.open_folder(&open_root, cx)), - }); - if let Some(status) = self - .menu_trust - .as_ref() - .filter(|status| status.available && &status.path == root) - { - let trusted = status.decision == Some(true); - let path = status.path.clone(); - items.push(SidebarMenuItem { - id: SharedString::from(format!("trust-project-{root}")), - icon: if trusted { "shield-check" } else { "lock" }, - label: if trusted { - "Untrust project" - } else { - "Trust project" - }, - on_click: Box::new(move |_, cx| { - cx.emit(SidebarEvent::SetTrust { - path: path.clone(), - trusted: !trusted, - }); - }), - }); - } - items.push(SidebarMenuItem { - id: SharedString::from(format!("remove-project-{root}")), - icon: "trash-2", - label: "Remove project", - on_click: Box::new(move |_, cx| { - cx.emit(SidebarEvent::RemoveRoot(remove_root.clone())); - }), + let remove = owns_project.then(|| { + let root = root.clone(); + SidebarMenuItem { + id: target.element_id("remove-project"), + icon: "trash-2", + label: "Remove project", + on_click: Box::new(move |_, cx| cx.emit(SidebarEvent::RemoveRoot(root.clone()))), + } }); - items + ProjectMenuItems { actions, remove } } /// The task entry whose overflow menu is open. @@ -3571,14 +3579,14 @@ impl Sidebar { ) -> Div { let open = self.project_popup.is_open(&root.target); let menu = open.then(|| { - let menu = Menu::new(root.target.element_id("project-menu"), px(180.)) + let items = self.project_menu_items(&root.target); + let mut menu = Menu::new(root.target.element_id("project-menu"), px(180.)) .label("Project options") .application_vim(self.application_vim_enabled) - .items( - self.project_menu_items(&root.target) - .into_iter() - .map(menu_item), - ); + .items(items.actions.into_iter().map(menu_item)); + if let Some(remove) = items.remove { + menu = menu.separator().item(menu_item(remove)); + } let placement = self .project_menu_at .map_or(Placement::BelowEnd, Placement::At); diff --git a/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs b/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs index 8a8f59eea..ec402dd0d 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs @@ -50,7 +50,7 @@ impl Sidebar { this.order = crate::settings::install_sidebar_order(this.user_id.clone(), order); this.order_loaded = true; - this.rebuild_task_ranks(); + this.rebuild_order_ranks(); this.rebuild_sections(); this.refresh_task_order(cx); } @@ -85,7 +85,7 @@ impl Sidebar { ); } - pub(super) fn rebuild_task_ranks(&mut self) { + pub(super) fn rebuild_order_ranks(&mut self) { self.task_ranks.clear(); for (rank, task) in self.order.tasks.iter().enumerate() { self.task_ranks @@ -162,7 +162,7 @@ impl Sidebar { self.order_query_dirty = true; } self.order = order; - self.rebuild_task_ranks(); + self.rebuild_order_ranks(); } } @@ -215,7 +215,7 @@ impl Sidebar { } } if changed { - self.rebuild_task_ranks(); + self.rebuild_order_ranks(); // The settings writer owns retries and the Quit barrier, even if // this projection is replaced before its acknowledgement arrives. drop(crate::settings::save_sidebar_order( @@ -243,7 +243,7 @@ impl Sidebar { self.seed_task_order(); if !order_fits(&self.order) { self.order = previous; - self.rebuild_task_ranks(); + self.rebuild_order_ranks(); cx.emit(SidebarEvent::Notice( "This sidebar order is too large to save".into(), )); @@ -578,7 +578,7 @@ impl Sidebar { .collect::>(); self.order.tasks = visible.into_iter().chain(remaining).collect(); self.order.created_before_ms = crate::settings::timestamp_ms(); - self.rebuild_task_ranks(); + self.rebuild_order_ranks(); } pub(super) fn commit_reorder( @@ -635,19 +635,21 @@ impl Sidebar { move_item(&mut desired, source, target, after); self.order.tasks = merge_task_subset(&self.order.tasks, &desired); self.view.sort = SidebarSort::Manual; - self.rebuild_task_ranks(); } _ => return, } if !order_fits(&self.order) { self.order = previous_order; self.view = previous_view; - self.rebuild_task_ranks(); + self.rebuild_order_ranks(); cx.emit(SidebarEvent::Notice( "This sidebar order is too large to save".into(), )); return; } + // Every accepted move refreshes the display indexes, including project + // moves invoked by menus and drag-and-drop. + self.rebuild_order_ranks(); if self.view != previous_view { let view = self.view; crate::settings::update_settings_in_background(move |settings| { From de74df916670ae61ab1dbbe5020d5ea1e84d839a Mon Sep 17 00:00:00 2001 From: marks Date: Tue, 27 Oct 2026 01:02:28 -0500 Subject: [PATCH 10/29] Agent: group task menu actions and isolate deletion --- apps/maple-agent/app/src/ui/chat/sidebar.rs | 132 +++++++++++--------- apps/maple-agent/app/src/ui/chat/tests.rs | 14 +-- 2 files changed, 79 insertions(+), 67 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index e50c8ae8d..71165e5be 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -430,6 +430,13 @@ struct SidebarMenuItem { on_click: MenuAction, } +/// Ordinary task actions, workflow changes and deletion occupy separate groups. +struct TaskMenuItems { + actions: Vec, + workflow: Vec, + delete: SidebarMenuItem, +} + /// Destructive project actions are rendered in their own popup section. struct ProjectMenuItems { actions: Vec, @@ -2350,33 +2357,33 @@ impl Sidebar { 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(); - }; + /// Task actions are grouped by organization/editing, workflow, then deletion. + fn task_menu_items(&self, task: SidebarTaskEntry) -> Option { + let row = self.rows.get(task.session)?; let rung = TaskRung::of(task); + let mut actions = Vec::new(); + if rung == TaskRung::Active { + let task_id = row.id.to_string(); + actions.push(SidebarMenuItem { + id: row.menu_pin_id.clone(), + icon: "pin", + label: if task.pinned { + "Unpin task" + } else { + "Pin task" + }, + on_click: Box::new(move |this, cx| this.toggle_task_pin(&task_id, cx)), + }); + } let rename_id = row.id.to_string(); - let mut items = vec![SidebarMenuItem { + actions.push(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) }), - }]; - 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 => &[ ( @@ -2391,12 +2398,6 @@ impl Sidebar { "archive", "Archive task", ), - ( - TaskMove::Deleted, - &row.menu_delete_id, - "trash-2", - "Delete task", - ), ], TaskRung::Settled => &[ ( @@ -2411,38 +2412,38 @@ impl Sidebar { "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", - ), ], + TaskRung::Archived => &[( + TaskMove::Active, + &row.menu_reopen_id, + "undo-2", + "Reopen task", + )], }; - 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 + let workflow = moves + .iter() + .map(|&(to, id, icon, label)| { + let task_id = row.id.to_string(); + SidebarMenuItem { + id: id.clone(), + icon, + label, + on_click: Box::new(move |this, cx| this.move_task(&task_id, to, cx)), + } + }) + .collect(); + let task_id = row.id.to_string(); + let delete = SidebarMenuItem { + id: row.menu_delete_id.clone(), + icon: "trash-2", + label: "Delete task", + on_click: Box::new(move |this, cx| this.move_task(&task_id, TaskMove::Deleted, cx)), + }; + Some(TaskMenuItems { + actions, + workflow, + delete, + }) } /// Send a task to a rung. The runtime owns the state, so every move @@ -2482,9 +2483,13 @@ impl Sidebar { #[cfg(test)] 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) + .and_then(|task| self.task_menu_items(task)) + .map(|items| { + items + .actions .into_iter() + .chain(items.workflow) + .chain(std::iter::once(items.delete)) .map(|item| item.label) .collect() }) @@ -3626,10 +3631,17 @@ impl Sidebar { 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(180.)) .label("Task options") - .application_vim(self.application_vim_enabled) - .items(self.task_menu_items(task).into_iter().map(menu_item)); + .application_vim(self.application_vim_enabled); + if let Some(items) = self.task_menu_items(task) { + menu = menu + .items(items.actions.into_iter().map(menu_item)) + .separator() + .items(items.workflow.into_iter().map(menu_item)) + .separator() + .item(menu_item(items.delete)); + } self.popup.render(menu, Placement::BelowEnd, window, cx) }); let session = row.id.clone(); diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index da61042f0..b01b87205 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -5854,8 +5854,8 @@ mod state_tests { assert_eq!( sidebar.task_menu_labels_for_test("s1"), vec![ - "Rename task", "Pin task", + "Rename task", "Settle task", "Archive task", "Delete task" @@ -6185,8 +6185,8 @@ mod state_tests { let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); sidebar.update(cx, |sidebar, cx| sidebar.open_task_menu_for_test("s1", cx)); cx.run_until_parked(); - // "Rename task" comes first. - cx.simulate_keystrokes("down enter"); + // Pin comes first; Rename follows it. + cx.simulate_keystrokes("down down enter"); assert!(matches!( sidebar.update(cx, |sidebar, _| sidebar.rename_target()), Some(RenameTarget::Task(ref target)) if target == "s1" @@ -6202,12 +6202,12 @@ mod state_tests { cx.simulate_keystrokes("j j"); assert_eq!( sidebar.update(cx, |sidebar, _| sidebar.popup_highlight()), - Some("pin-task-s1".into()) + Some("rename-task-s1".into()) ); cx.simulate_keystrokes("k"); assert_eq!( sidebar.update(cx, |sidebar, _| sidebar.popup_highlight()), - Some("rename-task-s1".into()) + Some("pin-task-s1".into()) ); } @@ -6226,13 +6226,13 @@ mod state_tests { cx.simulate_keystrokes(keys); assert!(sidebar.update(cx, |sidebar, _| sidebar.task_menu().is_none())); }; - // Rows: Rename, Pin, Settle, Archive, Delete. + // Rows: Pin, Rename | Settle, Archive | Delete; separators are skipped. pick(cx, "down down down enter"); // Rows: Rename, Reopen, Archive, Delete. chat.update(cx, |this, cx| this.settle_task("s1", cx)); pick(cx, "down down enter"); chat.update(cx, |this, cx| this.unsettle_task("s1", cx)); - pick(cx, "down down enter"); + pick(cx, "down enter"); assert_eq!(chat.update(cx, |this, cx| this.sidebar_pinned(cx)), vec![0]); } From 0be98a564886f68f3f3a8d8ba3bdff71f1d5a634 Mon Sep 17 00:00:00 2001 From: marks Date: Tue, 27 Oct 2026 01:19:01 -0500 Subject: [PATCH 11/29] Agent: separate project ordering menu section --- apps/maple-agent/app/src/ui/chat/sidebar.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index 71165e5be..2ec743516 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -440,6 +440,7 @@ struct TaskMenuItems { /// Destructive project actions are rendered in their own popup section. struct ProjectMenuItems { actions: Vec, + ordering: Vec, remove: Option, } @@ -1334,6 +1335,7 @@ impl Sidebar { items .actions .into_iter() + .chain(items.ordering) .chain(items.remove) .map(|item| item.label) .collect() @@ -2548,6 +2550,7 @@ impl Sidebar { }); } } + let mut ordering = Vec::new(); if self.can_reorder() { for (forward, id, icon, label) in [ (false, "move-project-up", "arrow-up", "Move up"), @@ -2555,7 +2558,7 @@ impl Sidebar { ] { if self.project_move_available(target, forward) { let target = target.clone(); - actions.push(SidebarMenuItem { + ordering.push(SidebarMenuItem { id: target.element_id(id), icon, label, @@ -2573,7 +2576,11 @@ impl Sidebar { on_click: Box::new(move |_, cx| cx.emit(SidebarEvent::RemoveRoot(root.clone()))), } }); - ProjectMenuItems { actions, remove } + ProjectMenuItems { + actions, + ordering, + remove, + } } /// The task entry whose overflow menu is open. @@ -3589,6 +3596,11 @@ impl Sidebar { .label("Project options") .application_vim(self.application_vim_enabled) .items(items.actions.into_iter().map(menu_item)); + if !items.ordering.is_empty() { + menu = menu + .separator() + .items(items.ordering.into_iter().map(menu_item)); + } if let Some(remove) = items.remove { menu = menu.separator().item(menu_item(remove)); } From 1490c7dab8658ff1c2829ec87b2c51b23962de99 Mon Sep 17 00:00:00 2001 From: marks Date: Tue, 27 Oct 2026 08:39:23 -0500 Subject: [PATCH 12/29] Agent: align remote compatibility coverage after master sync --- apps/maple-agent/app/src/remote/update.rs | 8 ++++---- apps/maple-agent/docs/updates.md | 11 ++++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/apps/maple-agent/app/src/remote/update.rs b/apps/maple-agent/app/src/remote/update.rs index cb5093a5c..bf433aeb5 100644 --- a/apps/maple-agent/app/src/remote/update.rs +++ b/apps/maple-agent/app/src/remote/update.rs @@ -520,7 +520,7 @@ mod tests { fn a_cached_peer_status_is_not_remote_refresh_support() { let mut info = current_peer_for_compatibility(); assert!(info.supports_remote_refresh()); - info.protocol.version = "7.0.0".into(); + info.protocol.version = "8.0.0".into(); info.protocol .capabilities .retain(|value| value != REFRESH_CAPABILITY); @@ -953,17 +953,17 @@ mod tests { serde_json::from_value(fixtures["peer_info_without_tool_summary_start"].clone()) .unwrap(); without.validate().unwrap(); - assert!(without.supports_remote_updates()); + assert!(!without.supports_remote_updates()); assert!(!without.supports_tool_summary_start()); let mut info = PeerInfo { protocol: ProtocolInfo::current(), ..without }; - for version in ["7.0.0", "7.1.0", "7.2.0"] { + for version in ["8.0.0", "8.1.0", "8.2.0"] { info.protocol.version = version.into(); assert!(info.supports_tool_summary_start(), "{version}"); } - for version in ["6.2.0", "8.0.0", "invalid"] { + for version in ["6.2.0", "7.1.0", "9.0.0", "invalid"] { info.protocol.version = version.into(); assert!(!info.supports_tool_summary_start(), "{version}"); } diff --git a/apps/maple-agent/docs/updates.md b/apps/maple-agent/docs/updates.md index babf91d9b..d5f9c7a83 100644 --- a/apps/maple-agent/docs/updates.md +++ b/apps/maple-agent/docs/updates.md @@ -47,11 +47,12 @@ unlocked; an independent host-side check runs at the final shutdown boundary. The controller's updater is never installed or restarted by this operation. Daemon-only hosts require a local update. Remote management requires compatible remote protocol majors, just like ordinary task access. The attachment change -requires wire 7 on both computers; it does not add a cross-version management -bridge. Update the execution computer first while both still use wire 6, then -update the controller through its local updater. During the expected mismatch, -retain the original install operation rather than resubmitting it; verify the -running identity and its receipt after both computers reconnect on wire 7. +introduced wire 7; Manual sidebar paging now requires wire 8 on both computers. +There is no cross-version management bridge. Update the execution computer +first while both still use the same wire major, then update the controller +through its local updater. During the expected mismatch, retain the original +install operation rather than resubmitting it; verify the running identity and +its receipt after both computers reconnect on wire 8. Updating the controller first requires updating the execution computer directly. A remote mismatch does not disable the controller's local update controls. Definite install refusals are shown as declined requests and require a new check From 6e4eba4a4189a6772b2345cbf82a7472501acfbe Mon Sep 17 00:00:00 2001 From: marks Date: Tue, 27 Oct 2026 09:47:58 -0500 Subject: [PATCH 13/29] Agent: keep sidebar ordering local and restore remote protocol 7 --- apps/maple-agent/app/src/backend/paging.rs | 21 +- apps/maple-agent/app/src/remote/update.rs | 24 +- apps/maple-agent/app/src/remote/wire.rs | 43 +- apps/maple-agent/app/src/ui/chat/sidebar.rs | 7 + .../app/src/ui/chat/sidebar_catalog.rs | 626 ++++++++++++++++++ .../app/src/ui/chat/sidebar_computers.rs | 235 +++++-- .../app/src/ui/chat/sidebar_ordering.rs | 24 +- .../app/src/ui/chat/sidebar_paging.rs | 79 ++- .../crates/maple-agent/src/acp/mod.rs | 1 - .../crates/maple-agent/src/store/mod.rs | 10 +- .../src/store/paging/navigation.rs | 403 ++--------- .../src/store/paging/navigation_tests.rs | 13 - .../maple-agent/crates/store-bench/src/run.rs | 3 - apps/maple-agent/docs/remote-access.md | 18 +- apps/maple-agent/docs/updates.md | 13 +- 15 files changed, 964 insertions(+), 556 deletions(-) create mode 100644 apps/maple-agent/app/src/ui/chat/sidebar_catalog.rs diff --git a/apps/maple-agent/app/src/backend/paging.rs b/apps/maple-agent/app/src/backend/paging.rs index d4ab0c63c..51eb20c1e 100644 --- a/apps/maple-agent/app/src/backend/paging.rs +++ b/apps/maple-agent/app/src/backend/paging.rs @@ -12,9 +12,9 @@ use maple_agent::agent::{ use maple_agent::store::{AccountId, PageTransport, Store}; pub use maple_agent::store::{ AttachmentRangeReply, HistoryAnchorRequest, HistoryPage, HistoryPageRequest, - NavigationGetTasksReply, NavigationGroupPage, NavigationGroupQuery, NavigationManualOrder, - NavigationProjectPage, NavigationSearchQuery, NavigationTaskPage, NavigationTaskQuery, - OpenTaskPageRequest, ProjectSummary, RowRange, RowRangeRequest, TaskSummary, TimelineRow, + NavigationGetTasksReply, NavigationGroupPage, NavigationGroupQuery, NavigationProjectPage, + NavigationSearchQuery, NavigationTaskPage, NavigationTaskQuery, OpenTaskPageRequest, + ProjectSummary, RowRange, RowRangeRequest, TaskSummary, TimelineRow, }; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use serde_json::Value; @@ -27,8 +27,6 @@ use crate::host::protocol::Request; pub struct PinnedTasksRequest { pub project: Option, pub cursor: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub manual: Option, #[serde(default = "default_limit")] pub limit: u16, } @@ -433,16 +431,11 @@ pub(super) async fn dispatch( let (store, account) = context(backend, &user).await?; encode( blocking(move || { - store.list_task_summaries( + store.pinned_task_summaries( account, - &NavigationTaskQuery { - section: maple_agent::store::TaskSection::Pinned, - project: query.project, - pins: maple_agent::store::PinFilter::Ignore, - cursor: query.cursor, - manual: query.manual, - limit: query.limit, - }, + query.project.as_deref(), + query.cursor.as_deref(), + query.limit, transport, ) }) diff --git a/apps/maple-agent/app/src/remote/update.rs b/apps/maple-agent/app/src/remote/update.rs index bf433aeb5..a32cb82cc 100644 --- a/apps/maple-agent/app/src/remote/update.rs +++ b/apps/maple-agent/app/src/remote/update.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Deserializer, Serialize}; use std::sync::{Arc, Weak}; use uuid::Uuid; -pub const PROTOCOL_VERSION: &str = "8.0.0"; +pub const PROTOCOL_VERSION: &str = "7.1.0"; pub const PEER_INFO_CAPABILITY: &str = "peer_info_v1"; pub const INSTALL_CAPABILITY: &str = "remote_update_install_v1"; pub const REFRESH_CAPABILITY: &str = "remote_update_refresh_v1"; @@ -520,7 +520,7 @@ mod tests { fn a_cached_peer_status_is_not_remote_refresh_support() { let mut info = current_peer_for_compatibility(); assert!(info.supports_remote_refresh()); - info.protocol.version = "8.0.0".into(); + info.protocol.version = "7.0.0".into(); info.protocol .capabilities .retain(|value| value != REFRESH_CAPABILITY); @@ -616,7 +616,7 @@ mod tests { #[test] fn responses_accept_future_fields_and_unknown_status_without_enabling_mutation() { let value = json!({ - "protocol":{"version":"8.1.0","capabilities":[INSTALL_CAPABILITY,"future_feature"],"future":true}, + "protocol":{"version":"7.1.0","capabilities":[INSTALL_CAPABILITY,"future_feature"],"future":true}, "running":{"version":"0.0.6","source_sha":"a".repeat(40),"platform":"linux-x86_64","future":true}, "update":{"state":"future_state","future":true}, "future":true @@ -626,10 +626,10 @@ mod tests { assert_eq!(info.update.state, UpdateState::Unknown); assert!(info.supports_remote_updates()); let mut next_major = info.clone(); - next_major.protocol.version = "9.0.0".into(); + next_major.protocol.version = "8.0.0".into(); assert!(!next_major.supports_remote_updates()); let mut previous_major = info.clone(); - previous_major.protocol.version = "7.0.0".into(); + previous_major.protocol.version = "6.2.0".into(); assert!(!previous_major.supports_remote_updates()); let mut no_capability = info; no_capability.protocol.capabilities.clear(); @@ -915,18 +915,18 @@ mod tests { fn project_folder_support_requires_its_capability_and_current_major() { let mut info = current_peer_for_compatibility(); assert!(info.supports_project_folders()); - for version in ["8.0.0", "8.1.0", "8.2.0"] { + for version in ["7.0.0", "7.1.0", "7.2.0"] { info.protocol.version = version.into(); assert!( info.supports_project_folders(), "explicit capability in {version}" ); } - for version in ["6.2.0", "7.0.0", "9.0.0", "invalid"] { + for version in ["6.2.0", "8.0.0", "invalid"] { info.protocol.version = version.into(); assert!(!info.supports_project_folders()); } - info.protocol.version = "8.0.0".into(); + info.protocol.version = "7.0.0".into(); info.protocol.capabilities = vec!["peer_info_v1".into(), "remote_update_install_v1".into()]; assert!( info.supports_remote_updates(), @@ -936,7 +936,7 @@ mod tests { !info.supports_project_folders(), "folder support still requires its capability" ); - info.protocol.version = "8.1.0".into(); + info.protocol.version = "7.1.0".into(); assert!( !info.supports_project_folders(), "a minor version alone is not a capability" @@ -953,17 +953,17 @@ mod tests { serde_json::from_value(fixtures["peer_info_without_tool_summary_start"].clone()) .unwrap(); without.validate().unwrap(); - assert!(!without.supports_remote_updates()); + assert!(without.supports_remote_updates()); assert!(!without.supports_tool_summary_start()); let mut info = PeerInfo { protocol: ProtocolInfo::current(), ..without }; - for version in ["8.0.0", "8.1.0", "8.2.0"] { + for version in ["7.0.0", "7.1.0", "7.2.0"] { info.protocol.version = version.into(); assert!(info.supports_tool_summary_start(), "{version}"); } - for version in ["6.2.0", "7.1.0", "9.0.0", "invalid"] { + for version in ["6.2.0", "8.0.0", "invalid"] { info.protocol.version = version.into(); assert!(!info.supports_tool_summary_start(), "{version}"); } diff --git a/apps/maple-agent/app/src/remote/wire.rs b/apps/maple-agent/app/src/remote/wire.rs index 344404be0..710bce588 100644 --- a/apps/maple-agent/app/src/remote/wire.rs +++ b/apps/maple-agent/app/src/remote/wire.rs @@ -6,7 +6,7 @@ use zeroize::Zeroizing; // Keep the ALPN family stable so older peers receive the explicit version refusal. pub const ALPN: &[u8] = b"maple-agent/remote/1"; -pub const VERSION: u32 = 8; +pub const VERSION: u32 = 7; pub const MAX_FRAME: usize = 16 * 1024 * 1024; pub const MAX_PREFACE: usize = 4096; pub const MAX_MANAGEMENT_REPLY: usize = 16 * 1024; @@ -238,9 +238,6 @@ impl Method { ) } pub fn parse(value: &str) -> Result { - if matches!(value, "get_sidebar_order" | "set_sidebar_order") { - return Err("This operation is available only on the local computer.".into()); - } serde_json::from_value(serde_json::Value::String(value.into())) .map_err(|_| "This operation is available only on the local computer.".into()) } @@ -568,7 +565,7 @@ mod tests { #[test] fn optional_management_keeps_the_shipped_control_ack_and_alpn() { assert_eq!(ALPN, b"maple-agent/remote/1"); - assert_eq!(VERSION, 8); + assert_eq!(VERSION, 7); assert_eq!( serde_json::to_string(&Ok::<(), Refusal>(())).unwrap(), r#"{"Ok":null}"# @@ -613,38 +610,6 @@ mod tests { } } - #[tokio::test] - async fn manual_paging_wire_upgrade_refuses_seven_before_decoding_requests() { - let previous: serde_json::Value = - serde_json::from_str(include_str!("wire_v7.json")).unwrap(); - for kind in ["hello", "pair"] { - let (mut tx, mut rx) = tokio::io::duplex(4096); - write(&mut tx, &previous[kind], MAX_PREFACE).await.unwrap(); - let refusal = match read_hello(&mut rx).await { - Err(refusal) => refusal, - Ok(_) => panic!("wire 7 must update before manual paged queries are sent"), - }; - assert!(is_protocol_incompatibility_refusal(&refusal.into_error())); - } - } - - #[test] - fn released_wire_seven_refuses_eight_before_request_admission() { - // Preserve the released policy independently of the current constant. - let mut current: serde_json::Value = - serde_json::from_str(include_str!("wire_v7.json")).unwrap(); - for kind in ["hello", "pair"] { - current[kind]["version"] = serde_json::json!(VERSION); - current[kind]["minimum_version"] = serde_json::json!(VERSION); - let version = current[kind]["version"].as_u64().unwrap(); - let minimum = current[kind]["minimum_version"].as_u64().unwrap(); - let refused = minimum == 0 || minimum > version || minimum > 7 || version < 7; - assert!(refused, "released wire 7 must refuse the new baseline"); - let hello: Hello = serde_json::from_value(current[kind].clone()).unwrap(); - assert!(hello.check_version().is_ok()); - } - } - #[tokio::test] async fn released_wire_six_refuses_seven_before_reading_any_request() { // Freeze the released reader's version policy and refusal shape. Do not @@ -744,7 +709,7 @@ mod tests { ); } #[test] - fn frozen_wire_seven_examples_keep_their_payload_shapes() { + fn wire_seven_examples_are_stable() { let examples: serde_json::Value = serde_json::from_str(include_str!("wire_v7.json")).unwrap(); fn check(value: &serde_json::Value) { @@ -817,7 +782,7 @@ mod tests { "fixture must exercise a forwarded event" ); } - assert_eq!(examples["hello"]["version"], 7); + assert_eq!(examples["hello"]["version"], VERSION); } #[test] diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index 2ec743516..0b921ddb2 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -10,6 +10,8 @@ //! Clicks that need the window (new task, settings, collapse) call the //! screen directly from plain closures, which run outside any update. +#[path = "sidebar_catalog.rs"] +mod catalog; #[path = "sidebar_computers.rs"] mod computers; #[path = "sidebar_ordering.rs"] @@ -62,6 +64,7 @@ pub(crate) struct BrowseState { /// Application Vim's row. Other computers' sidebars name a task by its /// computer, so it travels in that form. vim_selected: Option, + catalog: catalog::Catalog, computers: computers::ComputerBrowseState, } @@ -569,6 +572,7 @@ pub(super) struct Sidebar { order: SidebarOrder, order_loaded: bool, order_loading: bool, + manual_catalog: catalog::Catalog, task_ranks: HashMap, HashMap>, order_query_dirty: bool, project_ranks: HashMap, HashMap>, @@ -742,6 +746,7 @@ impl Sidebar { }), target => target, }), + catalog: self.manual_catalog.clone(), computers: self.computer_browse_state(), } } @@ -750,6 +755,7 @@ impl Sidebar { if state.computers.user_id != self.user_id { return; } + self.manual_catalog = state.catalog; let mut sort_changed = self.order_query_dirty || self.view.sort != state.view.sort; self.view = state.view; self.collapsed_projects = state.collapsed_projects; @@ -890,6 +896,7 @@ impl Sidebar { order: SidebarOrder::default(), order_loaded: false, order_loading: false, + manual_catalog: catalog::Catalog::default(), task_ranks: HashMap::new(), order_query_dirty: false, project_ranks: HashMap::new(), diff --git a/apps/maple-agent/app/src/ui/chat/sidebar_catalog.rs b/apps/maple-agent/app/src/ui/chat/sidebar_catalog.rs new file mode 100644 index 000000000..044695af8 --- /dev/null +++ b/apps/maple-agent/app/src/ui/chat/sidebar_catalog.rs @@ -0,0 +1,626 @@ +//! Desktop-only manual ordering over the unchanged summary paging protocol. +//! Only complete small inventories are reused. Larger scopes are scanned with +//! a bounded candidate heap; neither saved ranks nor local cursors leave here. +use std::cmp::Ordering; +use std::collections::{BinaryHeap, HashMap, VecDeque}; +use std::sync::{Arc, Mutex, Weak}; + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use maple_agent::store::{ + NavigationSearchQuery, NavigationTaskPage, NavigationTaskQuery, TaskSummary, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tokio::sync::Semaphore; +use tokio_util::sync::CancellationToken; + +use crate::backend::{AgentBackend, PinnedTasksRequest}; + +pub(super) const CANCELLED: &str = "Sidebar manual ordering was cancelled"; +const BAD_CURSOR: &str = "The sidebar order changed; refresh this list"; +const CURSOR_PREFIX: &str = "sidebar-manual-v1:"; +const MAX_CURSOR_BYTES: usize = 4096; +const MAX_WIRE_CURSOR_BYTES: usize = 16 * 1024; +const MAX_PAGE: usize = 200; +const MAX_INVENTORY_ROWS: usize = 2048; +const MAX_INVENTORY_BYTES: usize = 8 * 1024 * 1024; +const MAX_CACHE_BYTES: usize = 16 * 1024 * 1024; +const MAX_CACHE_SCOPES: usize = 8; + +#[derive(Clone, Serialize)] +pub(super) struct ManualOrder { + pub(super) cutoff: i64, + pub(super) tasks: Vec, +} + +impl ManualOrder { + /// Used after independently read active/settled prefixes are combined. + /// Callers run their summary preparation on the backend executor. + pub(super) fn sort(&self, tasks: &mut [TaskSummary]) { + let ranks = Ranks::new(self); + tasks.sort_by_cached_key(|task| ranks.position(task)); + } + + fn validate(&self) -> Result<(), String> { + if self.cutoff < 0 || self.tasks.len() > 8192 { + return Err("Invalid sidebar manual order".into()); + } + let mut seen = std::collections::HashSet::new(); + for task in &self.tasks { + if task.is_empty() + || task.len() > 256 + || task.chars().any(char::is_control) + || !seen.insert(task) + { + return Err("Invalid sidebar manual order".into()); + } + } + Ok(()) + } +} + +#[derive(Clone, Serialize)] +#[serde(tag = "kind", content = "query")] +pub(super) enum TaskQuery { + Tasks(NavigationTaskQuery), + Search(NavigationSearchQuery), + Pinned(PinnedTasksRequest), +} + +impl TaskQuery { + fn cursor(&self) -> Option<&str> { + match self { + Self::Tasks(query) => query.cursor.as_deref(), + Self::Search(query) => query.cursor.as_deref(), + Self::Pinned(query) => query.cursor.as_deref(), + } + } + + fn limit(&self) -> usize { + usize::from(match self { + Self::Tasks(query) => query.limit, + Self::Search(query) => query.limit, + Self::Pinned(query) => query.limit, + }) + } + + fn wire_query(&self, cursor: Option) -> Self { + let mut query = self.clone(); + match &mut query { + Self::Tasks(query) => { + query.cursor = cursor; + query.limit = 200; + } + Self::Search(query) => { + query.cursor = cursor; + // Protocol seven bounds search pages to one hundred rows. + query.limit = 100; + } + Self::Pinned(query) => { + query.cursor = cursor; + query.limit = 200; + } + } + query + } + + async fn fetch( + &self, + backend: &AgentBackend, + user: &str, + cursor: Option, + ) -> Result { + match self.wire_query(cursor) { + Self::Tasks(query) => backend.list_tasks(user, query).await, + Self::Search(query) => backend.search_tasks(user, query).await, + Self::Pinned(query) => backend.pinned_tasks(user, query).await, + } + } +} + +#[derive(Default, Clone)] +pub(super) struct Catalog { + shared: Arc, +} + +struct Shared { + state: Mutex, + scan_slots: Arc, +} + +impl Default for Shared { + fn default() -> Self { + Self { + state: Mutex::new(State::default()), + scan_slots: Arc::new(Semaphore::new(2)), + } + } +} + +impl Drop for Shared { + fn drop(&mut self) { + self.state + .get_mut() + .unwrap_or_else(|error| error.into_inner()) + .pending + .cancel(); + } +} + +#[derive(Default)] +struct State { + generation: u64, + pending: CancellationToken, + cache: VecDeque, + cache_bytes: usize, +} + +struct Inventory { + scope: [u8; 32], + backend: Weak, + tasks: Arc>, + bytes: usize, +} + +impl Catalog { + pub(super) fn reader(&self) -> Reader { + let state = self + .shared + .state + .lock() + .unwrap_or_else(|error| error.into_inner()); + Reader { + shared: Arc::downgrade(&self.shared), + generation: state.generation, + pending: state.pending.clone(), + } + } + + /// Live activity invalidates reuse without starving scans already reading. + /// Their result can finish, but cannot refill this generation's cache. + pub(super) fn invalidate(&self) { + let mut state = self + .shared + .state + .lock() + .unwrap_or_else(|error| error.into_inner()); + state.generation = state.generation.wrapping_add(1); + state.cache.clear(); + state.cache_bytes = 0; + } + + /// Explicit scope/order refreshes also fence the caller's request generation. + pub(super) fn cancel_pending(&self) { + let mut state = self + .shared + .state + .lock() + .unwrap_or_else(|error| error.into_inner()); + state.pending.cancel(); + state.pending = CancellationToken::new(); + } +} + +#[derive(Clone)] +pub(super) struct Reader { + shared: Weak, + generation: u64, + pending: CancellationToken, +} + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +struct Position { + band: u8, + value: i64, + id: String, +} + +struct Ranks<'a> { + cutoff: i64, + tasks: HashMap<&'a str, usize>, +} + +impl<'a> Ranks<'a> { + fn new(order: &'a ManualOrder) -> Self { + Self { + cutoff: order.cutoff, + tasks: order + .tasks + .iter() + .enumerate() + .map(|(rank, id)| (id.as_str(), rank)) + .collect(), + } + } + + fn position(&self, task: &TaskSummary) -> Position { + let (band, value) = if let Some(rank) = self.tasks.get(task.uuid.as_str()) { + (1, *rank as i64) + } else if task.created_ms > self.cutoff { + (0, task.created_ms.saturating_neg()) + } else { + (2, task.created_ms.saturating_neg()) + }; + Position { + band, + value, + id: task.uuid.clone(), + } + } +} + +struct Candidate { + position: Position, + task: TaskSummary, +} + +impl PartialEq for Candidate { + fn eq(&self, other: &Self) -> bool { + self.position == other.position + } +} +impl Eq for Candidate {} +impl PartialOrd for Candidate { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for Candidate { + fn cmp(&self, other: &Self) -> Ordering { + self.position.cmp(&other.position) + } +} + +/// The heap contains only the next page and one lookahead row. Its root is the +/// worst retained row, so no number of discarded history pages grows memory. +struct Candidates { + rows: BinaryHeap, + maximum: usize, +} + +impl Candidates { + fn new(limit: usize) -> Self { + Self { + rows: BinaryHeap::new(), + maximum: limit + 1, + } + } + + fn offer(&mut self, candidate: Candidate) { + if let Some(previous) = self + .rows + .iter() + .find(|row| row.task.uuid == candidate.task.uuid) + { + if previous.task.updated_ms > candidate.task.updated_ms { + return; + } + self.rows.retain(|row| row.task.uuid != candidate.task.uuid); + } + if self.rows.len() < self.maximum { + self.rows.push(candidate); + } else if self + .rows + .peek() + .is_some_and(|worst| candidate.position < worst.position) + { + self.rows.pop(); + self.rows.push(candidate); + } + } + + fn finish( + self, + scope: [u8; 32], + after: Option, + limit: usize, + ) -> Result { + let mut rows = self.rows.into_sorted_vec(); + let more = rows.len() > limit; + rows.truncate(limit); + let last = rows.last().map(|row| row.position.clone()).or(after); + Ok(NavigationTaskPage { + tasks: rows.into_iter().map(|row| row.task).collect(), + next_cursor: more.then(|| encode_cursor(scope, last)).transpose()?, + }) + } +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Cursor { + scope: [u8; 32], + after: Option, +} + +fn encode_cursor(scope: [u8; 32], after: Option) -> Result { + let bytes = serde_json::to_vec(&Cursor { scope, after }).map_err(|_| BAD_CURSOR.to_string())?; + Ok(format!("{CURSOR_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes))) +} + +fn decode_cursor(cursor: Option<&str>, scope: [u8; 32]) -> Result, String> { + let Some(cursor) = cursor else { + return Ok(None); + }; + if cursor.len() > MAX_CURSOR_BYTES { + return Err(BAD_CURSOR.into()); + } + let encoded = cursor.strip_prefix(CURSOR_PREFIX).ok_or(BAD_CURSOR)?; + let bytes = URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| BAD_CURSOR.to_string())?; + let cursor: Cursor = serde_json::from_slice(&bytes).map_err(|_| BAD_CURSOR.to_string())?; + if cursor.scope != scope + || cursor + .after + .as_ref() + .is_some_and(|after| after.band > 2 || after.id.is_empty() || after.id.len() > 256) + { + return Err(BAD_CURSOR.into()); + } + Ok(cursor.after) +} + +fn scopes( + backend: &Arc, + user: &str, + query: &TaskQuery, + order: &ManualOrder, +) -> Result<([u8; 32], [u8; 32]), String> { + let mut hash = Sha256::new(); + hash.update(b"maple.sidebar.catalog.v1"); + hash.update((Arc::as_ptr(backend) as usize).to_be_bytes()); + hash.update((user.len() as u64).to_be_bytes()); + hash.update(user.as_bytes()); + let host = backend.remote_host().unwrap_or(""); + hash.update((host.len() as u64).to_be_bytes()); + hash.update(host.as_bytes()); + hash.update(serde_json::to_vec(&query.wire_query(None)).map_err(|_| BAD_CURSOR.to_string())?); + let inventory: [u8; 32] = hash.finalize().into(); + let mut hash = Sha256::new(); + hash.update(inventory); + hash.update(serde_json::to_vec(order).map_err(|_| BAD_CURSOR.to_string())?); + Ok((inventory, hash.finalize().into())) +} + +fn charged_bytes(task: &TaskSummary) -> Result { + // Charge owned strings, struct/index overhead and JSON escaping conservatively. + Ok(serde_json::to_vec(task) + .map_err(|_| "Could not prepare sidebar summaries".to_string())? + .len() + .saturating_add(std::mem::size_of::()) + .saturating_add(task.uuid.len()) + .saturating_add(64)) +} + +impl Reader { + fn owner(&self) -> Result, String> { + if self.pending.is_cancelled() { + return Err(CANCELLED.into()); + } + self.shared.upgrade().ok_or_else(|| CANCELLED.into()) + } + + fn authorize(&self, backend: &AgentBackend, user: &str) -> Result<(), String> { + self.owner()?; + // The IPC client's current account is maintained by its host event + // stream. This also fences cache-only replies without a network call. + if backend.saved_user_id().as_deref() != Some(user) { + return Err("The sidebar account is no longer active".into()); + } + Ok(()) + } + + fn cached( + &self, + backend: &Arc, + scope: [u8; 32], + ) -> Result>>, String> { + let shared = self.owner()?; + let mut state = shared + .state + .lock() + .unwrap_or_else(|error| error.into_inner()); + let found = state.cache.iter().position(|entry| { + entry.scope == scope + && entry + .backend + .upgrade() + .is_some_and(|owner| Arc::ptr_eq(&owner, backend)) + }); + let Some(index) = found else { + return Ok(None); + }; + let inventory = state.cache.remove(index).expect("located cache entry"); + let tasks = inventory.tasks.clone(); + state.cache.push_back(inventory); + Ok(Some(tasks)) + } + + fn install( + &self, + backend: &Arc, + scope: [u8; 32], + tasks: Vec, + bytes: usize, + ) { + let Ok(shared) = self.owner() else { + return; + }; + let mut state = shared + .state + .lock() + .unwrap_or_else(|error| error.into_inner()); + if state.generation != self.generation { + return; + } + if let Some(index) = state.cache.iter().position(|entry| entry.scope == scope) { + if let Some(previous) = state.cache.remove(index) { + state.cache_bytes = state.cache_bytes.saturating_sub(previous.bytes); + } + } + while state.cache.len() >= MAX_CACHE_SCOPES + || state.cache_bytes.saturating_add(bytes) > MAX_CACHE_BYTES + { + let Some(previous) = state.cache.pop_front() else { + break; + }; + state.cache_bytes = state.cache_bytes.saturating_sub(previous.bytes); + } + state.cache_bytes = state.cache_bytes.saturating_add(bytes); + state.cache.push_back(Inventory { + scope, + backend: Arc::downgrade(backend), + tasks: Arc::new(tasks), + bytes, + }); + } + + /// Derive an exact continuation for a prefix of this same returned page. + /// Group projection can merge two state prefixes without a second scan. + pub(super) fn prefix_cursor( + &self, + backend: &Arc, + user: &str, + query: &TaskQuery, + order: &ManualOrder, + page: &NavigationTaskPage, + keep: usize, + ) -> Result, String> { + self.authorize(backend, user)?; + order.validate()?; + if keep > page.tasks.len() { + return Err(BAD_CURSOR.into()); + } + if keep == page.tasks.len() && page.next_cursor.is_none() { + return Ok(None); + } + let (_, scope) = scopes(backend, user, query, order)?; + let after = if let Some(task) = keep.checked_sub(1).and_then(|index| page.tasks.get(index)) + { + Some(Ranks::new(order).position(task)) + } else { + decode_cursor(query.cursor(), scope)? + }; + encode_cursor(scope, after).map(Some) + } + + pub(super) async fn page( + self, + backend: Arc, + user: String, + query: TaskQuery, + order: ManualOrder, + ) -> Result { + self.authorize(&backend, &user)?; + order.validate()?; + let limit = query.limit(); + if limit > MAX_PAGE { + return Err("Sidebar page is too large".into()); + } + let (inventory_scope, scope) = scopes(&backend, &user, &query, &order)?; + let after = decode_cursor(query.cursor(), scope)?; + let ranks = Ranks::new(&order); + let mut candidates = Candidates::new(limit); + if let Some(tasks) = self.cached(&backend, inventory_scope)? { + for task in tasks.iter() { + let position = ranks.position(task); + if after.as_ref().is_none_or(|after| position > *after) { + candidates.offer(Candidate { + position, + task: task.clone(), + }); + } + } + self.authorize(&backend, &user)?; + return candidates.finish(scope, after, limit); + } + let slots = self.owner()?.scan_slots.clone(); + let _permit = tokio::select! { + biased; + _ = self.pending.cancelled() => return Err(CANCELLED.into()), + permit = slots.acquire_owned() => permit.map_err(|_| CANCELLED.to_string())?, + }; + // A preceding scan may have completed while this reader waited. + if let Some(tasks) = self.cached(&backend, inventory_scope)? { + for task in tasks.iter() { + let position = ranks.position(task); + if after.as_ref().is_none_or(|after| position > *after) { + candidates.offer(Candidate { + position, + task: task.clone(), + }); + } + } + self.authorize(&backend, &user)?; + return candidates.finish(scope, after, limit); + } + let mut inventory = Some(HashMap::::new()); + let mut bytes = 0_usize; + let mut wire_cursor = None; + // Brent's cycle guard retains one cursor, rather than unbounded history. + let mut checkpoint = None; + let mut distance = 0_usize; + let mut power = 1_usize; + loop { + self.authorize(&backend, &user)?; + let page = tokio::select! { + biased; + _ = self.pending.cancelled() => return Err(CANCELLED.into()), + page = query.fetch(&backend, &user, wire_cursor.clone()) => page?, + }; + self.authorize(&backend, &user)?; + if page.tasks.is_empty() && page.next_cursor.is_some() { + return Err("Sidebar summary paging did not advance".into()); + } + for task in page.tasks { + if let Some(rows) = &mut inventory { + let charge = charged_bytes(&task)?; + if let Some((previous, previous_bytes)) = rows.get(&task.uuid) { + if previous.updated_ms <= task.updated_ms { + bytes = bytes.saturating_sub(*previous_bytes).saturating_add(charge); + rows.insert(task.uuid.clone(), (task.clone(), charge)); + } + } else { + bytes = bytes.saturating_add(charge); + rows.insert(task.uuid.clone(), (task.clone(), charge)); + } + if rows.len() > MAX_INVENTORY_ROWS || bytes > MAX_INVENTORY_BYTES { + inventory = None; + } + } + let position = ranks.position(&task); + if after.as_ref().is_none_or(|after| position > *after) { + candidates.offer(Candidate { position, task }); + } + } + let Some(next) = page.next_cursor else { + break; + }; + if next.len() > MAX_WIRE_CURSOR_BYTES + || wire_cursor.as_ref() == Some(&next) + || checkpoint.as_ref() == Some(&next) + { + return Err("Sidebar summary paging did not advance".into()); + } + distance = distance.saturating_add(1); + if distance == power { + checkpoint = Some(next.clone()); + power = power.saturating_mul(2); + distance = 0; + } + wire_cursor = Some(next); + tokio::task::yield_now().await; + } + self.authorize(&backend, &user)?; + if let Some(inventory) = inventory { + self.install( + &backend, + inventory_scope, + inventory.into_values().map(|(task, _)| task).collect(), + bytes, + ); + } + candidates.finish(scope, after, limit) + } +} 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 efef86f32..f52ab3747 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs @@ -152,6 +152,98 @@ enum ComputerPageReply { Tasks(NavigationTaskPage), } +async fn computer_task_page( + reader: catalog::Reader, + backend: Arc, + user: String, + query: catalog::TaskQuery, + manual: Option, +) -> Result { + if let Some(order) = manual { + return reader.page(backend, user, query, order).await; + } + match query { + catalog::TaskQuery::Tasks(query) => backend.list_tasks(&user, query).await, + catalog::TaskQuery::Search(query) => backend.search_tasks(&user, query).await, + catalog::TaskQuery::Pinned(query) => backend.pinned_tasks(&user, query).await, + } +} + +/// Project headers keep their host cursor. Task prefixes and their independent +/// section continuations are projected from the desktop's complete inventory. +async fn manual_computer_groups( + reader: catalog::Reader, + backend: Arc, + user: String, + mut page: NavigationGroupPage, + order: catalog::ManualOrder, +) -> Result { + let mut remaining = 200usize; + for group in &mut page.groups { + let budget = remaining.min(20) as u16; + let active_query = NavigationTaskQuery { + section: TaskSection::State(TaskState::Active), + project: Some(group.project.root.clone()), + pins: PinFilter::Ignore, + cursor: None, + limit: budget, + }; + let settled_query = NavigationTaskQuery { + section: TaskSection::State(TaskState::Settled), + ..active_query.clone() + }; + // At most two scans run concurrently for this execution computer. + let (active, settled) = tokio::try_join!( + reader.clone().page( + backend.clone(), + user.clone(), + catalog::TaskQuery::Tasks(active_query.clone()), + order.clone(), + ), + reader.clone().page( + backend.clone(), + user.clone(), + catalog::TaskQuery::Tasks(settled_query.clone()), + order.clone(), + ), + )?; + let mut candidates = active + .tasks + .iter() + .chain(settled.tasks.iter()) + .cloned() + .collect::>(); + order.sort(&mut candidates); + candidates.truncate(usize::from(budget)); + let settled_count = candidates + .iter() + .filter(|task| task.state == "settled") + .count(); + let active_count = candidates.len() - settled_count; + group.tasks = candidates; + remaining -= group.tasks.len(); + // Derive each continuation from this exact candidate snapshot. A + // section contributing no rows keeps a local start cursor when needed. + group.more.active = reader.prefix_cursor( + &backend, + &user, + &catalog::TaskQuery::Tasks(active_query), + &order, + &active, + active_count, + )?; + group.more.settled = reader.prefix_cursor( + &backend, + &user, + &catalog::TaskQuery::Tasks(settled_query), + &order, + &settled, + settled_count, + )?; + } + Ok(page) +} + /// The bounded visible projection travels with the desktop's browse state. /// Exact backend handles and the account fence it from a retired attachment. pub(super) struct ComputerBrowseState { @@ -420,6 +512,7 @@ impl Sidebar { // Pending callbacks belong to the previous Sidebar entity. Fence this // entity's earlier requests, and restart any inherited initial reads. self.group_pages.generation = self.group_pages.generation.wrapping_add(1); + let mut inventory_changed = false; state.pages.hosts.retain(|key, page| { let owners = state .computers @@ -427,6 +520,7 @@ impl Sidebar { .find(|computer| &computer.key == key) .zip(self.computers.iter().find(|computer| &computer.key == key)); let Some((old, new)) = owners.filter(|(old, new)| same_computer_owner(old, new)) else { + inventory_changed = true; return false; }; let refreshing = self @@ -440,10 +534,14 @@ impl Sidebar { != computer_revision(std::slice::from_ref(new)) || existing_snapshot_changed(std::slice::from_ref(old), std::slice::from_ref(new)) { + inventory_changed = true; page.invalidate(); } true }); + if inventory_changed { + self.manual_catalog.invalidate(); + } self.group_pages.hosts = state.pages.hosts; self.group_pages.scope = Some(scope); self.group_pages.more.clear(); @@ -488,6 +586,7 @@ impl Sidebar { } pub(super) fn revalidate_computer_pages(&mut self, cx: &mut Context) { + self.manual_catalog.invalidate(); self.group_pages.generation = self.group_pages.generation.wrapping_add(1); for page in self.group_pages.hosts.values_mut() { page.invalidate(); @@ -622,7 +721,8 @@ impl Sidebar { let Some(backend) = computer.backend.clone() else { return; }; - let manual = self.manual_task_order(&action.computer); + let manual = self.local_manual_order(&action.computer); + let reader = self.manual_catalog.reader(); let key = action.kind.key(); let page = self .group_pages @@ -676,68 +776,77 @@ impl Sidebar { ) .await .map(ComputerPageReply::Projects), - ComputerPageKind::Groups => backend - .list_project_groups( - &user, - NavigationGroupQuery { - limit_groups: 20, - per_project: 20, - cursor: request.cursor.clone(), - manual: manual.clone(), - }, - ) - .await - .map(ComputerPageReply::Groups), - ComputerPageKind::Search => backend - .search_tasks( - &user, - NavigationSearchQuery { - text: search_text, - project, - project_roots: matched_roots, - cursor: request.cursor.clone(), - limit: 100, - manual: manual.clone(), - }, - ) - .await - .map(ComputerPageReply::Tasks), - ComputerPageKind::Tasks { settled } - | ComputerPageKind::Project { settled, .. } => backend - .list_tasks( - &user, - NavigationTaskQuery { - section: TaskSection::State(if *settled { - TaskState::Settled - } else { - TaskState::Active - }), - project: match &request.kind { - ComputerPageKind::Project { root, .. } => Some(root.clone()), - _ => project, + ComputerPageKind::Groups => { + let groups = backend + .list_project_groups( + &user, + NavigationGroupQuery { + limit_groups: 20, + per_project: 20, + cursor: request.cursor.clone(), }, - pins: PinFilter::Ignore, - cursor: request.cursor.clone(), - limit: 50, - manual: manual.clone(), - }, - ) - .await - .map(ComputerPageReply::Tasks), - ComputerPageKind::Archived => backend - .list_tasks( - &user, - NavigationTaskQuery { - section: TaskSection::State(TaskState::Archived), - project, - pins: PinFilter::Ignore, - cursor: request.cursor.clone(), - limit: 50, - manual, + ) + .await?; + let groups = if let Some(order) = manual { + manual_computer_groups(reader, backend, user, groups, order).await? + } else { + groups + }; + Ok(ComputerPageReply::Groups(groups)) + } + ComputerPageKind::Search => computer_task_page( + reader, + backend, + user, + catalog::TaskQuery::Search(NavigationSearchQuery { + text: search_text, + project, + project_roots: matched_roots, + cursor: request.cursor.clone(), + limit: 100, + }), + manual, + ) + .await + .map(ComputerPageReply::Tasks), + ComputerPageKind::Tasks { settled } + | ComputerPageKind::Project { settled, .. } => computer_task_page( + reader, + backend, + user, + catalog::TaskQuery::Tasks(NavigationTaskQuery { + section: TaskSection::State(if *settled { + TaskState::Settled + } else { + TaskState::Active + }), + project: match &request.kind { + ComputerPageKind::Project { root, .. } => Some(root.clone()), + _ => project, }, - ) - .await - .map(ComputerPageReply::Tasks), + pins: PinFilter::Ignore, + cursor: request.cursor.clone(), + limit: 50, + }), + manual, + ) + .await + .map(ComputerPageReply::Tasks), + ComputerPageKind::Archived => computer_task_page( + reader, + backend, + user, + catalog::TaskQuery::Tasks(NavigationTaskQuery { + section: TaskSection::State(TaskState::Archived), + project, + pins: PinFilter::Ignore, + cursor: request.cursor.clone(), + limit: 50, + }), + manual, + ) + .await + .map(ComputerPageReply::Tasks), } }, cx, @@ -766,6 +875,10 @@ impl Sidebar { page.loading.remove(&key); let result = match result { Ok(reply) => reply, + Err(error) if error == catalog::CANCELLED => { + self.load_computer_page(action, cx); + return; + } Err(error) => { page.errors.insert(key, error); self.rebuild_entries(); diff --git a/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs b/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs index ec402dd0d..c63faffc4 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs @@ -125,21 +125,19 @@ impl Sidebar { } } - pub(super) fn manual_task_order( + pub(super) fn local_manual_order( &self, computer: &Option, - ) -> Option { - (self.view.sort == SidebarSort::Manual && self.order_loaded).then(|| { - maple_agent::store::NavigationManualOrder { - created_before_ms: self.order.created_before_ms, - tasks: self - .order - .tasks - .iter() - .filter(|task| &task.computer == computer) - .map(|task| task.id.clone()) - .collect(), - } + ) -> Option { + (self.view.sort == SidebarSort::Manual && self.order_loaded).then(|| catalog::ManualOrder { + cutoff: self.order.created_before_ms, + tasks: self + .order + .tasks + .iter() + .filter(|task| &task.computer == computer) + .map(|task| task.id.clone()) + .collect(), }) } 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 8455ae60e..b5d3f2afa 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_paging.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_paging.rs @@ -734,10 +734,11 @@ impl Sidebar { /// Also used after reconnect: deeper pages are discarded and visible first /// pages are re-authorized on the current host/account. - /// Ordering changes invalidate cursors, while retaining already visible - /// membership until the new ordered pages extend it. This keeps a drop in - /// a later loaded page visible and avoids discarding its scroll anchor. + /// Ordering changes replace desktop page positions while keeping the + /// existing task-summary API unchanged. Retain visible membership so a + /// drop in a later loaded page keeps its scroll anchor. pub(super) fn refresh_task_order(&mut self, cx: &mut Context) { + self.manual_catalog.cancel_pending(); self.order_query_dirty = false; if self.pages.enabled { self.pages.generation = self.pages.generation.wrapping_add(1); @@ -767,6 +768,8 @@ impl Sidebar { } fn refresh_pages_with_display(&mut self, preserve_display: bool, cx: &mut Context) { + self.manual_catalog.cancel_pending(); + self.manual_catalog.invalidate(); let retained = if preserve_display { self.group_pages.own_task_ids(self.backend.remote_host()) } else { @@ -830,12 +833,14 @@ impl Sidebar { } } if changed { + self.manual_catalog.invalidate(); self.rebuild_sections(); cx.notify(); } } pub(super) fn forget_page_tasks(&mut self, ids: &[String]) { + self.manual_catalog.invalidate(); self.pages.deleted.extend(ids.iter().cloned()); self.sessions.retain(|session| !ids.contains(&session.id)); for members in self.pages.ids.values_mut() { @@ -889,6 +894,7 @@ impl Sidebar { if !self.pages.enabled { return; } + self.manual_catalog.invalidate(); let id = summary.id.clone(); self.pages.live.insert(id.clone(), summary.clone()); let target = match summary.state { @@ -962,7 +968,9 @@ impl Sidebar { let user = self.user_id.clone(); let project = self.project_filter.clone(); let filter = self.filter.clone(); - let manual = self.manual_task_order(&self.backend.remote_host().map(str::to_owned)); + let manual = self + .local_manual_order(&self.backend.remote_host().map(str::to_owned)) + .map(|order| (self.manual_catalog.reader(), order)); let search = if kind == PageKind::Search { if self.pages.search.is_none() { let roots = self @@ -972,7 +980,6 @@ impl Sidebar { .map(|(root, _)| root.clone()) .collect(); self.pages.search = Some(NavigationSearchQuery { - manual: manual.clone(), text: filter, project: project.clone(), project_roots: roots, @@ -993,41 +1000,40 @@ impl Sidebar { }; self.call( async move { - if kind == PageKind::Search { - backend - .search_tasks(&user, search.ok_or("Search is unavailable")?) - .await + let query = if kind == PageKind::Search { + catalog::TaskQuery::Search(search.ok_or("Search is unavailable")?) } else if kind == PageKind::Pinned { - backend - .pinned_tasks( - &user, - crate::backend::PinnedTasksRequest { - manual: manual.clone(), - project, - cursor, - limit: 200, - }, - ) - .await + catalog::TaskQuery::Pinned(crate::backend::PinnedTasksRequest { + project, + cursor, + limit: 200, + }) } else { let section = TaskSection::State(match kind { PageKind::Archived => TaskState::Archived, PageKind::Settled => TaskState::Settled, _ => TaskState::Active, }); - backend - .list_tasks( - &user, - NavigationTaskQuery { - manual, - section, - project, - pins, - cursor, - limit: 50, - }, - ) - .await + catalog::TaskQuery::Tasks(NavigationTaskQuery { + section, + project, + pins, + cursor, + limit: 50, + }) + }; + if let Some((reader, order)) = manual { + reader.page(backend, user, query, order).await + } else { + match query { + catalog::TaskQuery::Search(query) => { + backend.search_tasks(&user, query).await + } + catalog::TaskQuery::Pinned(query) => { + backend.pinned_tasks(&user, query).await + } + catalog::TaskQuery::Tasks(query) => backend.list_tasks(&user, query).await, + } } }, cx, @@ -1050,6 +1056,13 @@ impl Sidebar { this.load_page(kind, cx); } } + Err(message) if message == catalog::CANCELLED => { + if !this.pages.next.contains_key(&kind) { + this.pages.started.remove(&kind); + } + this.load_page(kind, cx); + return; + } Err(message) => { if !append { this.pages.started.remove(&kind); diff --git a/apps/maple-agent/crates/maple-agent/src/acp/mod.rs b/apps/maple-agent/crates/maple-agent/src/acp/mod.rs index c1b08d379..06cf1ae46 100644 --- a/apps/maple-agent/crates/maple-agent/src/acp/mod.rs +++ b/apps/maple-agent/crates/maple-agent/src/acp/mod.rs @@ -890,7 +890,6 @@ impl AcpConnectionContext { let mut listed = Vec::new(); loop { let query = crate::store::NavigationTaskQuery { - manual: None, section: crate::store::TaskSection::Recent, project: project.clone(), pins: crate::store::PinFilter::Ignore, diff --git a/apps/maple-agent/crates/maple-agent/src/store/mod.rs b/apps/maple-agent/crates/maple-agent/src/store/mod.rs index 7e8f1e887..88101a738 100644 --- a/apps/maple-agent/crates/maple-agent/src/store/mod.rs +++ b/apps/maple-agent/crates/maple-agent/src/store/mod.rs @@ -58,11 +58,11 @@ pub use error::{DamageReason, RefusalReason, StoreError, StoreResult}; pub use gc::{CollectionProgress, CollectionStage, DeletedTask}; pub use paging::{ AttachmentRangeReply, HistoryAnchorRequest, HistoryPage, HistoryPageRequest, - NavigationGetTasksReply, NavigationGroupPage, NavigationGroupQuery, NavigationManualOrder, - NavigationProjectGroup, NavigationProjectPage, NavigationSearchQuery, NavigationTaskPage, - NavigationTaskQuery, OpenTaskPage, OpenTaskPageRequest, PageTransport, ProjectSummary, - ProjectTaskCursors, RowRange, RowRangeRequest, RowReader, TaskFactsReply, TaskHistoryStamp, - TaskSummary, TimelineRow, TransportCaps, + NavigationGetTasksReply, NavigationGroupPage, NavigationGroupQuery, NavigationProjectGroup, + NavigationProjectPage, NavigationSearchQuery, NavigationTaskPage, NavigationTaskQuery, + OpenTaskPage, OpenTaskPageRequest, PageTransport, ProjectSummary, ProjectTaskCursors, RowRange, + RowRangeRequest, RowReader, TaskFactsReply, TaskHistoryStamp, TaskSummary, TimelineRow, + TransportCaps, }; pub use paths::{FilesystemKind, StorePaths}; pub use persistence::{PersistenceFailure, PersistenceFailureReason, PersistenceStatus}; diff --git a/apps/maple-agent/crates/maple-agent/src/store/paging/navigation.rs b/apps/maple-agent/crates/maple-agent/src/store/paging/navigation.rs index 16a5767e3..0f8f35e81 100644 --- a/apps/maple-agent/crates/maple-agent/src/store/paging/navigation.rs +++ b/apps/maple-agent/crates/maple-agent/src/store/paging/navigation.rs @@ -14,7 +14,6 @@ use crate::store::{AccountId, PinFilter, Store, StoreError, StoreResult, TaskSec const MAX_ROWS: u16 = 200; const MAX_TEXT_BYTES: usize = 16 * 1024; -const MAX_MANUAL_TASKS: usize = 8192; // SQLite bounds even anomalously long model/title values before Rust allocates. const TASK_COLUMNS: &str = "id,uuid,project_id,substr(title,1,4096),state,kind,pinned_ms,web_enabled,\ substr(model,1,1024),message_count,created_ms,updated_ms,\ @@ -54,15 +53,6 @@ pub struct ProjectSummary { pub truncated: bool, } -/// A bounded owner-UUID order. Tasks created after the snapshot lead the saved -/// order; unranked older history follows it by creation time, never activity. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct NavigationManualOrder { - pub created_before_ms: i64, - pub tasks: Vec, -} - #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct NavigationTaskQuery { @@ -70,8 +60,6 @@ pub struct NavigationTaskQuery { pub project: Option, #[serde(default = "default_pin_filter")] pub pins: PinFilter, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub manual: Option, pub cursor: Option, #[serde(default = "default_task_limit")] pub limit: u16, @@ -92,8 +80,6 @@ pub struct NavigationSearchQuery { pub project: Option, #[serde(default)] pub project_roots: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub manual: Option, pub cursor: Option, #[serde(default = "default_search_limit")] pub limit: u16, @@ -121,8 +107,6 @@ pub struct NavigationGroupQuery { pub limit_groups: u16, #[serde(default = "default_group_limit")] pub per_project: u16, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub manual: Option, pub cursor: Option, } @@ -175,100 +159,6 @@ struct ProjectRow { summary: ProjectSummary, } -struct PreparedManualOrder { - cutoff: i64, - ranks: HashMap, - encoded_tasks: String, - fingerprint: Value, -} - -impl PreparedManualOrder { - fn new(order: &NavigationManualOrder) -> StoreResult { - if order.created_before_ms < 0 || order.tasks.len() > MAX_MANUAL_TASKS { - return Err(StoreError::InvalidInput); - } - let mut ranks = HashMap::with_capacity(order.tasks.len()); - let mut tasks = Vec::with_capacity(order.tasks.len()); - for (index, task) in order.tasks.iter().enumerate() { - validate_uuid(task)?; - let task = task.to_ascii_lowercase(); - if ranks.insert(task.clone(), index as i64).is_some() { - return Err(StoreError::InvalidInput); - } - tasks.push(task); - } - Ok(Self { - cutoff: order.created_before_ms, - ranks, - encoded_tasks: serde_json::to_string(&tasks).map_err(|_| StoreError::InvalidInput)?, - fingerprint: cursor_scope(order)?, - }) - } - - fn position(&self, row: &SummaryRow) -> TaskPosition { - let rank = self - .ranks - .get(&row.summary.uuid.to_ascii_lowercase()) - .copied(); - TaskPosition::Manual { - band: if rank.is_some() { - 1 - } else if row.summary.created_ms > self.cutoff { - 0 - } else { - 2 - }, - rank: rank.unwrap_or(0), - created: if rank.is_some() { - 0 - } else { - row.summary.created_ms - }, - uuid: row.summary.uuid.clone(), - } - } - - fn compare(&self, left: &SummaryRow, right: &SummaryRow) -> std::cmp::Ordering { - let TaskPosition::Manual { - band: left_band, - rank: left_rank, - created: left_created, - uuid: left_uuid, - } = self.position(left) - else { - unreachable!() - }; - let TaskPosition::Manual { - band: right_band, - rank: right_rank, - created: right_created, - uuid: right_uuid, - } = self.position(right) - else { - unreachable!() - }; - left_band - .cmp(&right_band) - .then_with(|| left_rank.cmp(&right_rank)) - .then_with(|| right_created.cmp(&left_created)) - .then_with(|| left_uuid.cmp(&right_uuid)) - } -} - -#[derive(Clone)] -enum TaskPosition { - Updated { - updated: i64, - key: i64, - }, - Manual { - band: i64, - rank: i64, - created: i64, - uuid: String, - }, -} - impl Store { pub fn list_task_summaries( &self, @@ -280,19 +170,13 @@ impl Store { if let Some(root) = &query.project { validate_root(root)?; } - let manual = query - .manual - .as_ref() - .map(PreparedManualOrder::new) - .transpose()?; let scope = cursor_scope(&task_scope( query.section, query.project.as_deref(), query.pins, - manual.as_ref(), ))?; let position = cursor_position(query.cursor.as_deref(), "tasks", account, &scope)?; - let position = read_task_position(position.as_ref(), manual.is_some())?; + let position = read_task_position(position.as_ref())?; self.read(|tx| { let project = resolve_project(tx, account, query.project.as_deref())?; if query.project.is_some() && project.is_none() { @@ -304,20 +188,11 @@ impl Store { query.section, project, query.pins, - manual.as_ref(), position, usize::from(query.limit) + 1, )?; populate_roots(tx, account, &mut rows)?; - finish_task_page( - account, - &scope, - "tasks", - rows, - query.limit, - transport, - manual.as_ref(), - ) + finish_task_page(account, &scope, "tasks", rows, query.limit, transport) }) } @@ -335,7 +210,6 @@ impl Store { section: TaskSection::Pinned, project: project.map(str::to_owned), pins: PinFilter::Ignore, - manual: None, cursor: cursor.map(str::to_owned), limit, }, @@ -419,63 +293,44 @@ impl Store { return Err(StoreError::InvalidInput); } let fold = query.text.to_lowercase(); - let manual = query - .manual - .as_ref() - .map(PreparedManualOrder::new) - .transpose()?; - let mut scope = json!({"text":fold,"project_roots":roots,"project":query.project}); - if let Some(manual) = &manual { - scope["manual"] = manual.fingerprint.clone(); - } - let scope = cursor_scope(&scope)?; + let scope = + cursor_scope(&json!({"text":fold,"project_roots":roots,"project":query.project}))?; let position = cursor_position(query.cursor.as_deref(), "search", account, &scope)?; - let position = read_task_position(position.as_ref(), manual.is_some())?; + let position = read_task_position(position.as_ref())?; let roots = serde_json::to_string(&roots).map_err(|_| StoreError::InvalidInput)?; self.read(|tx| { let project = resolve_project(tx, account, query.project.as_deref())?; if query.project.is_some() && project.is_none() { return Ok(NavigationTaskPage::default()); } - let (mut sql, mut values) = task_select("tasks_recent", account, manual.as_ref()); - sql.push_str( - " AND provisional=0 AND deleted_ms IS NULL AND message_count>0 + let mut sql = format!( + "SELECT {TASK_COLUMNS} FROM tasks INDEXED BY tasks_recent + WHERE account_id=? AND provisional=0 AND deleted_ms IS NULL AND message_count>0 AND (instr(title_fold,?)>0 OR project_id IN (SELECT id FROM projects WHERE account_id=? AND (root IN (SELECT value FROM json_each(?)) OR - instr(maple_project_label_fold(root,display_name),?)>0)))", + instr(maple_project_label_fold(root,display_name),?)>0)))" ); - values.extend([ + let mut values = vec![ + account.0.into(), fold.clone().into(), account.0.into(), roots.clone().into(), fold.clone().into(), - ]); + ]; if let Some(project) = project { sql.push_str(" AND project_id=?"); values.push(project.into()); } - append_task_order( - &mut sql, - &mut values, - position, - manual.is_some(), - usize::from(query.limit) + 1, - )?; + append_task_position(&mut sql, &mut values, position); + sql.push_str(" ORDER BY updated_ms DESC,id DESC LIMIT ?"); + values.push((i64::from(query.limit) + 1).into()); let mut statement = tx.prepare_cached(&sql)?; let mut rows = statement .query_map(params_from_iter(values), read_summary)? .collect::>>()?; populate_roots(tx, account, &mut rows)?; - finish_task_page( - account, - &scope, - "search", - rows, - query.limit, - transport, - manual.as_ref(), - ) + finish_task_page(account, &scope, "search", rows, query.limit, transport) }) } @@ -572,16 +427,7 @@ impl Store { ) -> StoreResult { validate_limit(query.limit_groups, 50)?; validate_limit(query.per_project, 50)?; - let manual = query - .manual - .as_ref() - .map(PreparedManualOrder::new) - .transpose()?; - let mut scope = json!({"view":"groups","per_project":query.per_project}); - if let Some(manual) = &manual { - scope["manual"] = manual.fingerprint.clone(); - } - let scope = cursor_scope(&scope)?; + let scope = cursor_scope(&json!({"view":"groups","per_project":query.per_project}))?; let position = cursor_position(query.cursor.as_deref(), "groups", account, &scope)?; let position = read_project_position(position.as_ref())?; self.read(|tx| { @@ -613,13 +459,11 @@ impl Store { TaskSection::State(TaskState::Active), Some(&project.summary.root), PinFilter::Ignore, - manual.as_ref(), ))?; let settled_scope = cursor_scope(&task_scope( TaskSection::State(TaskState::Settled), Some(&project.summary.root), PinFilter::Ignore, - manual.as_ref(), ))?; let budget = usize::from(query.per_project).min(usize::from(MAX_ROWS) - flat_count); // Read at most the remaining task budget plus one lookahead. @@ -629,7 +473,6 @@ impl Store { TaskSection::State(TaskState::Active), Some(project.key), PinFilter::Ignore, - manual.as_ref(), None, budget + 1, )?; @@ -639,7 +482,6 @@ impl Store { TaskSection::State(TaskState::Settled), Some(project.key), PinFilter::Ignore, - manual.as_ref(), None, budget + 1, )?; @@ -683,17 +525,11 @@ impl Store { } let mut active_taken = 0; let mut settled_taken = 0; - let mut tasks: Vec<_> = active + for (is_active, row) in active .into_iter() .map(|r| (true, r)) .chain(settled.into_iter().map(|r| (false, r))) - .collect(); - if let Some(manual) = &manual { - // Merge the independently paged sections by the same - // immutable order before applying the shared row budget. - tasks.sort_by(|(_, left), (_, right)| manual.compare(left, right)); - } - for (is_active, row) in tasks { + { if group.tasks.len() == budget { break; } @@ -703,24 +539,14 @@ impl Store { active_taken += 1; group.more.active = (active_taken < active_len) .then(|| { - make_cursor( - "tasks", - account, - &active_scope, - task_position(&row, manual.as_ref()), - ) + make_cursor("tasks", account, &active_scope, task_position(&row)) }) .transpose()?; } else { settled_taken += 1; group.more.settled = (settled_taken < settled_len) .then(|| { - make_cursor( - "tasks", - account, - &settled_scope, - task_position(&row, manual.as_ref()), - ) + make_cursor("tasks", account, &settled_scope, task_position(&row)) }) .transpose()?; } @@ -745,8 +571,7 @@ fn task_rows( section: TaskSection, project: Option, pins: PinFilter, - manual: Option<&PreparedManualOrder>, - position: Option, + position: Option<(i64, i64)>, limit: usize, ) -> StoreResult> { let index = match (section, project) { @@ -756,8 +581,11 @@ fn task_rows( (TaskSection::State(_), Some(_)) => "tasks_project_section", (TaskSection::State(_), None) => "tasks_section", }; - let (mut sql, mut values) = task_select(index, account, manual); - sql.push_str(" AND provisional=0 AND deleted_ms IS NULL"); + let mut sql = format!( + "SELECT {TASK_COLUMNS} FROM tasks INDEXED BY {index} + WHERE account_id=? AND provisional=0 AND deleted_ms IS NULL" + ); + let mut values = vec![rusqlite::types::Value::Integer(account.0)]; match section { TaskSection::State(state) => { sql.push_str(" AND state=? AND message_count>0"); @@ -775,94 +603,28 @@ fn task_rows( sql.push_str(" AND project_id=?"); values.push(project.into()); } - append_task_order(&mut sql, &mut values, position, manual.is_some(), limit)?; + append_task_position(&mut sql, &mut values, position); + sql.push_str(" ORDER BY updated_ms DESC,id DESC LIMIT ?"); + values.push( + i64::try_from(limit) + .map_err(|_| StoreError::InvalidInput)? + .into(), + ); let mut statement = tx.prepare_cached(&sql)?; Ok(statement .query_map(params_from_iter(values), read_summary)? .collect::>>()?) } -fn task_select( - index: &str, - account: AccountId, - manual: Option<&PreparedManualOrder>, -) -> (String, Vec) { - if let Some(manual) = manual { - // Materialize at most 8192 UUID ranks once; SQLite can index the - // materialized rank relation rather than scanning JSON per task. - return ( - format!( - "WITH manual_ranks AS MATERIALIZED ( - SELECT value AS rank_uuid,CAST(key AS INTEGER) AS rank_index FROM json_each(?) - ), manual_tasks AS ( - SELECT {TASK_COLUMNS}, - CASE WHEN rank_index IS NOT NULL THEN 1 WHEN created_ms>? THEN 0 ELSE 2 END AS manual_band, - coalesce(rank_index,0) AS manual_rank, - CASE WHEN rank_index IS NULL THEN created_ms ELSE 0 END AS manual_created - FROM tasks INDEXED BY {index} - LEFT JOIN manual_ranks ON manual_ranks.rank_uuid=lower(tasks.uuid) - WHERE account_id=?" - ), - vec![manual.encoded_tasks.clone().into(), manual.cutoff.into(), account.0.into()], - ); - } - ( - format!("SELECT {TASK_COLUMNS} FROM tasks INDEXED BY {index} WHERE account_id=?"), - vec![account.0.into()], - ) -} - -fn append_task_order( +fn append_task_position( sql: &mut String, values: &mut Vec, - position: Option, - manual: bool, - limit: usize, -) -> StoreResult<()> { - if manual { - sql.push_str(") SELECT * FROM manual_tasks WHERE 1=1"); - if let Some(position) = position { - let TaskPosition::Manual { - band, - rank, - created, - uuid, - } = position - else { - return Err(StoreError::InvalidInput); - }; - sql.push_str( - " AND ((manual_band,manual_rank)>(?,?) OR - ((manual_band,manual_rank)=(?,?) AND - (manual_created?))))", - ); - values.extend([ - band.into(), - rank.into(), - band.into(), - rank.into(), - created.into(), - created.into(), - uuid.into(), - ]); - } - sql.push_str(" ORDER BY manual_band,manual_rank,manual_created DESC,uuid ASC LIMIT ?"); - } else { - if let Some(position) = position { - let TaskPosition::Updated { updated, key } = position else { - return Err(StoreError::InvalidInput); - }; - sql.push_str(" AND (updated_ms,id)<(?,?)"); - values.extend([updated.into(), key.into()]); - } - sql.push_str(" ORDER BY updated_ms DESC,id DESC LIMIT ?"); + position: Option<(i64, i64)>, +) { + if let Some((updated, key)) = position { + sql.push_str(" AND (updated_ms,id)<(?,?)"); + values.extend([updated.into(), key.into()]); } - values.push( - i64::try_from(limit) - .map_err(|_| StoreError::InvalidInput)? - .into(), - ); - Ok(()) } fn finish_task_page( @@ -872,14 +634,13 @@ fn finish_task_page( rows: Vec, limit: u16, transport: PageTransport, - manual: Option<&PreparedManualOrder>, ) -> StoreResult { let mut page = NavigationTaskPage::default(); let count = rows.len(); for (index, row) in rows.iter().take(usize::from(limit)).enumerate() { page.tasks.push(row.summary.clone()); page.next_cursor = (index + 1 < count) - .then(|| make_cursor(kind, account, scope, task_position(row, manual))) + .then(|| make_cursor(kind, account, scope, task_position(row))) .transpose()?; if serialized_len(&page)? > reply_limit(transport) { page.tasks.pop(); @@ -890,7 +651,7 @@ fn finish_task_page( kind, account, scope, - task_position(&rows[index - 1], manual), + task_position(&rows[index - 1]), )?); return Ok(page); } @@ -1164,85 +925,33 @@ fn read_summary(row: &Row<'_>) -> rusqlite::Result { }) } -fn task_scope( - section: TaskSection, - project: Option<&str>, - pins: PinFilter, - manual: Option<&PreparedManualOrder>, -) -> Value { +fn task_scope(section: TaskSection, project: Option<&str>, pins: PinFilter) -> Value { let section = match section { TaskSection::State(state) => state.as_str(), TaskSection::Pinned => "pinned", TaskSection::Recent => "recent", }; - let mut scope = json!({"section":section,"project":project,"pins":if pins==PinFilter::Exclude {"exclude"} else {"ignore"}}); - if let Some(manual) = manual { - scope["manual"] = manual.fingerprint.clone(); - } - scope + json!({"section":section,"project":project,"pins":if pins==PinFilter::Exclude {"exclude"} else {"ignore"}}) } -fn task_position(row: &SummaryRow, manual: Option<&PreparedManualOrder>) -> Value { - if let Some(manual) = manual { - let TaskPosition::Manual { - band, - rank, - created, - uuid, - } = manual.position(row) - else { - unreachable!() - }; - return json!({"band":band,"rank":rank,"created_ms":created,"uuid":uuid}); - } +fn task_position(row: &SummaryRow) -> Value { json!({"updated_ms":row.summary.updated_ms,"id":row.key}) } -fn read_task_position(value: Option<&Value>, manual: bool) -> StoreResult> { +fn read_task_position(value: Option<&Value>) -> StoreResult> { match value { None | Some(Value::Null) => Ok(None), Some(value) => { - if manual { - let band = value - .get("band") - .and_then(Value::as_i64) - .filter(|band| (0..=2).contains(band)) - .ok_or(StoreError::InvalidInput)?; - let rank = value - .get("rank") - .and_then(Value::as_i64) - .filter(|rank| (0..MAX_MANUAL_TASKS as i64).contains(rank)) - .ok_or(StoreError::InvalidInput)?; - let created = value - .get("created_ms") - .and_then(Value::as_i64) - .ok_or(StoreError::InvalidInput)?; - if (band != 1 && rank != 0) || (band == 1 && created != 0) { - return Err(StoreError::InvalidInput); - } - let uuid = value - .get("uuid") - .and_then(Value::as_str) - .ok_or(StoreError::InvalidInput)?; - validate_uuid(uuid)?; - Ok(Some(TaskPosition::Manual { - band, - rank, - created, - uuid: uuid.to_owned(), - })) - } else { - let key = value - .get("id") - .and_then(Value::as_i64) - .filter(|n| *n > 0) - .ok_or(StoreError::InvalidInput)?; - let updated = value - .get("updated_ms") - .and_then(Value::as_i64) - .ok_or(StoreError::InvalidInput)?; - Ok(Some(TaskPosition::Updated { updated, key })) - } + let updated = value + .get("updated_ms") + .and_then(Value::as_i64) + .ok_or(StoreError::InvalidInput)?; + let key = value + .get("id") + .and_then(Value::as_i64) + .filter(|n| *n > 0) + .ok_or(StoreError::InvalidInput)?; + Ok(Some((updated, key))) } } } diff --git a/apps/maple-agent/crates/maple-agent/src/store/paging/navigation_tests.rs b/apps/maple-agent/crates/maple-agent/src/store/paging/navigation_tests.rs index f2d52891a..41d800371 100644 --- a/apps/maple-agent/crates/maple-agent/src/store/paging/navigation_tests.rs +++ b/apps/maple-agent/crates/maple-agent/src/store/paging/navigation_tests.rs @@ -52,7 +52,6 @@ fn task(store: &Store, account: AccountId, number: u64, project_root: &str, titl fn query(section: TaskSection, limit: u16) -> NavigationTaskQuery { NavigationTaskQuery { - manual: None, section, project: None, pins: PinFilter::Ignore, @@ -201,7 +200,6 @@ fn search_is_unicode_folded_and_cursor_binds_search_terms() { task(&store, alice, 2, &project, "äpfel two"); task(&store, alice, 3, &root("other"), "unrelated"); let mut request = NavigationSearchQuery { - manual: None, project: None, text: "Äpfel".to_owned(), project_roots: Vec::new(), @@ -219,7 +217,6 @@ fn search_is_unicode_folded_and_cursor_binds_search_terms() { assert_eq!(second.tasks.len(), 1); assert_ne!(first.tasks[0].uuid, second.tasks[0].uuid); let scoped = NavigationSearchQuery { - manual: None, text: "äpfel".to_owned(), project: Some(root("other")), project_roots: vec![project.clone()], @@ -264,7 +261,6 @@ fn group_flatten_cap_retains_empty_group_section_cursors() { .list_project_group_summaries( alice, &NavigationGroupQuery { - manual: None, limit_groups: 5, per_project: 50, cursor: None, @@ -287,7 +283,6 @@ fn group_flatten_cap_retains_empty_group_section_cursors() { .list_task_summaries( alice, &NavigationTaskQuery { - manual: None, section: TaskSection::State(TaskState::Active), project: Some(last.project.root.clone()), pins: PinFilter::Ignore, @@ -321,7 +316,6 @@ fn mixed_group_sections_keep_independent_continuations() { .list_project_group_summaries( alice, &NavigationGroupQuery { - manual: None, limit_groups: 1, per_project: 1, cursor: None, @@ -335,7 +329,6 @@ fn mixed_group_sections_keep_independent_continuations() { .list_task_summaries( alice, &NavigationTaskQuery { - manual: None, section: TaskSection::State(TaskState::Active), project: Some(project.clone()), pins: PinFilter::Ignore, @@ -349,7 +342,6 @@ fn mixed_group_sections_keep_independent_continuations() { .list_task_summaries( alice, &NavigationTaskQuery { - manual: None, section: TaskSection::State(TaskState::Settled), project: Some(project), pins: PinFilter::Ignore, @@ -384,7 +376,6 @@ fn remote_group_byte_cap_keeps_all_groups_and_unloaded_tasks_reachable() { } } let mut request = NavigationGroupQuery { - manual: None, limit_groups: 4, per_project: 50, cursor: None, @@ -413,7 +404,6 @@ fn remote_group_byte_cap_keeps_all_groups_and_unloaded_tasks_reachable() { .list_task_summaries( alice, &NavigationTaskQuery { - manual: None, section: TaskSection::State(TaskState::Active), project: Some(group.project.root.clone()), pins: PinFilter::Ignore, @@ -648,7 +638,6 @@ fn name_revisions_survive_reset_and_are_in_both_bounded_project_projections() { .list_project_group_summaries( alice, &NavigationGroupQuery { - manual: None, limit_groups: 1, per_project: 1, cursor: None, @@ -691,7 +680,6 @@ fn search_finds_unloaded_owner_project_names_with_unicode_and_bounded_cursors() let second_id = task(&store, alice, 100, &target, "Another unrelated title"); task(&store, bob, 200, &target, "Unrelated other account"); let mut request = NavigationSearchQuery { - manual: None, text: "äpfel".into(), project: None, project_roots: Vec::new(), @@ -740,7 +728,6 @@ fn search_resolves_owner_default_basename_and_workspace_label_without_client_hin let project = root("Ã…ngströmFolder"); let id = task(&store, alice, 1, &project, "Unrelated title"); let mut request = NavigationSearchQuery { - manual: None, text: "Ã¥ngströmfolder".into(), project: None, project_roots: Vec::new(), diff --git a/apps/maple-agent/crates/store-bench/src/run.rs b/apps/maple-agent/crates/store-bench/src/run.rs index 0c60a2e87..cbdb5487c 100644 --- a/apps/maple-agent/crates/store-bench/src/run.rs +++ b/apps/maple-agent/crates/store-bench/src/run.rs @@ -99,7 +99,6 @@ pub fn execute(mode: Mode, options: &Options) -> Result { store.bench_raw_connection_open() })?); let list = NavigationTaskQuery { - manual: None, section: TaskSection::State(TaskState::Active), project: None, pins: PinFilter::Ignore, @@ -122,13 +121,11 @@ pub fn execute(mode: Mode, options: &Options) -> Result { .map(|_| ()) })?); let groups = NavigationGroupQuery { - manual: None, limit_groups: 20, per_project: 10, cursor: None, }; let search = NavigationSearchQuery { - manual: None, text: "synthetic".into(), project: None, project_roots: Vec::new(), diff --git a/apps/maple-agent/docs/remote-access.md b/apps/maple-agent/docs/remote-access.md index f5bdd3f7c..7c4e31bf2 100644 --- a/apps/maple-agent/docs/remote-access.md +++ b/apps/maple-agent/docs/remote-access.md @@ -191,22 +191,22 @@ Maple explicitly clears public address lookup and builds Iroh without its port-mapping feature. The Minimal preset alone is not the port-mapping policy. There is no second encryption tunnel or custom routing layer. The remote protocol keeps the `maple-agent/remote/1` ALPN family and currently advertises -wire version 8 with minimum version 8, independent of application builds. Its +wire version 7 with minimum version 7, independent of application builds. Its shared project names, bounded navigation/history pages, stable row identities, attachment ranges, and per-connection task subscriptions remain unchanged. Project-change events carry only changed metadata; snapshots and project pages provide authoritative names, revisions, and default-workspace flags. -Version 7 and earlier peers receive an explicit version refusal. Bounded frames +Version 6 and earlier peers receive an explicit version refusal. Bounded frames and an explicit task-operation allowlist apply. -Manual sidebar ordering adds bounded task-order inputs to navigation, search, -project-group, and continuation queries. Both computers must be updated for this -wire-8 paging contract; older peers are refused during the handshake before -requests are admitted, rather than accepting a partially working sidebar. +The viewing desktop applies sidebar grouping and manual ordering to summaries +received through the existing navigation and project APIs. These preferences +stay on that desktop; no ordering parameters, commands, or capabilities are +added to the remote protocol. Account operations and arbitrary whole-host administration cannot be forwarded. The narrow experimental update capability uses the host's own signed updater; it does not expose the ordinary host-stop command. -Optional discovery reports protocol version `8.0.0` and explicit capabilities +Optional discovery reports protocol version `7.1.0` and explicit capabilities over separate request streams. The acknowledgement shape and ALPN remain readable across the required version transition. Bounded folder browsing and single-folder creation require the `project_folders_v1` capability. The controller checks support before sending @@ -226,9 +226,9 @@ separate from ordinary requests, range reads and urgent replies; generic files are limited to 30 MiB each, while composer images retain their 10 MiB limit. See [local storage](local-store.md). -Both computers must use wire 8 for remote access, including remote update +Both computers must use wire 7 for remote access, including remote update management. Upgrade the execution computer first while the old pair is still -compatible, then update the controller locally. A wire-8 controller stops +compatible, then update the controller locally. A wire-7 controller stops automatic reconnection after an explicit version refusal and shows **Update required**, without treating ordinary offline errors as version mismatches. Released wire-6 controllers keep their shipped behavior during the gap: cold diff --git a/apps/maple-agent/docs/updates.md b/apps/maple-agent/docs/updates.md index d5f9c7a83..fca8e9e4b 100644 --- a/apps/maple-agent/docs/updates.md +++ b/apps/maple-agent/docs/updates.md @@ -47,12 +47,13 @@ unlocked; an independent host-side check runs at the final shutdown boundary. The controller's updater is never installed or restarted by this operation. Daemon-only hosts require a local update. Remote management requires compatible remote protocol majors, just like ordinary task access. The attachment change -introduced wire 7; Manual sidebar paging now requires wire 8 on both computers. -There is no cross-version management bridge. Update the execution computer -first while both still use the same wire major, then update the controller -through its local updater. During the expected mismatch, retain the original -install operation rather than resubmitting it; verify the running identity and -its receipt after both computers reconnect on wire 8. +requires wire 7 on both computers; it does not add a cross-version management +bridge. Update the execution computer first while both still use wire 6, then +update the controller through its local updater. During the expected mismatch, +retain the original install operation rather than resubmitting it; verify the +running identity and its receipt after both computers reconnect on wire 7. +Sidebar grouping and manual ordering use those existing APIs and apply only on +the viewing desktop; they require no remote protocol upgrade or capability. Updating the controller first requires updating the execution computer directly. A remote mismatch does not disable the controller's local update controls. Definite install refusals are shown as declined requests and require a new check From adbcb13dd2aaf18893ba50243315ee2d1633d7fa Mon Sep 17 00:00:00 2001 From: marks Date: Tue, 27 Oct 2026 10:11:15 -0500 Subject: [PATCH 14/29] Agent: choose a computer before adding a sidebar project --- apps/maple-agent/app/src/ui/chat/sidebar.rs | 84 +++++++++++++++++++-- apps/maple-agent/docs/remote-access.md | 8 +- 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index 0b921ddb2..ab4e645cc 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -257,8 +257,10 @@ const FOLDER_ICON_SIZE: gpui::Pixels = px(16.); /// uses the project row's own popup (see `Sidebar::project_popup`). #[derive(Clone, Debug, PartialEq, Eq)] enum SidebarPopup { - /// The View options menu: grouping, order, what rows show. + /// The sidebar grouping and sorting menu. View, + /// Choose which computer owns a new project. + NewProject, /// A task row's overflow menu, by the row's shared id. Task(Arc), } @@ -3275,9 +3277,35 @@ impl Sidebar { self.popup .render(self.view_menu(window, cx), Placement::BelowEnd, window, cx) }); - let add = self.new_project_button().on_click( - cx.listener(|_this, _event, _window, cx| cx.emit(SidebarEvent::ChooseProject)), - ); + let choose_computer = self.uses_computer_projection(); + let add_open = choose_computer && self.popup.is_open(&SidebarPopup::NewProject); + let add_menu = add_open.then(|| { + self.popup.render( + self.new_project_computer_menu(), + Placement::BelowEnd, + window, + cx, + ) + }); + let add = if choose_computer { + div() + .relative() + .child(self.popup.trigger( + SidebarPopup::NewProject, + self.new_project_button(add_open), + cx, + |this, _, cx| { + this.project_popup.close(cx); + this.view_popup.close(cx); + this.popup.toggle(SidebarPopup::NewProject, cx); + }, + )) + .children(add_menu) + } else { + div().child(self.new_project_button(false).on_click( + cx.listener(|_this, _event, _window, cx| cx.emit(SidebarEvent::ChooseProject)), + )) + }; let expanded = self.projects_expanded; let label = match self.view.group { SidebarGroup::Project => "Projects", @@ -3418,9 +3446,9 @@ impl Sidebar { } /// Add project stays visible until there is a project to browse. - fn new_project_button(&self) -> gpui::Stateful
{ - header_button("new-project", "folder-plus", "New project", false).when( - !self.project_roots.is_empty(), + fn new_project_button(&self, open: bool) -> gpui::Stateful
{ + header_button("new-project", "folder-plus", "New project", open).when( + !open && !self.project_roots.is_empty(), |button| { button .opacity(0.) @@ -3429,6 +3457,48 @@ impl Sidebar { ) } + fn new_project_computer_menu(&self) -> Menu { + let mut menu = Menu::new("new-project-computer-menu", px(260.)) + .label("Create project on") + .max_height(px(360.)) + .application_vim(self.application_vim_enabled) + .item( + MenuItem::new("new-project-local", "This computer", |_, _, cx| { + cx.emit(SidebarEvent::Navigate(Navigate { + computer: None, + destination: Destination::NewProject, + })); + }) + .icon("monitor"), + ); + for computer in self + .computers + .iter() + .filter(|computer| computer.key.is_some()) + { + let request = Navigate { + computer: computer.key.clone(), + destination: Destination::NewProject, + }; + let mut item = MenuItem::new( + SharedString::from(format!( + "new-project-{}", + computer.key.as_deref().expect("remote computer") + )), + computer.label.clone(), + move |_, _, cx| cx.emit(SidebarEvent::Navigate(request.clone())), + ) + .icon("monitor"); + if computer.loading { + item = item.note("Connecting…"); + } else if computer.error.is_some() { + item = item.note("Offline"); + } + menu = menu.item(item); + } + menu + } + /// A folder group's header: the project's name, which folds its tasks, /// with its overflow menu and a new task in it on hover. fn render_local_project_header( diff --git a/apps/maple-agent/docs/remote-access.md b/apps/maple-agent/docs/remote-access.md index 7c4e31bf2..56ac1f925 100644 --- a/apps/maple-agent/docs/remote-access.md +++ b/apps/maple-agent/docs/remote-access.md @@ -77,8 +77,12 @@ every thirty seconds also discovers empty projects selected by another desktop. This is an in-memory navigation cache, not offline transcript replication. Search, sidebar position and each computer's composer remain intact when switching. -**New project…** opens the operating system's folder picker for this computer. -Remote computers use the built-in browser, starting at that computer's home. +With another computer added, the sidebar's **New project** button first asks +which computer should own the project, independently of the task being viewed. +Choose **This computer** for the operating system's local folder picker, or a +remote computer for its built-in folder browser. Without another computer added, +the button opens the local picker directly. The remote browser starts at that +computer's home. Browse folders, use the left arrow to go to the parent, enter an absolute path, or create one new folder, then choose **Use folder**. The built-in browser also serves as a fallback if the local platform picker is unavailable. From ba4105b9d704b3ef0a02d299678553f03e7c33c7 Mon Sep 17 00:00:00 2001 From: marks Date: Tue, 27 Oct 2026 10:35:18 -0500 Subject: [PATCH 15/29] Agent: label the new project computer chooser --- apps/maple-agent/app/src/ui/chat/sidebar.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index ab4e645cc..09fea74d7 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -3459,9 +3459,10 @@ impl Sidebar { fn new_project_computer_menu(&self) -> Menu { let mut menu = Menu::new("new-project-computer-menu", px(260.)) - .label("Create project on") + .label("New project on") .max_height(px(360.)) .application_vim(self.application_vim_enabled) + .header("New project on") .item( MenuItem::new("new-project-local", "This computer", |_, _, cx| { cx.emit(SidebarEvent::Navigate(Navigate { From c1f02e4314b5e78f7a025a56b53fd5b3b1bcebf0 Mon Sep 17 00:00:00 2001 From: marks Date: Tue, 27 Oct 2026 11:40:49 -0500 Subject: [PATCH 16/29] Agent: fix remote sidebar state and startup selection --- apps/maple-agent/app/src/desktop.rs | 37 ++- apps/maple-agent/app/src/ui/chat/computers.rs | 4 +- apps/maple-agent/app/src/ui/chat/mod.rs | 5 +- .../app/src/ui/chat/sidebar_computers.rs | 252 +++++++++++++++++- .../app/src/ui/chat/sidebar_ordering.rs | 7 +- 5 files changed, 286 insertions(+), 19 deletions(-) diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index 053cc8c32..6c25ed91f 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -119,6 +119,9 @@ struct MapleApp { local_chat: Option>, selected_host: Option, remote_computers: std::collections::BTreeMap, + /// An empty startup projection is provisional until this account's first + /// paired-computer status arrives. It must not replace saved grouping. + remote_inventory_loaded: bool, next_connection_attempt: u64, requested_navigation: Option, computers_sync_pending: bool, @@ -719,6 +722,7 @@ impl MapleApp { self.parked_chat = None; self.local_chat = None; self.remote_computers.clear(); + self.remote_inventory_loaded = false; self.requested_navigation = None; self.selected_host = None; self.user_id = None; @@ -1167,6 +1171,7 @@ impl MapleApp { self.parked_chat = None; self.local_chat = None; self.remote_computers.clear(); + self.remote_inventory_loaded = false; self.requested_navigation = None; self.selected_host = None; if matches!(self.screen, Screen::Login(_)) { @@ -1210,6 +1215,7 @@ impl MapleApp { self.screen = Screen::Chat(chat.clone()); self.local_chat = Some(chat.clone()); self.remote_computers.clear(); + self.remote_inventory_loaded = false; self.requested_navigation = None; self.selected_host = None; self.subscribe_chat(&chat, cx); @@ -1300,7 +1306,9 @@ impl MapleApp { (key, (label, peer.grant)) }) .collect::>(); - let mut changed = self.remote_computers.len() != computers.len(); + let mut changed = + !self.remote_inventory_loaded || self.remote_computers.len() != computers.len(); + self.remote_inventory_loaded = true; let mut selected_grant_changed = false; self.remote_computers .retain(|key, _| computers.contains_key(key)); @@ -1368,6 +1376,9 @@ impl MapleApp { } fn sync_computers(&mut self, cx: &mut Context) { + if !self.remote_inventory_loaded { + return; + } let Some(local) = &self.local_chat else { return; }; @@ -1716,8 +1727,10 @@ impl MapleApp { .detach(); cx.subscribe( &screen, - |app, _, event: &ui::remote::ComputersChanged, cx| { - app.update_computers(event.0.clone(), cx); + |app, emitter, event: &ui::remote::ComputersChanged, cx| { + if app.is_current_settings_screen(&emitter) { + app.update_computers(event.0.clone(), cx); + } }, ) .detach(); @@ -3317,6 +3330,7 @@ pub fn run() { local_chat: None, selected_host: None, remote_computers: Default::default(), + remote_inventory_loaded: false, next_connection_attempt: 0, requested_navigation: None, computers_sync_pending: false, @@ -3407,7 +3421,13 @@ pub fn run() { let update_bridge = remote_updates::Bridge::default(); cx.spawn(async move |cx| { loop { - let account = status_root.update(cx, |app, _| app.user_id.clone()); + let (account, epoch, owner) = status_root.update(cx, |app, _| { + ( + app.user_id.clone(), + app.store_load_epoch, + app.local_chat.as_ref().map(Entity::entity_id), + ) + }); let backend = status_backend.clone(); let expected_account = account.clone(); let bridge = update_bridge.clone(); @@ -3444,7 +3464,13 @@ pub fn run() { app.begin_remote_install(command, cx); } let Ok(status) = remote else { return }; - if app.user_id == account && account.is_some() { + if app.user_id == account + && account.is_some() + && app.store_load_epoch == epoch + && app.local_chat.as_ref().map(Entity::entity_id) == owner + && app.store_ready + && app.backend.store_state() == maple_agent::store::StoreState::Open + { if let Screen::Settings(settings) = &app.screen { settings.update(cx, |settings, cx| { settings.update_remote_status(status.clone(), cx) @@ -4053,6 +4079,7 @@ mod tests { ), ] .into(), + remote_inventory_loaded: true, next_connection_attempt: 0, requested_navigation: None, computers_sync_pending: false, diff --git a/apps/maple-agent/app/src/ui/chat/computers.rs b/apps/maple-agent/app/src/ui/chat/computers.rs index c0beeba16..7fc842f0b 100644 --- a/apps/maple-agent/app/src/ui/chat/computers.rs +++ b/apps/maple-agent/app/src/ui/chat/computers.rs @@ -174,10 +174,12 @@ impl ChatScreen { if !visible { self.project_folder_dialog = None; } - if changed && visible { + if changed && visible && !self.computers.is_empty() { // Only the mounted sidebar projects every computer. Background // views keep their own summaries and the shared catalog handle; // they do not rebuild all foreign rows on every update. + // An empty list is provisional startup state; an authoritative + // inventory always includes This computer. let computers = self.computers.clone(); self.sidebar .update(cx, |sidebar, cx| sidebar.set_computers(computers, cx)); diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 31e731111..e9b61c8ba 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -1591,7 +1591,10 @@ impl ChatScreen { ); this.runtime_error = None; // Runtime root is only the fallback used at startup. - if this.selected_session.is_none() + // A draft may have selected its folder before this + // start, including a catalog's first remote activation. + if !this.draft + && this.selected_session.is_none() && this.selection_generation == generation { this.set_project_context(status.project_root, cx); 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 f52ab3747..80e8a1f50 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs @@ -91,6 +91,7 @@ impl ComputerHostPage { fn cursor_is_current(&self, kind: &ComputerPageKind, cursor: &Option) -> bool { let expected = match kind { + ComputerPageKind::ArchiveCount => None, ComputerPageKind::Projects => self.next_projects.as_ref(), ComputerPageKind::Groups => self.next_groups.as_ref(), ComputerPageKind::Tasks { settled: false } => self.next_active.as_ref(), @@ -125,6 +126,7 @@ struct ComputerMore { #[derive(Clone)] enum ComputerPageKind { + ArchiveCount, Projects, Groups, Tasks { settled: bool }, @@ -136,6 +138,7 @@ enum ComputerPageKind { impl ComputerPageKind { fn key(&self) -> String { match self { + Self::ArchiveCount => "archive-count".into(), Self::Projects => "projects".into(), Self::Groups => "groups".into(), Self::Tasks { settled } => format!("tasks:{settled}"), @@ -147,6 +150,7 @@ impl ComputerPageKind { } enum ComputerPageReply { + ArchiveCount(u64), Projects(NavigationProjectPage), Groups(NavigationGroupPage), Tasks(NavigationTaskPage), @@ -267,6 +271,7 @@ fn computer_revision(computers: &[Computer]) -> u64 { computer.host_epoch.hash(&mut hash); computer.loading.hash(&mut hash); computer.error.hash(&mut hash); + computer.pinned.hash(&mut hash); // Loading an older page adds summaries to the snapshot. Only a new // head or an update to the head invalidates these paging cursors. let newest = computer.sessions.iter().map(|task| task.updated_ms).max(); @@ -445,6 +450,119 @@ pub(super) struct OtherTask { } impl Sidebar { + fn computer_task_pin(&self, computer: &Computer, task: &TaskSummary) -> Option { + if computer.key.is_none() { + return task.pinned_ms; + } + // Remote pins belong to the viewer, independently of pins in the + // execution computer's store. Prefer this view's immediate edits. + let pins = if computer.key.as_deref() == self.backend.remote_host() { + &self.pinned_tasks + } else { + &computer.pinned + }; + pins.iter() + .position(|id| id == &task.uuid) + .map(|rank| i64::MAX - i64::try_from(rank).unwrap_or(i64::MAX)) + } + + /// Saved pins are already hydrated by the owning sidebar's bounded GetTasks + /// batches. Keep those verified summaries visible beyond the first page. + fn computer_pinned_summaries(&self, computer: &Computer) -> Vec { + let own = computer.key.as_deref() == self.backend.remote_host(); + let pins = if own { + &self.pinned_tasks + } else { + &computer.pinned + }; + let ranks = pins + .iter() + .enumerate() + .map(|(rank, id)| (id.as_str(), rank)) + .collect::>(); + let mut tasks = HashMap::::new(); + for session in computer + .sessions + .iter() + .chain(self.sessions.iter().filter(|_| own)) + { + if self + .project_filter + .as_ref() + .is_some_and(|root| !own || root != &session.project_root) + || (own && self.removed_projects.contains(&session.project_root)) + { + continue; + } + let Some(rank) = ranks.get(session.id.as_str()) else { + continue; + }; + if tasks + .get(&session.id) + .is_some_and(|task| task.updated_ms >= session.updated_ms) + { + continue; + } + let project = computer + .projects + .iter() + .find(|project| project.path == session.project_root); + let task = TaskSummary { + uuid: session.id.clone(), + title: session.title.clone(), + project_root: session.project_root.clone(), + project_display_name: project.map(|project| project.label.to_string()), + state: match session.state { + AgentTaskState::Active => "active", + AgentTaskState::Settled => "settled", + AgentTaskState::Archived => "archived", + } + .into(), + kind: if session.acp { "acp" } else { "desktop" }.into(), + pinned_ms: Some(i64::MAX - i64::try_from(*rank).unwrap_or(i64::MAX)), + web_enabled: session.web_enabled, + model: session.model.clone(), + message_count: u64::try_from(session.message_count).unwrap_or(u64::MAX), + created_ms: session.created_ms, + updated_ms: session.updated_ms, + truncated: false, + }; + let task = self.current_computer_task(computer, &task); + if task.state != "archived" + && (task.message_count > 0 + || computer.running.contains(&task.uuid) + || (own && self.running.contains(&task.uuid))) + { + tasks.insert(task.uuid.clone(), task); + } + } + tasks + .into_values() + .filter(|task| self.computer_task_matches(computer, task)) + .collect() + } + + fn computer_task_matches(&self, computer: &Computer, task: &TaskSummary) -> bool { + self.filter.is_empty() + || task.title.to_lowercase().contains(&self.filter) + || computer.label.to_lowercase().contains(&self.filter) + || self + .computer_project_name( + computer, + &ProjectSummary { + root: task.project_root.clone(), + display_name: task.project_display_name.clone(), + position: None, + last_used_ms: task.updated_ms, + name_revision: 0, + is_default_workspace: false, + truncated: false, + }, + ) + .to_lowercase() + .contains(&self.filter) + } + pub(super) fn uses_computer_projection(&self) -> bool { self.computers.iter().any(|computer| computer.key.is_some()) } @@ -635,6 +753,23 @@ impl Sidebar { .map(|computer| computer.key.clone()) .collect::>(); for computer in hosts { + let page = self.group_pages.hosts.entry(computer.clone()).or_default(); + if !page.loaded.contains("archive-count") + && !page.loading.contains("archive-count") + && !page.errors.contains_key("archive-count") + { + self.load_computer_page( + ComputerMore { + computer: computer.clone(), + generation: self.group_pages.generation, + kind: ComputerPageKind::ArchiveCount, + cursor: None, + label: "Loading archived task count…".into(), + loading: false, + }, + cx, + ); + } let page = self.group_pages.hosts.entry(computer.clone()).or_default(); if !page.loaded.contains("projects") && !page.loading.contains("projects") @@ -766,6 +901,17 @@ impl Sidebar { self.call( async move { match &request.kind { + ComputerPageKind::ArchiveCount => backend + .list_project_groups( + &user, + NavigationGroupQuery { + limit_groups: 1, + per_project: 1, + cursor: None, + }, + ) + .await + .map(|page| ComputerPageReply::ArchiveCount(page.archived_count)), ComputerPageKind::Projects => backend .list_projects( &user, @@ -891,6 +1037,7 @@ impl Sidebar { let mut own_tasks = Vec::new(); let mut own_labels = Vec::new(); match result { + ComputerPageReply::ArchiveCount(count) => page.archived_count = count, ComputerPageReply::Projects(reply) => { if action.cursor.is_none() && !retain_head { page.projects.clear(); @@ -910,7 +1057,9 @@ impl Sidebar { if action.cursor.is_none() && !retain_head { page.groups.clear(); } - page.archived_count = reply.archived_count; + if !page.loaded.contains("archive-count") { + page.archived_count = reply.archived_count; + } page.next_groups = reply.next_cursor; for group in reply.groups { // Group replies are authoritative project metadata too. @@ -994,7 +1143,9 @@ impl Sidebar { merge_tasks(&mut group.tasks, reply.tasks); } } - ComputerPageKind::Groups | ComputerPageKind::Projects => {} + ComputerPageKind::ArchiveCount + | ComputerPageKind::Groups + | ComputerPageKind::Projects => {} } } } @@ -1069,6 +1220,7 @@ impl Sidebar { if computer.running.contains(&task.uuid) || (own && self.running.contains(&task.uuid)) { task.state = "active".into(); } + task.pinned_ms = self.computer_task_pin(computer, &task); task } @@ -1142,6 +1294,39 @@ impl Sidebar { .collect(); } } + for task in self.computer_pinned_summaries(computer) { + if let Some(group) = groups + .iter_mut() + .find(|group| group.project.root == task.project_root) + { + if !group + .tasks + .iter() + .any(|existing| existing.uuid == task.uuid) + { + group.tasks.push(task); + } + } else { + groups.push(NavigationProjectGroup { + project: page + .projects + .iter() + .find(|project| project.root == task.project_root) + .cloned() + .unwrap_or_else(|| ProjectSummary { + root: task.project_root.clone(), + display_name: task.project_display_name.clone(), + position: None, + last_used_ms: task.updated_ms, + name_revision: 0, + is_default_workspace: false, + truncated: false, + }), + tasks: vec![task], + more: Default::default(), + }); + } + } groups.retain(|group| { (!own || !self.removed_projects.contains(&group.project.root)) && self @@ -1156,8 +1341,14 @@ impl Sidebar { tasks.sort_by(|(left, a), (right, b)| { let left = &self.computers[*left].key; let right = &self.computers[*right].key; - self.task_order_key(left, &a.uuid, a.created_ms, a.updated_ms) - .cmp(&self.task_order_key(right, &b.uuid, b.created_ms, b.updated_ms)) + a.pinned_ms + .is_none() + .cmp(&b.pinned_ms.is_none()) + .then_with(|| b.pinned_ms.cmp(&a.pinned_ms)) + .then_with(|| { + self.task_order_key(left, &a.uuid, a.created_ms, a.updated_ms) + .cmp(&self.task_order_key(right, &b.uuid, b.created_ms, b.updated_ms)) + }) .then_with(|| left.cmp(right)) .then_with(|| a.uuid.cmp(&b.uuid)) }); @@ -1179,8 +1370,19 @@ impl Sidebar { return; } group.tasks.sort_by(|a, b| { - self.task_order_key(&computer.key, &a.uuid, a.created_ms, a.updated_ms) - .cmp(&self.task_order_key(&computer.key, &b.uuid, b.created_ms, b.updated_ms)) + a.pinned_ms + .is_none() + .cmp(&b.pinned_ms.is_none()) + .then_with(|| b.pinned_ms.cmp(&a.pinned_ms)) + .then_with(|| { + self.task_order_key(&computer.key, &a.uuid, a.created_ms, a.updated_ms) + .cmp(&self.task_order_key( + &computer.key, + &b.uuid, + b.created_ms, + b.updated_ms, + )) + }) .then_with(|| a.uuid.cmp(&b.uuid)) }); for task in &group.tasks { @@ -1293,10 +1495,15 @@ impl Sidebar { continue; } if flat { + let pins = self.computer_pinned_summaries(computer); let source = if searching { - page.search.iter().collect::>() + page.search.iter().chain(pins.iter()).collect::>() } else { - page.active.iter().chain(&page.settled).collect::>() + page.active + .iter() + .chain(&page.settled) + .chain(pins.iter()) + .collect::>() }; let mut seen = HashSet::new(); for summary in source { @@ -1414,8 +1621,31 @@ impl Sidebar { } } } - if self.computer_archived_count > 0 || !archived.is_empty() { + let count_failed = hosts.iter().any(|index| { + self.group_pages + .hosts + .get(&computers[*index].key) + .is_some_and(|page| page.errors.contains_key("archive-count")) + }); + if self.computer_archived_count > 0 || !archived.is_empty() || count_failed { entries.push(SidebarEntry::ArchivedHeader); + for &index in &hosts { + let computer = &computers[index]; + if self + .group_pages + .hosts + .get(&computer.key) + .is_some_and(|page| page.errors.contains_key("archive-count")) + { + self.computer_more_entry( + &mut entries, + &computer.key, + ComputerPageKind::ArchiveCount, + None, + &format!("archived task count from {}", computer.label), + ); + } + } if self.archived_expanded { for (index, task) in archived { self.push_computer_task(&mut entries, index, &computers[index], &task); @@ -1537,7 +1767,7 @@ impl Sidebar { } entries.push(SidebarEntry::Task(SidebarTaskEntry { session: index, - pinned: task.pinned_ms.is_some(), + pinned: self.computer_task_pin(computer, task).is_some(), settled: state == AgentTaskState::Settled && !self.running.contains(&task.uuid), archived: state == AgentTaskState::Archived, gap_after: false, @@ -1592,7 +1822,7 @@ impl Sidebar { title: latest .map_or_else(|| task.title.clone(), |session| session.title.clone()) .into(), - pinned: task.pinned_ms.is_some(), + pinned: self.computer_task_pin(computer, task).is_some(), activity: if computer.running.contains(&task.uuid) { Some(SessionActivity::Running) } else if computer.unread.contains(&task.uuid) { diff --git a/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs b/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs index c63faffc4..5fc77abaa 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs @@ -226,7 +226,12 @@ impl Sidebar { pub(super) fn set_view(&mut self, mut view: SidebarView, cx: &mut Context) { self.sync_cached_order(); - if view.group == SidebarGroup::Connection && !self.connection_grouping_available() { + // Changing task order must preserve a saved Connection grouping while + // the desktop is still loading its paired-computer inventory. + if view.group != self.view.group + && view.group == SidebarGroup::Connection + && !self.connection_grouping_available() + { view.group = SidebarGroup::Project; } if self.view == view { From 3d1fd80daddb9cc91b44a2105b8cc71ee338ee21 Mon Sep 17 00:00:00 2001 From: marks Date: Tue, 27 Oct 2026 12:15:46 -0500 Subject: [PATCH 17/29] Agent: unify drop cues and add per-computer project actions --- apps/maple-agent/app/src/desktop.rs | 2 +- apps/maple-agent/app/src/ui/chat/mod.rs | 2 +- apps/maple-agent/app/src/ui/chat/sidebar.rs | 2 +- .../app/src/ui/chat/sidebar_computers.rs | 49 ++++++++++++++++++- .../app/src/ui/chat/sidebar_ordering.rs | 9 +++- apps/maple-agent/docs/remote-access.md | 7 ++- 6 files changed, 64 insertions(+), 7 deletions(-) diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index 6c25ed91f..26904658b 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -1382,7 +1382,7 @@ impl MapleApp { let Some(local) = &self.local_chat else { return; }; - let mut computers = vec![local.read(cx).computer_snapshot("This computer".into(), cx)]; + let mut computers = vec![local.read(cx).computer_snapshot("This Computer".into(), cx)]; for (key, computer) in &self.remote_computers { computers.push(match &computer.chat { Some(chat) => chat diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index e9b61c8ba..713fed0e9 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -1965,7 +1965,7 @@ impl ChatScreen { if self.backend.remote_host().is_some() { "Remote computer".into() } else { - "This computer".into() + "This Computer".into() } }); let dialog = cx.new(|cx| { diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index 09fea74d7..eb1a4bb95 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -3464,7 +3464,7 @@ impl Sidebar { .application_vim(self.application_vim_enabled) .header("New project on") .item( - MenuItem::new("new-project-local", "This computer", |_, _, cx| { + MenuItem::new("new-project-local", "This Computer", |_, _, cx| { cx.emit(SidebarEvent::Navigate(Navigate { computer: None, destination: Destination::NewProject, 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 80e8a1f50..a100a9915 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs @@ -2114,8 +2114,17 @@ impl Sidebar { computer: computer.key.clone(), destination: Destination::Overview, }; + let action_group: SharedString = + format!("computer-header-actions-{:?}", computer.key).into(); + let new_project = Navigate { + computer: computer.key.clone(), + destination: Destination::NewProject, + }; + let new_project_label: SharedString = + format!("New project on {}", computer.label).into(); div() .id(SharedString::from(format!("computer-{:?}", computer.key))) + .group(action_group.clone()) .role(gpui::Role::Button) .aria_label(computer.label.clone()) .when(collapsible, |row| row.aria_expanded(expanded)) @@ -2144,6 +2153,7 @@ impl Sidebar { })) .child( div() + .w_full() .flex() .items_center() .gap_2() @@ -2159,7 +2169,44 @@ impl Sidebar { )) }) .child(icon("monitor", px(14.), theme::text_muted())) - .child(computer.label.clone()), + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .child(computer.label.clone()), + ) + .when( + collapsible && !dragging && !self.sidebar_menu_open(), + |row| { + row.child( + div() + .flex_none() + .on_mouse_down(gpui::MouseButton::Left, |_, _, cx| { + cx.stop_propagation(); + }) + .child( + header_button( + "connection-new-project", + "folder-plus", + "New project", + false, + ) + .aria_label(new_project_label) + .opacity(0.) + .group_hover(action_group, |style| { + style.opacity(1.) + }) + .on_click(cx.listener(move |_, _, _, cx| { + cx.stop_propagation(); + cx.emit(SidebarEvent::Navigate( + new_project.clone(), + )); + })), + ), + ) + }, + ), ) .when_some(status, |row, status| row.child(status)) .into_any_element() diff --git a/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs b/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs index 5fc77abaa..13eef6641 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs @@ -504,7 +504,7 @@ impl Sidebar { return None; } let target = self.reorder_rows.get(row)?.target(drag, self.view.group)?; - let (first, last) = *self.drag_ranges.get(&target)?; + let (first, _) = *self.drag_ranges.get(&target)?; let after = if row == first { position.y >= bounds.center().y } else { @@ -516,6 +516,13 @@ impl Sidebar { if to == from || to == from + 1 { return None; } + // The trailing edge of one item and leading edge of the next are + // the same slot. Give that slot one target and one line position. + let (target, after) = match sequence.get(to) { + Some(next) => (next.clone(), false), + None => (sequence.last()?.clone(), true), + }; + let (first, last) = *self.drag_ranges.get(&target)?; Some(SidebarDrop { target, after, diff --git a/apps/maple-agent/docs/remote-access.md b/apps/maple-agent/docs/remote-access.md index 56ac1f925..1400c260d 100644 --- a/apps/maple-agent/docs/remote-access.md +++ b/apps/maple-agent/docs/remote-access.md @@ -29,7 +29,7 @@ The sidebar's **View options** menu groups tasks by **Project**, **Connection**, computer is added, including while it is offline; enabling remote access on this computer alone does not add that choice. Project and Connection views keep known empty projects visible. A globe marks remote projects. Selecting a task or -project also selects its execution computer; select a **This computer** project +project also selects its execution computer; select a **This Computer** project to return to local work. **Sort tasks by** offers **Last updated** and **Manual order**, independently of @@ -79,10 +79,13 @@ sidebar position and each computer's composer remain intact when switching. With another computer added, the sidebar's **New project** button first asks which computer should own the project, independently of the task being viewed. -Choose **This computer** for the operating system's local folder picker, or a +Choose **This Computer** for the operating system's local folder picker, or a remote computer for its built-in folder browser. Without another computer added, the button opens the local picker directly. The remote browser starts at that computer's home. + +In Connection view, hover a computer heading and choose its **New project** +folder-plus button to open that computer's folder selector directly. Browse folders, use the left arrow to go to the parent, enter an absolute path, or create one new folder, then choose **Use folder**. The built-in browser also serves as a fallback if the local platform picker is unavailable. From b4f63da1fd4c051e748bb46912e8a100b2c5c953 Mon Sep 17 00:00:00 2001 From: marks Date: Tue, 27 Oct 2026 12:36:46 -0500 Subject: [PATCH 18/29] Agent: align connection names with project labels --- .../app/src/ui/chat/sidebar_computers.rs | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) 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 a100a9915..4839105e9 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs @@ -2131,7 +2131,7 @@ impl Sidebar { .mx_2() .my(px(1.)) .mt_3() - .px_2() + .px(FOLDER_HEADER_PADDING - gpui::rems(0.5)) .py_2() .rounded(ROW_RADIUS) .flex() @@ -2156,19 +2156,28 @@ impl Sidebar { .w_full() .flex() .items_center() - .gap_2() - .when(collapsible, |row| { - row.child(icon( - if expanded { - "chevron-down" - } else { - "chevron-right" - }, - px(12.), - theme::text_muted(), - )) - }) - .child(icon("monitor", px(14.), theme::text_muted())) + .gap(FOLDER_HEADER_GAP) + .child( + div() + .flex_none() + .w(FOLDER_ICON_SIZE) + .flex() + .items_center() + .justify_center() + .child(icon( + if collapsible { + if expanded { + "chevron-down" + } else { + "chevron-right" + } + } else { + "monitor" + }, + if collapsible { px(12.) } else { px(14.) }, + theme::text_muted(), + )), + ) .child( div() .flex_1() From dbc8cefebea7ef161c8dba98be69593ec107b998 Mon Sep 17 00:00:00 2001 From: marks Date: Tue, 27 Oct 2026 12:43:31 -0500 Subject: [PATCH 19/29] Agent: render computer icons beside aligned connection names --- apps/maple-agent/app/assets/icons/monitor.svg | 1 + apps/maple-agent/app/src/assets.rs | 1 + .../app/src/ui/chat/sidebar_computers.rs | 38 ++++++++++++------- 3 files changed, 27 insertions(+), 13 deletions(-) create mode 100644 apps/maple-agent/app/assets/icons/monitor.svg diff --git a/apps/maple-agent/app/assets/icons/monitor.svg b/apps/maple-agent/app/assets/icons/monitor.svg new file mode 100644 index 000000000..1b75e161f --- /dev/null +++ b/apps/maple-agent/app/assets/icons/monitor.svg @@ -0,0 +1 @@ + diff --git a/apps/maple-agent/app/src/assets.rs b/apps/maple-agent/app/src/assets.rs index c8595dcca..e9ce6ed25 100644 --- a/apps/maple-agent/app/src/assets.rs +++ b/apps/maple-agent/app/src/assets.rs @@ -42,6 +42,7 @@ assets!( "icons/maple-wordmark.svg", "icons/maximize-2.svg", "icons/minimize-2.svg", + "icons/monitor.svg", // OpenAI mark (simple-icons, CC0) for the Codex integration card. "icons/openai-mark.svg", "icons/paperclip.svg", 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 4839105e9..d8465308f 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs @@ -2160,23 +2160,35 @@ impl Sidebar { .child( div() .flex_none() + .relative() .w(FOLDER_ICON_SIZE) + .h(FOLDER_ICON_SIZE) .flex() .items_center() .justify_center() - .child(icon( - if collapsible { - if expanded { - "chevron-down" - } else { - "chevron-right" - } - } else { - "monitor" - }, - if collapsible { px(12.) } else { px(14.) }, - theme::text_muted(), - )), + .child(icon("monitor", FOLDER_ICON_SIZE, theme::text_muted())) + .when(collapsible, |slot| { + slot.child( + div() + .absolute() + .top_0() + .right(FOLDER_ICON_SIZE) + .w(FOLDER_HEADER_PADDING - gpui::rems(0.5)) + .h(FOLDER_ICON_SIZE) + .flex() + .items_center() + .justify_center() + .child(icon( + if expanded { + "chevron-down" + } else { + "chevron-right" + }, + px(12.), + theme::text_muted(), + )), + ) + }), ) .child( div() From a473dea65f418b15d6c3eaa36b13c1dac46d3797 Mon Sep 17 00:00:00 2001 From: marks Date: Tue, 27 Oct 2026 13:19:55 -0500 Subject: [PATCH 20/29] Agent: center connection chevrons in the left gutter --- apps/maple-agent/app/src/ui/chat/sidebar_computers.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 d8465308f..a2b74f934 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs @@ -2114,6 +2114,7 @@ impl Sidebar { computer: computer.key.clone(), destination: Destination::Overview, }; + let disclosure_request = request.clone(); let action_group: SharedString = format!("computer-header-actions-{:?}", computer.key).into(); let new_project = Navigate { @@ -2170,14 +2171,22 @@ impl Sidebar { .when(collapsible, |slot| { slot.child( div() + .id("computer-disclosure") .absolute() .top_0() - .right(FOLDER_ICON_SIZE) + .right(FOLDER_ICON_SIZE + px(3.)) .w(FOLDER_HEADER_PADDING - gpui::rems(0.5)) .h(FOLDER_ICON_SIZE) .flex() .items_center() .justify_center() + .on_click(cx.listener(move |this, _, _, cx| { + cx.stop_propagation(); + this.toggle_computer_group( + &disclosure_request, + cx, + ); + })) .child(icon( if expanded { "chevron-down" From d02f623b875679a4134eab21cf60732e5ffa284a Mon Sep 17 00:00:00 2001 From: marks Date: Tue, 27 Oct 2026 13:33:50 -0500 Subject: [PATCH 21/29] Agent: open sidebar View menu toward the right --- apps/maple-agent/app/src/ui/chat/sidebar.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index eb1a4bb95..be0ab9fa0 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -3274,8 +3274,12 @@ impl Sidebar { fn render_projects_header(&self, window: &mut Window, cx: &mut Context) -> AnyElement { let open = self.popup.is_open(&SidebarPopup::View); let menu = open.then(|| { - self.popup - .render(self.view_menu(window, cx), Placement::BelowEnd, window, cx) + self.popup.render( + self.view_menu(window, cx), + Placement::BelowStart, + window, + cx, + ) }); let choose_computer = self.uses_computer_projection(); let add_open = choose_computer && self.popup.is_open(&SidebarPopup::NewProject); From 3de538cf3f20e9c3a41f1c7744b1dbb6beb232ab Mon Sep 17 00:00:00 2001 From: marks Date: Tue, 27 Oct 2026 13:51:34 -0500 Subject: [PATCH 22/29] Agent: remove superseded sidebar header and ordering code --- apps/maple-agent/app/src/ui/chat/sidebar.rs | 14 +- .../app/src/ui/chat/sidebar_catalog.rs | 23 ++- .../app/src/ui/chat/sidebar_computers.rs | 167 +++++------------- .../app/src/ui/chat/sidebar_ordering.rs | 21 +-- apps/maple-agent/docs/remote-access.md | 7 +- 5 files changed, 80 insertions(+), 152 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index be0ab9fa0..6bb11f4ff 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -19,7 +19,7 @@ mod ordering; #[path = "sidebar_paging.rs"] mod paging; use super::computers::{Computer, Destination, Navigate, ProjectRenameRequest, ProjectTarget}; -use computers::{ComputerPages, OtherTask, ProjectHeading}; +use computers::{ComputerPages, OtherTask}; use paging::{PageKind, SidebarPages}; pub(super) fn project_from_summary( @@ -527,7 +527,7 @@ pub(super) struct Sidebar { pages: SidebarPages, group_pages: ComputerPages, computers: Arc>, - computer_projects: Vec, + computer_projects: Vec, other_tasks: Vec, computer_archived_count: usize, backend: Arc, @@ -2148,7 +2148,12 @@ impl Sidebar { .find(|session| &session.id == id) .map(|session| session.title.clone()) .unwrap_or_default(), - RenameTarget::Project(target) => self.project_name(target), + RenameTarget::Project(target) => self + .computer_projects + .iter() + .find(|row| &row.target == target) + .map(|row| row.name.to_string()) + .unwrap_or_else(|| self.project_name(target)), }; let chat = self.chat.clone(); let sidebar = cx.entity().downgrade(); @@ -3713,8 +3718,7 @@ 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 overflow button and its grouped task actions. fn task_menu_trigger( &self, row: &SidebarRow, diff --git a/apps/maple-agent/app/src/ui/chat/sidebar_catalog.rs b/apps/maple-agent/app/src/ui/chat/sidebar_catalog.rs index 044695af8..c46839c31 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_catalog.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_catalog.rs @@ -27,6 +27,17 @@ const MAX_INVENTORY_BYTES: usize = 8 * 1024 * 1024; const MAX_CACHE_BYTES: usize = 16 * 1024 * 1024; const MAX_CACHE_SCOPES: usize = 8; +/// The paged catalog and loaded sidebar rows use the same manual order bands. +pub(super) fn manual_order_key(rank: Option, created: i64, cutoff: i64) -> (u8, i64) { + if let Some(rank) = rank { + (1, rank as i64) + } else if created > cutoff { + (0, created.saturating_neg()) + } else { + (2, created.saturating_neg()) + } +} + #[derive(Clone, Serialize)] pub(super) struct ManualOrder { pub(super) cutoff: i64, @@ -234,13 +245,11 @@ impl<'a> Ranks<'a> { } fn position(&self, task: &TaskSummary) -> Position { - let (band, value) = if let Some(rank) = self.tasks.get(task.uuid.as_str()) { - (1, *rank as i64) - } else if task.created_ms > self.cutoff { - (0, task.created_ms.saturating_neg()) - } else { - (2, task.created_ms.saturating_neg()) - }; + let (band, value) = manual_order_key( + self.tasks.get(task.uuid.as_str()).copied(), + task.created_ms, + self.cutoff, + ); Position { band, value, 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 a2b74f934..8afe35c18 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs @@ -430,14 +430,6 @@ fn snapshot_page(computer: &Computer, query: &str, archived_expanded: bool) -> C page } -pub(super) struct ProjectHeading { - pub(super) request: Navigate, - pub(super) target: ProjectTarget, - pub(super) name: SharedString, - id: SharedString, - label: SharedString, - remote: bool, -} pub(super) struct OtherTask { pub(super) request: Navigate, pub(super) archived: bool, @@ -1337,27 +1329,39 @@ impl Sidebar { groups } + fn compare_computer_tasks( + &self, + left: &Option, + a: &TaskSummary, + right: &Option, + b: &TaskSummary, + ) -> std::cmp::Ordering { + a.pinned_ms + .is_none() + .cmp(&b.pinned_ms.is_none()) + .then_with(|| b.pinned_ms.cmp(&a.pinned_ms)) + .then_with(|| { + self.task_order_key(left, &a.uuid, a.created_ms, a.updated_ms) + .cmp(&self.task_order_key(right, &b.uuid, b.created_ms, b.updated_ms)) + }) + .then_with(|| left.cmp(right)) + .then_with(|| a.uuid.cmp(&b.uuid)) + } + fn sort_computer_tasks(&self, tasks: &mut [(usize, TaskSummary)]) { tasks.sort_by(|(left, a), (right, b)| { - let left = &self.computers[*left].key; - let right = &self.computers[*right].key; - a.pinned_ms - .is_none() - .cmp(&b.pinned_ms.is_none()) - .then_with(|| b.pinned_ms.cmp(&a.pinned_ms)) - .then_with(|| { - self.task_order_key(left, &a.uuid, a.created_ms, a.updated_ms) - .cmp(&self.task_order_key(right, &b.uuid, b.created_ms, b.updated_ms)) - }) - .then_with(|| left.cmp(right)) - .then_with(|| a.uuid.cmp(&b.uuid)) + self.compare_computer_tasks( + &self.computers[*left].key, + a, + &self.computers[*right].key, + b, + ) }); } fn push_computer_group( &mut self, entries: &mut Vec, - computer_index: usize, computer: &Computer, mut group: NavigationProjectGroup, ) { @@ -1365,28 +1369,15 @@ impl Sidebar { computer: computer.key.clone(), path: group.project.root.clone(), }; - self.push_computer_heading(entries, computer_index, computer, &group.project); + self.push_computer_heading(entries, computer, &group.project); if self.collapsed_projects.contains(&target) { return; } - group.tasks.sort_by(|a, b| { - a.pinned_ms - .is_none() - .cmp(&b.pinned_ms.is_none()) - .then_with(|| b.pinned_ms.cmp(&a.pinned_ms)) - .then_with(|| { - self.task_order_key(&computer.key, &a.uuid, a.created_ms, a.updated_ms) - .cmp(&self.task_order_key( - &computer.key, - &b.uuid, - b.created_ms, - b.updated_ms, - )) - }) - .then_with(|| a.uuid.cmp(&b.uuid)) - }); + group + .tasks + .sort_by(|a, b| self.compare_computer_tasks(&computer.key, a, &computer.key, b)); for task in &group.tasks { - self.push_computer_task(entries, computer_index, computer, task); + self.push_computer_task(entries, computer, task); } if self.filter.is_empty() { for (settled, cursor) in [(false, group.more.active), (true, group.more.settled)] { @@ -1551,7 +1542,7 @@ impl Sidebar { match self.view.group { SidebarGroup::Project => { for (index, group) in groups { - self.push_computer_group(&mut entries, index, &computers[index], group); + self.push_computer_group(&mut entries, &computers[index], group); } for &index in &hosts { self.push_computer_flat_more(&mut entries, index, None); @@ -1567,7 +1558,6 @@ impl Sidebar { for (owner, group) in groups.iter().filter(|(owner, _)| *owner == index) { self.push_computer_group( &mut entries, - *owner, &computers[*owner], group.clone(), ); @@ -1590,7 +1580,7 @@ impl Sidebar { continue; } for (index, task) in visible { - self.push_computer_task(&mut entries, *index, &computers[*index], task); + self.push_computer_task(&mut entries, &computers[*index], task); } if !searching { for &index in &hosts { @@ -1607,7 +1597,7 @@ impl Sidebar { } SidebarGroup::List => { for (index, task) in &tasks { - self.push_computer_task(&mut entries, *index, &computers[*index], task); + self.push_computer_task(&mut entries, &computers[*index], task); } for &index in &hosts { if searching { @@ -1648,7 +1638,7 @@ impl Sidebar { } if self.archived_expanded { for (index, task) in archived { - self.push_computer_task(&mut entries, index, &computers[index], &task); + self.push_computer_task(&mut entries, &computers[index], &task); } if !searching { for &index in &hosts { @@ -1692,7 +1682,6 @@ impl Sidebar { fn push_computer_heading( &mut self, entries: &mut Vec, - _computer_index: usize, computer: &Computer, project: &ProjectSummary, ) { @@ -1703,21 +1692,8 @@ impl Sidebar { computer: computer.key.clone(), path: root.clone(), }; - self.computer_projects.push(ProjectHeading { - request: Navigate { - computer: computer.key.clone(), - destination: Destination::Project(root.clone()), - }, - id: format!("computer-project-{:?}-{root}", computer.key).into(), - target, - name: label.clone().into(), - label: if self.view.group == SidebarGroup::Connection { - label.into() - } else { - format!("{} · {}", label, computer.label).into() - }, - remote: computer.key.is_some(), - }); + self.computer_projects + .push(ProjectRow::new(target, label.into())); entries.push(SidebarEntry::ComputerProject(heading)); } @@ -1754,7 +1730,6 @@ impl Sidebar { fn push_computer_task( &mut self, entries: &mut Vec, - _computer_index: usize, computer: &Computer, task: &TaskSummary, ) { @@ -1844,7 +1819,11 @@ impl Sidebar { } let request = match entry { SidebarEntry::ComputerProject(index) => { - self.computer_projects.get(index)?.request.clone() + let target = &self.computer_projects.get(index)?.target; + Navigate { + computer: target.computer.clone(), + destination: Destination::Project(target.path.clone()), + } } SidebarEntry::OtherTask(index) => self.other_tasks.get(index)?.request.clone(), SidebarEntry::Computer(index) => Navigate { @@ -1967,69 +1946,7 @@ impl Sidebar { .into_any_element() } SidebarEntry::ComputerProject(index) => { - let project = &self.computer_projects[index]; - if let Some(root) = self - .computer_project_roots - .iter() - .flatten() - .find(|root| root.target == project.target) - { - return self.render_project_header(root, window, cx); - } - let request = project.request.clone(); - let expanded = !self.collapsed_projects.contains(&project.target); - div() - .id(project.id.clone()) - .role(gpui::Role::Button) - .aria_label(project.label.clone()) - .aria_expanded(expanded) - .mx_2() - .my(px(1.)) - .mt_3() - .px_2() - .py_1p5() - .rounded(ROW_RADIUS) - .flex() - .gap_2() - .items_center() - .text_xs() - .text_color(gpui::rgb(theme::text_secondary())) - .when(selected, |row| { - row.aria_active_descendant() - .bg(gpui::rgb(theme::bg_sidebar_row_hover())) - }) - .hover(move |s| { - if dragging { - s - } else { - s.bg(gpui::rgb(hover_bg)).cursor_pointer() - } - }) - .on_click(cx.listener(move |this, _, _, cx| { - this.toggle_computer_group(&request, cx); - })) - .child(icon( - if expanded { - "chevron-down" - } else { - "chevron-right" - }, - px(12.), - theme::text_muted(), - )) - .child(icon( - if project.remote { "globe" } else { "folder" }, - px(14.), - theme::text_muted(), - )) - .child( - div() - .flex_1() - .min_w_0() - .truncate() - .child(project.label.clone()), - ) - .into_any_element() + self.render_project_header(&self.computer_projects[index], window, cx) } SidebarEntry::OtherTask(index) => { let task = &self.other_tasks[index]; diff --git a/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs b/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs index 13eef6641..9e7ae5edb 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs @@ -30,7 +30,7 @@ impl ReorderRow { impl Sidebar { pub(super) fn connection_grouping_available(&self) -> bool { - self.computers.iter().any(|computer| computer.key.is_some()) + self.uses_computer_projection() } pub(super) fn load_order(&mut self, cx: &mut Context) { @@ -112,17 +112,14 @@ impl Sidebar { if self.view.sort == SidebarSort::Updated { return (0, updated.saturating_neg()); } - if let Some(rank) = self - .task_ranks - .get(computer) - .and_then(|ranks| ranks.get(id)) - { - (1, *rank as i64) - } else if created > self.order.created_before_ms { - (0, created.saturating_neg()) - } else { - (2, created.saturating_neg()) - } + catalog::manual_order_key( + self.task_ranks + .get(computer) + .and_then(|ranks| ranks.get(id)) + .copied(), + created, + self.order.created_before_ms, + ) } pub(super) fn local_manual_order( diff --git a/apps/maple-agent/docs/remote-access.md b/apps/maple-agent/docs/remote-access.md index 1400c260d..335c5d3da 100644 --- a/apps/maple-agent/docs/remote-access.md +++ b/apps/maple-agent/docs/remote-access.md @@ -28,9 +28,10 @@ The sidebar's **View options** menu groups tasks by **Project**, **Connection**, **Status**, or **In one list**. Connection becomes available after another computer is added, including while it is offline; enabling remote access on this computer alone does not add that choice. Project and Connection views keep -known empty projects visible. A globe marks remote projects. Selecting a task or -project also selects its execution computer; select a **This Computer** project -to return to local work. +known empty projects visible. Project view shows the computer name beside remote +projects; Connection view places projects under their computer heading. Selecting +a task or project also selects its execution computer; select a **This Computer** +project to return to local work. **Sort tasks by** offers **Last updated** and **Manual order**, independently of grouping. Project and connection positions stay stable in either mode and can From 1aa717550c8cd111562ef3185934b374ec63a776 Mon Sep 17 00:00:00 2001 From: marks Date: Tue, 27 Oct 2026 15:36:21 -0500 Subject: [PATCH 23/29] Keep sidebar task layout stable across computers --- apps/maple-agent/app/src/ui/chat/sidebar.rs | 103 ++++++++++-------- .../app/src/ui/chat/sidebar_computers.rs | 12 +- apps/maple-agent/app/src/ui/chat/tests.rs | 1 - 3 files changed, 64 insertions(+), 52 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index 6bb11f4ff..d31abf5fd 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -253,6 +253,12 @@ const FOLDER_HEADER_PADDING: gpui::Rems = gpui::rems(1.); const FOLDER_HEADER_GAP: gpui::Rems = gpui::rems(0.5); const FOLDER_ICON_SIZE: gpui::Pixels = px(16.); +/// Task titles share the project-name column, regardless of their computer or state. +fn task_row_padding(window: &Window) -> gpui::Pixels { + (FOLDER_HEADER_PADDING + FOLDER_HEADER_GAP - gpui::rems(0.5)).to_pixels(window.rem_size()) + + FOLDER_ICON_SIZE +} + /// The sidebar's popup menus. One is open at a time; a project's own menu /// uses the project row's own popup (see `Sidebar::project_popup`). #[derive(Clone, Debug, PartialEq, Eq)] @@ -303,8 +309,13 @@ pub(super) struct SidebarTaskEntry { pub(super) pinned: bool, pub(super) settled: bool, pub(super) archived: bool, - /// Space after the last visible task in a section. - pub(super) gap_after: bool, +} + +/// Section spacing belongs to the visible list, not to a task's active computer. +#[derive(Clone, Copy, Default, PartialEq, Eq)] +struct RowSpacing { + before: bool, + after: bool, } /// Where a task sits on the ladder active, settled, archived, deleted. @@ -552,7 +563,7 @@ pub(super) struct Sidebar { drag_ranges: HashMap, dragging: bool, drop_target: Option, - heading_gaps: Vec, + row_spacing: Vec, entry_sections: Vec>, hovered_section: Option, hovered_entry: Option, @@ -880,7 +891,7 @@ impl Sidebar { drag_ranges: HashMap::new(), dragging: false, drop_target: None, - heading_gaps: Vec::new(), + row_spacing: Vec::new(), entry_sections: Vec::new(), hovered_section: None, hovered_entry: None, @@ -1711,7 +1722,6 @@ impl Sidebar { settled: section != SidebarSection::Pinned && !self.session_is_active(&self.sessions[session]), archived: false, - gap_after: false, }) })); } @@ -1756,7 +1766,6 @@ impl Sidebar { archived: true, pinned: false, settled: false, - gap_after: false, }) })); } @@ -1787,18 +1796,11 @@ impl Sidebar { self.hovered_entry = None; self.hovered_section = None; self.drop_target = None; - let mut entries = if self.uses_computer_projection() { + let entries = if self.uses_computer_projection() { self.computer_entries() } else { self.local_entries() }; - for ix in 0..entries.len() { - let last = - !matches!(entries.get(ix + 1), Some(SidebarEntry::Task(task)) if !task.archived); - if let SidebarEntry::Task(task) = &mut entries[ix] { - task.gap_after = !task.archived && last; - } - } let mut section = None; self.entry_sections = entries .iter() @@ -1815,20 +1817,26 @@ impl Sidebar { section }) .collect(); - let heading_gaps = entries + let unarchived_task = |entry: &SidebarEntry| match entry { + SidebarEntry::Task(task) => !task.archived, + SidebarEntry::OtherTask(index) => !self.other_tasks[*index].archived, + _ => false, + }; + let row_spacing = entries .iter() .enumerate() - .map(|(ix, entry)| { - matches!( + .map(|(ix, entry)| RowSpacing { + before: matches!( entry, SidebarEntry::SectionLabel(_) | SidebarEntry::ArchivedHeader ) && !matches!( ix.checked_sub(1).and_then(|ix| entries.get(ix)), Some(SidebarEntry::SectionLabel(_) | SidebarEntry::ArchivedHeader) - ) + ), + after: unarchived_task(entry) && !entries.get(ix + 1).is_some_and(unarchived_task), }) .collect(); - let old_gaps = std::mem::replace(&mut self.heading_gaps, heading_gaps); + let old_spacing = std::mem::replace(&mut self.row_spacing, row_spacing); let old = std::mem::replace(&mut self.entries, entries); self.rebuild_reorder_rows(); if self.application_vim_enabled { @@ -1856,11 +1864,18 @@ impl Sidebar { self.entries.len() - prefix - suffix, ); } - // A retained heading can gain or lose its gap when its predecessor - // changes. Invalidate its cached height even when it is off screen. - if suffix > 0 { - let row = self.entries.len() - suffix; - if self.heading_gaps[row] != old_gaps[old.len() - suffix] { + // Retained rows can gain or lose a gap when a neighbor changes. + // Remeasure only those rows, preserving their scroll anchor. + for (old_row, row) in (0..prefix) + .map(|row| (row, row)) + .chain((0..suffix).map(|offset| { + ( + old.len() - suffix + offset, + self.entries.len() - suffix + offset, + ) + })) + { + if self.row_spacing[row] != old_spacing[old_row] { self.list.remeasure_items(row..row + 1); } } @@ -2098,6 +2113,10 @@ impl Sidebar { matches!(self.view.group, SidebarGroup::Status | SidebarGroup::List) } + fn show_task_metadata(&self, pinned: bool, archived: bool) -> bool { + self.show_metadata() || pinned || archived + } + /// Open or close the Projects header's menu from its button. pub(super) fn toggle_header_menu(&mut self, cx: &mut Context) { self.project_popup.close(cx); @@ -2856,7 +2875,8 @@ impl Sidebar { let selected = self.selected.as_deref(); let chat = self.chat.clone(); // Adjacent folded headings keep only their pill margins. - let heading_gap = self.heading_gaps.get(ix).copied().unwrap_or(false); + let spacing = self.row_spacing.get(ix).copied().unwrap_or_default(); + let heading_gap = spacing.before; let entry = match self.entries.get(ix).copied() { Some( entry @ (SidebarEntry::ComputerProject(_) @@ -2946,10 +2966,7 @@ impl Sidebar { } Some(SidebarEntry::Task(task)) => { let application_selected = self.vim_selects_row(ix); - let row = self.render_task_row(task, selected, application_selected, window, cx); - div() - .when(task.gap_after, |row| row.mb_2()) - .child(row) + self.render_task_row(task, selected, application_selected, window, cx) .into_any_element() } Some(SidebarEntry::SectionLabel(SidebarSection::Project(index))) => { @@ -3086,6 +3103,17 @@ impl Sidebar { } None => div().into_any_element(), }; + let entry = if matches!( + self.entries.get(ix), + Some(SidebarEntry::Task(_) | SidebarEntry::OtherTask(_)) + ) { + div() + .when(spacing.after, |row| row.mb_2()) + .child(entry) + .into_any_element() + } else { + entry + }; let entry = if matches!( self.entries.get(ix), Some(SidebarEntry::NewTask | SidebarEntry::Search) @@ -3821,11 +3849,6 @@ impl Sidebar { archived, .. } = task; - let folder_task = matches!( - self.view.group, - SidebarGroup::Project | SidebarGroup::Connection - ) && !is_pinned - && !archived; let row = &self.rows[index]; let is_selected = selected == Some(&*row.id); let activity = (!archived) @@ -3861,15 +3884,7 @@ impl Sidebar { .flex() .items_center() .gap_1p5() - .when(archived, |row| row.pl_4()) - .when(!archived && !folder_task, |row| row.pl_6()) - .when(folder_task, |row| { - row.pl( - (FOLDER_HEADER_PADDING + FOLDER_HEADER_GAP - gpui::rems(0.5)) - .to_pixels(window.rem_size()) - + FOLDER_ICON_SIZE, - ) - }) + .pl(task_row_padding(window)) .pr_4() .py_1() .rounded(ROW_RADIUS) @@ -3898,7 +3913,7 @@ impl Sidebar { None => column .child(div().line_clamp(1).text_ellipsis().child(row.title.clone())), }) - .when(self.show_metadata() || is_pinned || archived, |column| { + .when(self.show_task_metadata(is_pinned, archived), |column| { column.child( div() .text_xs() 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 8afe35c18..c6b166eae 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs @@ -1745,7 +1745,6 @@ impl Sidebar { pinned: self.computer_task_pin(computer, task).is_some(), settled: state == AgentTaskState::Settled && !self.running.contains(&task.uuid), archived: state == AgentTaskState::Archived, - gap_after: false, })); } } else { @@ -1762,9 +1761,8 @@ impl Sidebar { } let index = self.other_tasks.len(); let session = session_from_summary(task); - let metadata = if matches!(self.view.group, SidebarGroup::Status | SidebarGroup::List) - || archived - { + let pinned = self.computer_task_pin(computer, task).is_some(); + let metadata = if self.show_task_metadata(pinned, archived) { format!( "{} · {}", self.computer_project_name( @@ -1797,7 +1795,7 @@ impl Sidebar { title: latest .map_or_else(|| task.title.clone(), |session| session.title.clone()) .into(), - pinned: self.computer_task_pin(computer, task).is_some(), + pinned, activity: if computer.running.contains(&task.uuid) { Some(SessionActivity::Running) } else if computer.unread.contains(&task.uuid) { @@ -1958,11 +1956,11 @@ impl Sidebar { .aria_selected(false) .mx_2() .my(px(1.)) - .pl_6() + .pl(task_row_padding(window)) .pr_4() .py_1() .flex() - .gap_2() + .gap_1p5() .items_center() .rounded(ROW_RADIUS) .text_sm() diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index b01b87205..0598feb9e 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -5510,7 +5510,6 @@ mod state_tests { pinned: session == 3, settled: false, archived: false, - gap_after: session != 2, }) }; assert_eq!( From 915ac8747caeb4add5627c4575b2b8453588c9a0 Mon Sep 17 00:00:00 2001 From: marks Date: Tue, 27 Oct 2026 15:40:06 -0500 Subject: [PATCH 24/29] Show pinned tasks above all sidebar groups --- apps/maple-agent/README.md | 5 +- .../app/src/ui/chat/sidebar_computers.rs | 57 +++++++++++-------- .../app/src/ui/chat/sidebar_ordering.rs | 8 ++- 3 files changed, 41 insertions(+), 29 deletions(-) diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index b49c80dbb..0165f1ac2 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -132,8 +132,9 @@ Goose, with an independent dependency graph. known empty projects visible. Project and connection order stays stable; dragging rearranges the sidebar without changing a task's project, computer or status. Dragging a task silently selects Manual order, which persists - across grouping changes and restarts; new tasks appear at the top. Tasks also - support rename, archive, and restore. + across grouping changes and restarts; new tasks appear at the top. Pinned + tasks sit above the grouping header and return to their normal group when + unpinned. Tasks also support rename, archive, and restore. - Settings: General (web tools, appearance, tool call details, desktop notifications, tool call summaries, composer Vim, application Vim, and the speech voice and speed), Keyboard Shortcuts, System prompt, Integrations 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 c6b166eae..2c2731a92 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs @@ -1336,14 +1336,8 @@ impl Sidebar { right: &Option, b: &TaskSummary, ) -> std::cmp::Ordering { - a.pinned_ms - .is_none() - .cmp(&b.pinned_ms.is_none()) - .then_with(|| b.pinned_ms.cmp(&a.pinned_ms)) - .then_with(|| { - self.task_order_key(left, &a.uuid, a.created_ms, a.updated_ms) - .cmp(&self.task_order_key(right, &b.uuid, b.created_ms, b.updated_ms)) - }) + self.task_order_key(left, &a.uuid, a.created_ms, a.updated_ms) + .cmp(&self.task_order_key(right, &b.uuid, b.created_ms, b.updated_ms)) .then_with(|| left.cmp(right)) .then_with(|| a.uuid.cmp(&b.uuid)) } @@ -1437,11 +1431,7 @@ impl Sidebar { self.other_tasks.clear(); self.group_pages.more.clear(); self.computer_archived_count = 0; - let mut entries = vec![ - SidebarEntry::NewTask, - SidebarEntry::Search, - SidebarEntry::ProjectsHeader, - ]; + let mut entries = vec![SidebarEntry::NewTask, SidebarEntry::Search]; // A mounted view projects only bounded, owner-fenced summary pages. // Drafts, transcripts and execution remain in their owning ChatScreen. let computers = self.computers.clone(); @@ -1524,6 +1514,35 @@ impl Sidebar { } } } + // Pins are a desktop view across computers, above every grouping. + // Keep the task's original project and saved order for unpinning. + let mut pinned = Vec::new(); + let mut seen_pins = HashSet::new(); + let mut take_pin = |index: usize, task: &TaskSummary| { + if task.pinned_ms.is_none() { + return true; + } + if seen_pins.insert((index, task.uuid.clone())) { + pinned.push((index, task.clone())); + } + false + }; + for (index, group) in &mut groups { + group.tasks.retain(|task| take_pin(*index, task)); + } + tasks.retain(|(index, task)| take_pin(*index, task)); + self.sort_computer_tasks(&mut pinned); + let has_pins = !pinned.is_empty(); + if has_pins { + entries.push(SidebarEntry::SectionLabel(SidebarSection::Pinned)); + if self.section_expanded(SidebarSection::Pinned) { + for (index, task) in &pinned { + self.push_computer_task(&mut entries, &computers[*index], task); + } + } + } + entries.push(SidebarEntry::ProjectsHeader); + let group_start = entries.len(); groups.sort_by_cached_key(|(index, group)| { let target = ProjectTarget { computer: computers[*index].key.clone(), @@ -1659,23 +1678,13 @@ impl Sidebar { } } } - if entries.len() == 3 && self.projects_expanded { + if entries.len() == group_start && !has_pins && self.projects_expanded { entries.push(SidebarEntry::Empty(if searching { SidebarEmpty::NoMatches } else { SidebarEmpty::NoTasks })); } - if !self.projects_expanded { - let archived = entries - .iter() - .position(|entry| *entry == SidebarEntry::ArchivedHeader); - let mut folded = entries[..3].to_vec(); - if let Some(start) = archived { - folded.extend_from_slice(&entries[start..]); - } - return folded; - } entries } diff --git a/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs b/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs index 9e7ae5edb..6e0db2f5f 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs @@ -276,10 +276,10 @@ impl Sidebar { state: AgentTaskState, pinned: bool, ) -> TaskGroup { - if pinned && !self.uses_computer_projection() { - TaskGroup::Pinned - } else if state == AgentTaskState::Archived { + if state == AgentTaskState::Archived { TaskGroup::Archived + } else if pinned { + TaskGroup::Pinned } else { match self.view.group { SidebarGroup::Project | SidebarGroup::Connection => TaskGroup::Project(target), @@ -343,6 +343,7 @@ impl Sidebar { self.view.group, SidebarGroup::Project | SidebarGroup::Connection ) && !task.archived + && !task.pinned { row.project = Some(target); } @@ -368,6 +369,7 @@ impl Sidebar { self.view.group, SidebarGroup::Project | SidebarGroup::Connection ) && !task.archived + && !task.pinned { row.project = Some(target); } From 3e5bb71111ae26d4f1c6348abc4a5447230355ef Mon Sep 17 00:00:00 2001 From: marks Date: Tue, 27 Oct 2026 16:04:42 -0500 Subject: [PATCH 25/29] Use the task identity when clicking Unpin --- apps/maple-agent/app/src/ui/chat/sidebar.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index d31abf5fd..25d6c1cb1 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -3859,7 +3859,7 @@ impl Sidebar { let rename_id = Arc::clone(&row.id); let rung = TaskRung::of(task); let step = rung.step(); - let pin_id = row.pin_id.clone(); + let pin_task_id = Arc::clone(&row.id); let rename_field = self.task_rename_field(&row.id, cx); let renaming = rename_field.is_some(); let hover_bg = if is_selected { @@ -3931,10 +3931,10 @@ impl Sidebar { .when_some(activity, |row_element, activity| { row_element.child(activity_indicator(&row.spinner_id, activity)) }) - .when(is_pinned, |row| { - row.child( + .when(is_pinned, |row_element| { + row_element.child( div() - .id(pin_id.clone()) + .id(row.pin_id.clone()) .size_5() .flex() .items_center() @@ -3948,7 +3948,7 @@ impl Sidebar { .tooltip(widgets::tooltip("Unpin", None)) .on_click(cx.listener(move |this, _event, _window, cx| { cx.stop_propagation(); - this.toggle_task_pin(&pin_id, cx); + this.toggle_task_pin(&pin_task_id, cx); })) .child(icon("pin", px(13.), theme::accent())), ) From beeb0f56c55663ec01b360f57a1d0db1fc93285b Mon Sep 17 00:00:00 2001 From: marks Date: Tue, 27 Oct 2026 16:17:18 -0500 Subject: [PATCH 26/29] Keep the normal cursor over project rows --- apps/maple-agent/app/src/ui/chat/sidebar.rs | 8 +------- 1 file changed, 1 insertion(+), 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 25d6c1cb1..6b612500b 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -3576,7 +3576,6 @@ impl Sidebar { // While a sidebar menu is open, only its owning header shows actions. let menu_open = self.project_popup.is_open(&root.target); let hover = !renaming && !self.sidebar_menu_open(); - let reorderable = hover && self.can_reorder(); let new_task = root.target.clone(); let menu_target = root.target.clone(); let selector = root.folder_id.clone(); @@ -3604,15 +3603,10 @@ impl Sidebar { }) .when(hover, |row| { row.hover(|style| { - let style = if self.dragging { + if self.dragging { style } else { style.bg(gpui::rgb(theme::bg_sidebar_row_hover())) - }; - if reorderable { - style.cursor_move() - } else { - style.cursor_pointer() } }) }) From 0a4e3480a7d029624192d07216a3ba22cfe310ba Mon Sep 17 00:00:00 2001 From: marks Date: Tue, 27 Oct 2026 22:23:47 -0500 Subject: [PATCH 27/29] Fix sidebar review regressions and popup placement --- apps/maple-agent/README.md | 6 +- .../app/assets/icons/arrow-down.svg | 1 + apps/maple-agent/app/src/backend/local.rs | 3 + apps/maple-agent/app/src/settings.rs | 98 ++- apps/maple-agent/app/src/ui/chat/mod.rs | 20 +- apps/maple-agent/app/src/ui/chat/sidebar.rs | 73 +- .../app/src/ui/chat/sidebar_catalog.rs | 62 +- .../app/src/ui/chat/sidebar_computers.rs | 660 +++++++++++++++++- .../app/src/ui/chat/sidebar_ordering.rs | 486 +++++++++++-- .../app/src/ui/chat/sidebar_paging.rs | 219 +++++- .../app/src/ui/chat/sidebar_prefix.rs | 236 +++++++ apps/maple-agent/app/src/ui/chat/tests.rs | 176 +++-- .../app/src/ui/chat/upload_tests.rs | 58 +- apps/maple-agent/docs/remote-access.md | 16 +- 14 files changed, 1927 insertions(+), 187 deletions(-) create mode 100644 apps/maple-agent/app/assets/icons/arrow-down.svg create mode 100644 apps/maple-agent/app/src/ui/chat/sidebar_prefix.rs diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index 0165f1ac2..1ff4be7d6 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -133,8 +133,10 @@ Goose, with an independent dependency graph. dragging rearranges the sidebar without changing a task's project, computer or status. Dragging a task silently selects Manual order, which persists across grouping changes and restarts; new tasks appear at the top. Pinned - tasks sit above the grouping header and return to their normal group when - unpinned. Tasks also support rename, archive, and restore. + tasks sit above the grouping header; their own drag order does not change + the position they return to when unpinned. Sidebar menus open toward the + main pane so the list stays visible. Tasks also support rename, archive, + and restore. Unknown or malformed View preferences fall back to defaults. - Settings: General (web tools, appearance, tool call details, desktop notifications, tool call summaries, composer Vim, application Vim, and the speech voice and speed), Keyboard Shortcuts, System prompt, Integrations diff --git a/apps/maple-agent/app/assets/icons/arrow-down.svg b/apps/maple-agent/app/assets/icons/arrow-down.svg new file mode 100644 index 000000000..f4228c61c --- /dev/null +++ b/apps/maple-agent/app/assets/icons/arrow-down.svg @@ -0,0 +1 @@ + diff --git a/apps/maple-agent/app/src/backend/local.rs b/apps/maple-agent/app/src/backend/local.rs index b0aef845f..0fddf643c 100644 --- a/apps/maple-agent/app/src/backend/local.rs +++ b/apps/maple-agent/app/src/backend/local.rs @@ -71,6 +71,7 @@ fn validate_sidebar_order(order: &crate::settings::SidebarOrder) -> Result<(), S const MAX_ORDER_BYTES: usize = 2 * 1024 * 1024; if order.projects.len() > MAX_ITEMS || order.tasks.len() > MAX_ITEMS + || order.pinned_tasks.len() > MAX_ITEMS || order.connections.len() > MAX_CONNECTIONS { return Err("Sidebar order is too large".into()); @@ -92,6 +93,7 @@ fn validate_sidebar_order(order: &crate::settings::SidebarOrder) -> Result<(), S for (items, maximum, is_task) in [ (&order.projects, MAX_ROOT_BYTES, false), (&order.tasks, MAX_TASK_ID_BYTES, true), + (&order.pinned_tasks, MAX_TASK_ID_BYTES, true), ] { let mut seen = HashSet::new(); for item in items { @@ -117,6 +119,7 @@ fn validate_sidebar_order(order: &crate::settings::SidebarOrder) -> Result<(), S .projects .iter() .chain(&order.tasks) + .chain(&order.pinned_tasks) .fold(bytes, |bytes, item| bytes.saturating_add(item.id.len())); if bytes > MAX_ORDER_BYTES || serde_json::to_vec(order) diff --git a/apps/maple-agent/app/src/settings.rs b/apps/maple-agent/app/src/settings.rs index fd4481819..d5de9748b 100644 --- a/apps/maple-agent/app/src/settings.rs +++ b/apps/maple-agent/app/src/settings.rs @@ -17,26 +17,41 @@ pub struct RemoteSidebarSettings { #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(default)] pub struct SidebarView { + #[serde(deserialize_with = "view_preference_or_default")] pub group: SidebarGroup, + #[serde(deserialize_with = "view_preference_or_default")] pub sort: SidebarSort, } +// View state is optional presentation data. Unknown values (for example after +// a downgrade) or malformed fields must not prevent the host from starting. +fn view_preference_or_default<'de, D, T>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, + T: serde::de::DeserializeOwned + Default, +{ + let value = serde::Deserialize::deserialize(deserializer)?; + Ok(serde_json::from_value(value).unwrap_or_default()) +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "lowercase")] pub enum SidebarGroup { - #[default] - Project, Connection, Status, List, + #[default] + #[serde(other)] + Project, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "lowercase")] pub enum SidebarSort { + Manual, #[default] + #[serde(other)] Updated, - Manual, } /// Stable presentation identity; a remote task or project belongs to its computer. @@ -53,6 +68,8 @@ pub struct SidebarOrder { pub projects: Vec, pub connections: Vec>, pub tasks: Vec, + /// Pinned presentation order is independent of each task's ordinary slot. + pub pinned_tasks: Vec, pub created_before_ms: i64, } @@ -90,7 +107,7 @@ pub struct AppSettings { /// `/work/project` is not the same project as that path on this computer. #[serde(default)] pub remote_sidebars: std::collections::HashMap, - #[serde(default)] + #[serde(default, deserialize_with = "view_preference_or_default")] pub sidebar_layout: SidebarView, /// Whether run completion and questions raise desktop notifications /// while the window is not focused. @@ -988,22 +1005,65 @@ mod tests { } #[test] - fn sidebar_layout_rejects_unknown_choices() { - for group in ["invalid", "unknown"] { - assert!( - serde_json::from_value::(serde_json::json!({ - "sidebar_layout": {"group": group} - })) - .is_err() - ); + fn sidebar_layout_defaults_unknown_and_invalid_choices_independently() { + for invalid in [ + serde_json::json!("future-choice"), + serde_json::json!(null), + serde_json::json!(false), + serde_json::json!(42), + serde_json::json!([]), + serde_json::json!({}), + ] { + let settings: AppSettings = serde_json::from_value(serde_json::json!({ + "sidebar_layout": {"group": invalid, "sort": "manual"}, + "theme": "dark" + })) + .unwrap(); + assert_eq!(settings.sidebar_layout.group, SidebarGroup::Project); + assert_eq!(settings.sidebar_layout.sort, SidebarSort::Manual); + assert_eq!(settings.theme, "dark"); + + let settings: AppSettings = serde_json::from_value(serde_json::json!({ + "sidebar_layout": {"group": "connection", "sort": invalid} + })) + .unwrap(); + assert_eq!(settings.sidebar_layout.group, SidebarGroup::Connection); + assert_eq!(settings.sidebar_layout.sort, SidebarSort::Updated); + + let settings: AppSettings = serde_json::from_value(serde_json::json!({ + "sidebar_layout": invalid + })) + .unwrap(); + assert_eq!(settings.sidebar_layout, SidebarView::default()); } - for sort in ["invalid", "unknown"] { - assert!( - serde_json::from_value::(serde_json::json!({ - "sidebar_layout": {"sort": sort} - })) - .is_err() - ); + } + + #[test] + fn invalid_view_preferences_do_not_block_host_settings_or_later_edits() { + let root = tempfile::tempdir().unwrap(); + let store = + maple_agent::store::Store::open(maple_agent::store::StorePaths::new(root.path())) + .unwrap(); + for invalid in [ + serde_json::json!({"group": "future-group", "sort": "future-sort"}), + serde_json::json!({"group": 7, "sort": {"invalid": true}}), + serde_json::Value::Null, + ] { + store + .set_app_setting( + "ui", + &serde_json::json!({"sidebar_layout": invalid, "theme": "dark"}), + 1, + ) + .unwrap(); + let loaded = load_host_settings(&store, None).unwrap(); + assert_eq!(loaded.sidebar_layout, SidebarView::default()); + assert_eq!(loaded.theme, "dark"); + apply_host_patch(&store, None, &serde_json::json!({"reduce_motion": true})).unwrap(); + let loaded = load_host_settings(&store, None).unwrap(); + assert!(loaded.reduce_motion); + assert_eq!(loaded.theme, "dark"); + assert_eq!(loaded.sidebar_layout, SidebarView::default()); } } diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 713fed0e9..ed4556f5b 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -5411,23 +5411,27 @@ impl ChatScreen { } self.sign_out_pending = true; // Pending upload completions cannot admit a prompt while saving the - // account's final presentation edits before sign-out. + // account's final presentation edits before sign-out. Those edits are + // best effort: a failed flush must never keep an account signed in. self.accept_send_results = false; self.call( async { - tokio::time::timeout( + match tokio::time::timeout( std::time::Duration::from_secs(10), crate::settings::flush_pending(), ) .await - .map_err(|_| "Saving changes before sign-out timed out. Try again.".to_string())? - .map_err(|message| format!("Could not save changes before signing out: {message}")) + { + Ok(Ok(())) => {} + Ok(Err(error)) => { + log::warn!("Could not save UI preferences before sign-out: {error}") + } + Err(_) => log::warn!("Saving UI preferences before sign-out timed out"), + } + Ok(()) }, cx, - |this, result, cx| match result { - Ok(()) => this.sign_out_after_settings_flush(cx), - Err(message) => this.sign_out_failed(message, cx), - }, + |this, _, cx| this.sign_out_after_settings_flush(cx), ); } diff --git a/apps/maple-agent/app/src/ui/chat/sidebar.rs b/apps/maple-agent/app/src/ui/chat/sidebar.rs index 6b612500b..81281ffc2 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -18,6 +18,8 @@ mod computers; mod ordering; #[path = "sidebar_paging.rs"] mod paging; +#[path = "sidebar_prefix.rs"] +mod prefix; use super::computers::{Computer, Destination, Navigate, ProjectRenameRequest, ProjectTarget}; use computers::{ComputerPages, OtherTask}; use paging::{PageKind, SidebarPages}; @@ -587,6 +589,7 @@ pub(super) struct Sidebar { order_loading: bool, manual_catalog: catalog::Catalog, task_ranks: HashMap, HashMap>, + pinned_task_ranks: HashMap, HashMap>, order_query_dirty: bool, project_ranks: HashMap, HashMap>, project_roots: Vec, @@ -776,6 +779,7 @@ impl Sidebar { if let Some(order) = crate::settings::cached_sidebar_order(&self.user_id) { sort_changed |= self.view.sort == SidebarSort::Manual && (self.order.tasks != order.tasks + || self.order.pinned_tasks != order.pinned_tasks || self.order.created_before_ms != order.created_before_ms); self.order = order; self.order_loaded = true; @@ -911,6 +915,7 @@ impl Sidebar { order_loading: false, manual_catalog: catalog::Catalog::default(), task_ranks: HashMap::new(), + pinned_task_ranks: HashMap::new(), order_query_dirty: false, project_ranks: HashMap::new(), project_roots: Vec::new(), @@ -1115,6 +1120,7 @@ impl Sidebar { /// Forget tasks that left the app (their project was removed). pub(super) fn forget_tasks(&mut self, ids: &[String], cx: &mut Context) { + self.forget_task_order(ids, cx); self.forget_page_tasks(ids); let before = self.pinned_tasks.len(); self.pinned_tasks @@ -1405,6 +1411,7 @@ impl Sidebar { /// process reads; a fixture starts from nothing persisted. #[cfg(test)] pub(super) fn reset_persisted_for_test(&mut self) { + crate::settings::retire_sidebar_account(&self.user_id); // Most fixtures check the task ladder, so they group by status. self.view = SidebarView { group: SidebarGroup::Status, @@ -1415,6 +1422,7 @@ impl Sidebar { self.order_loaded = true; self.order_loading = false; self.task_ranks.clear(); + self.pinned_task_ranks.clear(); self.project_ranks.clear(); self.project_metadata.clear(); self.task_project_labels.clear(); @@ -1550,7 +1558,7 @@ impl Sidebar { let filtering = !filter.is_empty(); let project_scope = self.project_filter.clone(); let scoped = project_scope.as_deref(); - let pinned_ids: HashSet<&str> = self.pinned_tasks.iter().map(String::as_str).collect(); + let pinned_ids: HashSet = self.pinned_tasks.iter().cloned().collect(); let mut pinned = Vec::new(); let mut listed = Vec::new(); let mut archived = Vec::new(); @@ -1619,10 +1627,14 @@ impl Sidebar { let mut keys = HashMap::new(); for &ix in pinned.iter().chain(&listed).chain(&archived) { let task = &self.sessions[ix]; - keys.insert( - ix, - self.task_order_key(&computer, &task.id, task.created_ms, task.updated_ms), - ); + let key = if task.state != AgentTaskState::Archived + && pinned_ids.contains(task.id.as_str()) + { + self.pinned_task_order_key(&computer, &task.id, task.created_ms, task.updated_ms) + } else { + self.task_order_key(&computer, &task.id, task.created_ms, task.updated_ms) + }; + keys.insert(ix, key); } let order = |a: &usize, b: &usize| { keys[a] @@ -1630,6 +1642,20 @@ impl Sidebar { .then_with(|| self.sessions[*a].id.cmp(&self.sessions[*b].id)) }; pinned.sort_by(order); + if let Some(prefix) = self.local_task_prefix() { + listed.retain(|&ix| { + let task = &self.sessions[ix]; + self.running.contains(&task.id) + || self.selected.as_ref() == Some(&task.id) + || self.pages.is_live(&task.id) + || prefix.allows(&self.task_prefix_position( + &computer, + &task.id, + task.created_ms, + task.updated_ms, + )) + }); + } listed.sort_by(order); archived.sort_by(order); let mut sections = Vec::new(); @@ -1737,6 +1763,8 @@ impl Sidebar { tasks(&mut entries, *section, rows); } if self.pages.enabled { + let prefix = self.local_task_prefix(); + let next_state = prefix.as_ref().and_then(|prefix| prefix.next.first()); for (kind, expanded) in [ ( PageKind::Active, @@ -1751,7 +1779,10 @@ impl Sidebar { (PageKind::Search, !self.filter.is_empty()), (PageKind::Projects, true), ] { - if expanded && self.pages.has_more(kind) { + let allowed = prefix.is_none() + || !matches!(kind, PageKind::Active | PageKind::Settled) + || next_state == Some(&kind); + if allowed && expanded && self.pages.has_more(kind) { entries.push(SidebarEntry::LoadMore(kind)); } } @@ -2587,7 +2618,7 @@ impl Sidebar { if self.can_reorder() { for (forward, id, icon, label) in [ (false, "move-project-up", "arrow-up", "Move up"), - (true, "move-project-down", "chevron-down", "Move down"), + (true, "move-project-down", "arrow-down", "Move down"), ] { if self.project_move_available(target, forward) { let target = target.clone(); @@ -3314,12 +3345,12 @@ impl Sidebar { cx, ) }); - let choose_computer = self.uses_computer_projection(); + let choose_computer = self.has_added_computer(); let add_open = choose_computer && self.popup.is_open(&SidebarPopup::NewProject); let add_menu = add_open.then(|| { self.popup.render( self.new_project_computer_menu(), - Placement::BelowEnd, + Placement::BelowStart, window, cx, ) @@ -3484,14 +3515,13 @@ impl Sidebar { /// Add project stays visible until there is a project to browse. fn new_project_button(&self, open: bool) -> gpui::Stateful
{ - header_button("new-project", "folder-plus", "New project", open).when( - !open && !self.project_roots.is_empty(), - |button| { + header_button("new-project", "folder-plus", "New project", open) + .debug_selector(|| "new-project".to_owned()) + .when(!open && !self.project_roots.is_empty(), |button| { button .opacity(0.) .group_hover("projects-header-actions", |style| style.opacity(1.)) - }, - ) + }) } fn new_project_computer_menu(&self) -> Menu { @@ -3715,7 +3745,7 @@ impl Sidebar { } let placement = self .project_menu_at - .map_or(Placement::BelowEnd, Placement::At); + .map_or(Placement::BelowStart, Placement::At); self.project_popup.render(menu, placement, window, cx) }); let toggle = root.target.clone(); @@ -3762,7 +3792,7 @@ impl Sidebar { .separator() .item(menu_item(items.delete)); } - self.popup.render(menu, Placement::BelowEnd, window, cx) + self.popup.render(menu, Placement::BelowStart, window, cx) }); let session = row.id.clone(); div() @@ -3929,6 +3959,8 @@ impl Sidebar { row_element.child( div() .id(row.pin_id.clone()) + .role(gpui::Role::Button) + .aria_label("Unpin task") .size_5() .flex() .items_center() @@ -4252,19 +4284,16 @@ mod tests { #[gpui::test] fn sidebar_remeasures_an_offscreen_heading_when_its_gap_changes(cx: &mut gpui::TestAppContext) { use super::*; + 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.update(cx, |chat, _| chat.sidebar.clone()); sidebar.update(cx, |sidebar, cx| { - sidebar.computers = Arc::new(vec![ - Computer::pending("east".into(), "East".into(), None), - Computer::pending("west".into(), "West".into(), None), - ]); + sidebar.reset_persisted_for_test(); sidebar.project_filter = Some("/tmp/proj".into()); sidebar.application_vim_enabled = true; - sidebar.list = gpui::ListState::new(0, gpui::ListAlignment::Top, px(0.)); let sessions = (0..102) .map(|ix| AgentSessionSummary { id: format!("s{ix}"), @@ -4285,6 +4314,8 @@ mod tests { .collect(); sidebar.pinned_tasks = (0..100).map(|ix| format!("s{ix}")).collect(); sidebar.set_sessions(sessions, cx); + sidebar.list = + gpui::ListState::new(sidebar.entries.len(), gpui::ListAlignment::Top, px(0.)); }); let list = sidebar.update(cx, |sidebar, _| sidebar.list.clone()); struct Host(Entity); diff --git a/apps/maple-agent/app/src/ui/chat/sidebar_catalog.rs b/apps/maple-agent/app/src/ui/chat/sidebar_catalog.rs index c46839c31..c87891d91 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_catalog.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_catalog.rs @@ -463,10 +463,10 @@ impl Reader { if state.generation != self.generation { return; } - if let Some(index) = state.cache.iter().position(|entry| entry.scope == scope) { - if let Some(previous) = state.cache.remove(index) { - state.cache_bytes = state.cache_bytes.saturating_sub(previous.bytes); - } + if let Some(index) = state.cache.iter().position(|entry| entry.scope == scope) + && let Some(previous) = state.cache.remove(index) + { + state.cache_bytes = state.cache_bytes.saturating_sub(previous.bytes); } while state.cache.len() >= MAX_CACHE_SCOPES || state.cache_bytes.saturating_add(bytes) > MAX_CACHE_BYTES @@ -514,6 +514,15 @@ impl Reader { encode_cursor(scope, after).map(Some) } + async fn scan_slot(&self) -> Result { + let slots = self.owner()?.scan_slots.clone(); + tokio::select! { + biased; + _ = self.pending.cancelled() => Err(CANCELLED.into()), + permit = slots.acquire_owned() => permit.map_err(|_| CANCELLED.to_string()), + } + } + pub(super) async fn page( self, backend: Arc, @@ -544,12 +553,7 @@ impl Reader { self.authorize(&backend, &user)?; return candidates.finish(scope, after, limit); } - let slots = self.owner()?.scan_slots.clone(); - let _permit = tokio::select! { - biased; - _ = self.pending.cancelled() => return Err(CANCELLED.into()), - permit = slots.acquire_owned() => permit.map_err(|_| CANCELLED.to_string())?, - }; + let _permit = self.scan_slot().await?; // A preceding scan may have completed while this reader waited. if let Some(tasks) = self.cached(&backend, inventory_scope)? { for task in tasks.iter() { @@ -633,3 +637,41 @@ impl Reader { candidates.finish(scope, after, limit) } } + +#[cfg(test)] +mod cancellation_tests { + use super::*; + + #[tokio::test] + async fn cancelling_a_reload_releases_queued_scans_before_a_slot_opens() { + let catalog = Catalog::default(); + let first = catalog.reader().scan_slot().await.unwrap(); + let second = catalog.reader().scan_slot().await.unwrap(); + let queued_reader = catalog.reader(); + let mut queued = Box::pin(queued_reader.scan_slot()); + let pending = std::future::poll_fn(|cx| { + std::task::Poll::Ready(std::future::Future::poll(queued.as_mut(), cx).is_pending()) + }) + .await; + assert!(pending, "both scan slots are occupied"); + catalog.cancel_pending(); + let result = tokio::time::timeout(std::time::Duration::from_secs(1), queued) + .await + .expect("cancelled queued scan must not wait for an old slot"); + assert!(matches!(result, Err(message) if message == CANCELLED)); + drop((first, second)); + assert!(catalog.reader().scan_slot().await.is_ok()); + } + + #[test] + fn replacing_a_reload_cancels_active_and_queued_readers() { + let catalog = Catalog::default(); + let active = catalog.reader(); + let queued = catalog.reader(); + catalog.cancel_pending(); + catalog.invalidate(); + assert!(matches!(active.owner(), Err(message) if message == CANCELLED)); + assert!(matches!(queued.owner(), Err(message) if message == CANCELLED)); + assert!(catalog.reader().owner().is_ok()); + } +} 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 2c2731a92..2f63b1669 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs @@ -18,9 +18,45 @@ pub(super) struct ComputerPages { scope: Option<(String, Option, bool)>, hosts: HashMap, ComputerHostPage>, more: Vec, + revalidation_pending: bool, } impl ComputerPages { + /// Manual order starts from every loaded summary, including folded groups. + pub(super) fn task_order_seeds(&self) -> Vec<(SidebarItemKey, i64, i64, bool)> { + self.hosts + .iter() + .flat_map(|(computer, page)| { + page.groups + .iter() + .flat_map(|group| group.tasks.iter()) + .chain(page.active.iter()) + .chain(page.settled.iter()) + .chain(page.search.iter()) + .chain(page.archived.iter()) + .map(|task| { + ( + SidebarItemKey { + computer: computer.clone(), + id: task.uuid.clone(), + }, + task.created_ms, + task.updated_ms, + task.pinned_ms.is_some(), + ) + }) + }) + .collect() + } + + fn manual_reads_pending(&self) -> bool { + self.hosts.values().any(|page| { + page.loading + .iter() + .any(|key| key != "archive-count" && key != "projects") + }) + } + pub(super) fn project_summaries(&self, computer: &Option) -> Option<&[ProjectSummary]> { self.hosts .get(computer) @@ -442,6 +478,42 @@ pub(super) struct OtherTask { } impl Sidebar { + /// View fixtures supply bounded, verified owner snapshots rather than + /// asynchronous pages from the fixtures' deliberately empty stores. + #[cfg(test)] + pub(crate) fn seed_computer_snapshot_pages_for_test(&mut self) { + self.group_pages.generation = self.group_pages.generation.wrapping_add(1); + self.manual_catalog.cancel_pending(); + self.group_pages.scope = Some(( + self.filter.clone(), + self.project_filter.clone(), + self.archived_expanded, + )); + self.group_pages.hosts = self + .computers + .iter() + .map(|computer| { + assert!(computer.backend.is_some(), "fixture needs a verified owner"); + let mut page = snapshot_page(computer, &self.filter, self.archived_expanded); + page.loaded.extend( + [ + "archive-count", + "projects", + "groups", + "tasks:false", + "tasks:true", + "search", + "archived", + ] + .into_iter() + .map(str::to_owned), + ); + (computer.key.clone(), page) + }) + .collect(); + self.rebuild_sections(); + } + fn computer_task_pin(&self, computer: &Computer, task: &TaskSummary) -> Option { if computer.key.is_none() { return task.pinned_ms; @@ -555,20 +627,33 @@ impl Sidebar { .contains(&self.filter) } - pub(super) fn uses_computer_projection(&self) -> bool { + pub(super) fn has_added_computer(&self) -> bool { self.computers.iter().any(|computer| computer.key.is_some()) } + pub(super) fn uses_computer_projection(&self) -> bool { + // Project navigation reads the owning host's scoped task pages. The + // cross-computer pages represent the unfiltered sidebar instead. + self.project_filter.is_none() && self.has_added_computer() + } + pub(in crate::ui::chat) fn set_computers( &mut self, computers: Arc>, cx: &mut Context, ) { - let scope_changed = self.uses_computer_projection() + let scope_changed = self.has_added_computer() && !computers.iter().any(|computer| computer.key.is_some()) && self.project_filter.take().is_some(); let changed = computer_revision(&self.computers) != computer_revision(&computers) || existing_snapshot_changed(&self.computers, &computers); + let owners_changed = self.computers.len() != computers.len() + || self.computers.iter().any(|old| { + computers + .iter() + .find(|new| old.key == new.key) + .is_none_or(|new| !same_computer_owner(old, new)) + }); if changed { self.group_pages.hosts.retain(|key, _| { self.computers @@ -589,7 +674,11 @@ impl Sidebar { if scope_changed && self.pages.enabled { self.refresh_pages(cx); } else if changed { - self.revalidate_computer_pages(cx); + if owners_changed { + self.start_computer_revalidation(cx); + } else { + self.revalidate_computer_pages(cx); + } } else { self.ensure_computer_pages(cx); } @@ -649,9 +738,11 @@ impl Sidebar { } true }); + self.manual_catalog.cancel_pending(); if inventory_changed { self.manual_catalog.invalidate(); } + self.group_pages.revalidation_pending = false; self.group_pages.hosts = state.pages.hosts; self.group_pages.scope = Some(scope); self.group_pages.more.clear(); @@ -696,6 +787,25 @@ impl Sidebar { } pub(super) fn revalidate_computer_pages(&mut self, cx: &mut Context) { + // Let the current Manual snapshot finish. Repeated activity only asks + // for one fresh trailing snapshot, rather than repeatedly cancelling + // useful work or filling the catalog's two scan slots with stale work. + if self.view.sort == SidebarSort::Manual && self.group_pages.manual_reads_pending() { + self.group_pages.revalidation_pending = true; + return; + } + self.start_computer_revalidation(cx); + } + + fn finish_computer_revalidation(&mut self, cx: &mut Context) { + if self.group_pages.revalidation_pending && !self.group_pages.manual_reads_pending() { + self.start_computer_revalidation(cx); + } + } + + fn start_computer_revalidation(&mut self, cx: &mut Context) { + self.group_pages.revalidation_pending = false; + self.manual_catalog.cancel_pending(); self.manual_catalog.invalidate(); self.group_pages.generation = self.group_pages.generation.wrapping_add(1); for page in self.group_pages.hosts.values_mut() { @@ -705,6 +815,8 @@ impl Sidebar { } pub(super) fn refresh_computer_pages(&mut self, cx: &mut Context) { + self.manual_catalog.cancel_pending(); + self.group_pages.revalidation_pending = false; self.group_pages.generation = self.group_pages.generation.wrapping_add(1); // Ordering edits invalidate cursors, not the summaries already loaded // beyond page one. A scope/owner change still discards them separately. @@ -728,6 +840,8 @@ impl Sidebar { self.archived_expanded, ); if self.group_pages.scope.as_ref() != Some(&scope) { + self.manual_catalog.cancel_pending(); + self.group_pages.revalidation_pending = false; self.group_pages.generation = self.group_pages.generation.wrapping_add(1); self.group_pages.hosts.clear(); self.group_pages.scope = Some(scope); @@ -1019,6 +1133,7 @@ impl Sidebar { } Err(error) => { page.errors.insert(key, error); + self.finish_computer_revalidation(cx); self.rebuild_entries(); cx.notify(); return; @@ -1145,6 +1260,8 @@ impl Sidebar { self.remember_task_project_labels(own_labels); self.merge_page_sessions(own_tasks); } + self.load_missing_group_states(cx); + self.finish_computer_revalidation(cx); self.rebuild_computer_project_roots(); self.rebuild_entries(); self.list.remeasure_items(0..self.entries.len()); @@ -1336,8 +1453,15 @@ impl Sidebar { right: &Option, b: &TaskSummary, ) -> std::cmp::Ordering { - self.task_order_key(left, &a.uuid, a.created_ms, a.updated_ms) - .cmp(&self.task_order_key(right, &b.uuid, b.created_ms, b.updated_ms)) + let key = |computer, task: &TaskSummary| { + if task.state != "archived" && task.pinned_ms.is_some() { + self.pinned_task_order_key(computer, &task.uuid, task.created_ms, task.updated_ms) + } else { + self.task_order_key(computer, &task.uuid, task.created_ms, task.updated_ms) + } + }; + key(left, a) + .cmp(&key(right, b)) .then_with(|| left.cmp(right)) .then_with(|| a.uuid.cmp(&b.uuid)) } @@ -1353,6 +1477,165 @@ impl Sidebar { }); } + fn computer_group_prefix( + &self, + computer: &Computer, + group: &NavigationProjectGroup, + ) -> prefix::Prefix { + let page = self.group_pages.hosts.get(&computer.key); + // The continuation belongs to the received snapshot, before live state + // changes or Pinned extraction alter its presentation. + let source = page + .and_then(|page| { + page.groups + .iter() + .find(|stored| stored.project.root == group.project.root) + }) + .map_or(group.tasks.as_slice(), |stored| stored.tasks.as_slice()); + prefix::Prefix::new( + [false, true].map(|settled| { + let key = ComputerPageKind::Project { + root: group.project.root.clone(), + settled, + } + .key(); + prefix::Stream { + source: settled, + end: source + .iter() + .filter(|task| (task.state == "settled") == settled) + .map(|task| { + self.task_prefix_position( + &computer.key, + &task.uuid, + task.created_ms, + task.updated_ms, + ) + }) + .max(), + complete: (if settled { + group.more.settled.is_none() + } else { + group.more.active.is_none() + }) && page.is_none_or(|page| { + !page.loading.contains(&key) && !page.errors.contains_key(&key) + }), + } + }), + self.view.sort == SidebarSort::Manual, + ) + } + + fn computer_flat_prefix( + &self, + hosts: &[usize], + state: Option, + ) -> prefix::Prefix<(usize, bool)> { + let streams = hosts.iter().flat_map(|&index| { + let computer = &self.computers[index]; + let page = self.group_pages.hosts.get(&computer.key); + [false, true] + .into_iter() + .filter(move |settled| { + state.is_none_or(|state| state == *settled) && computer.backend.is_some() + }) + .map(move |settled| { + let kind = ComputerPageKind::Tasks { settled }; + let key = kind.key(); + let tasks = page.map(|page| if settled { &page.settled } else { &page.active }); + prefix::Stream { + source: (index, settled), + end: tasks + .into_iter() + .flatten() + .map(|task| { + self.task_prefix_position( + &computer.key, + &task.uuid, + task.created_ms, + task.updated_ms, + ) + }) + .max(), + complete: page.is_some_and(|page| { + !page.loading.contains(&key) + && !page.errors.contains_key(&key) + && if settled { + page.next_settled.is_none() + } else { + page.next_active.is_none() + } + }), + } + }) + }); + prefix::Prefix::new(streams, self.view.sort == SidebarSort::Manual) + } + + fn prefix_exempt_task(&self, computer: &Computer, task: &TaskSummary) -> bool { + task.pinned_ms.is_some() + || computer.running.contains(&task.uuid) + || (computer.key.as_deref() == self.backend.remote_host() + && (self.selected.as_ref() == Some(&task.uuid) + || self.running.contains(&task.uuid) + || self.pages.is_live(&task.uuid))) + } + + /// A group first page can spend its budget on just Active tasks. Read the + /// missing Settled prefix before presenting a merged list; zero-budget + /// groups retain their explicit More action instead of causing background + /// scans of projects past the visible row budget. + fn load_missing_group_states(&mut self, cx: &mut Context) { + if !self.filter.is_empty() + || matches!(self.view.group, SidebarGroup::Status | SidebarGroup::List) + { + return; + } + let mut actions = Vec::new(); + for computer in self + .computers + .iter() + .filter(|computer| computer.backend.is_some()) + { + let Some(page) = self.group_pages.hosts.get(&computer.key) else { + continue; + }; + for group in page.groups.iter().filter(|group| !group.tasks.is_empty()) { + for settled in [false, true] { + let cursor = if settled { + &group.more.settled + } else { + &group.more.active + }; + let kind = ComputerPageKind::Project { + root: group.project.root.clone(), + settled, + }; + if cursor.is_some() + && !page.loading.contains(&kind.key()) + && !page.errors.contains_key(&kind.key()) + && !group + .tasks + .iter() + .any(|task| (task.state == "settled") == settled) + { + actions.push(ComputerMore { + computer: computer.key.clone(), + generation: self.group_pages.generation, + kind, + cursor: cursor.clone(), + label: "Loading tasks…".into(), + loading: false, + }); + } + } + } + } + for action in actions { + self.load_computer_page(action, cx); + } + } + fn push_computer_group( &mut self, entries: &mut Vec, @@ -1367,6 +1650,18 @@ impl Sidebar { if self.collapsed_projects.contains(&target) { return; } + let prefix = self.computer_group_prefix(computer, &group); + if self.filter.is_empty() { + group.tasks.retain(|task| { + self.prefix_exempt_task(computer, task) + || prefix.allows(&self.task_prefix_position( + &computer.key, + &task.uuid, + task.created_ms, + task.updated_ms, + )) + }); + } group .tasks .sort_by(|a, b| self.compare_computer_tasks(&computer.key, a, &computer.key, b)); @@ -1375,6 +1670,9 @@ impl Sidebar { } if self.filter.is_empty() { for (settled, cursor) in [(false, group.more.active), (true, group.more.settled)] { + if prefix.next.first() != Some(&settled) { + continue; + } self.computer_more_entry( entries, &computer.key, @@ -1383,11 +1681,7 @@ impl Sidebar { settled, }, cursor, - if settled { - "more settled tasks" - } else { - "more active tasks" - }, + "more tasks", ); } } @@ -1514,6 +1808,30 @@ impl Sidebar { } } } + let flat_prefix = self.computer_flat_prefix(&hosts, None); + let active_prefix = self.computer_flat_prefix(&hosts, Some(false)); + let settled_prefix = self.computer_flat_prefix(&hosts, Some(true)); + if flat && !searching { + tasks.retain(|(index, task)| { + let computer = &computers[*index]; + let prefix = if self.view.group == SidebarGroup::Status { + if task.state == "settled" { + &settled_prefix + } else { + &active_prefix + } + } else { + &flat_prefix + }; + self.prefix_exempt_task(computer, task) + || prefix.allows(&self.task_prefix_position( + &computer.key, + &task.uuid, + task.created_ms, + task.updated_ms, + )) + }); + } // Pins are a desktop view across computers, above every grouping. // Keep the task's original project and saved order for unpinning. let mut pinned = Vec::new(); @@ -1602,8 +1920,13 @@ impl Sidebar { self.push_computer_task(&mut entries, &computers[*index], task); } if !searching { - for &index in &hosts { - self.push_computer_flat_more(&mut entries, index, Some(settled)); + let prefix = if settled { + &settled_prefix + } else { + &active_prefix + }; + if let Some(&(index, state)) = prefix.next.first() { + self.push_computer_flat_more(&mut entries, index, Some(state)); } } } @@ -1618,13 +1941,12 @@ impl Sidebar { for (index, task) in &tasks { self.push_computer_task(&mut entries, &computers[*index], task); } - for &index in &hosts { - if searching { + if searching { + for &index in &hosts { self.push_computer_flat_more(&mut entries, index, None); - } else { - self.push_computer_flat_more(&mut entries, index, Some(false)); - self.push_computer_flat_more(&mut entries, index, Some(true)); } + } else if let Some(&(index, state)) = flat_prefix.next.first() { + self.push_computer_flat_more(&mut entries, index, Some(state)); } entries.extend(unavailable.iter().copied().map(SidebarEntry::Computer)); } @@ -2096,6 +2418,13 @@ impl Sidebar { slot.child( div() .id("computer-disclosure") + .role(gpui::Role::Button) + .aria_label(if expanded { + "Collapse connection" + } else { + "Expand connection" + }) + .aria_expanded(expanded) .absolute() .top_0() .right(FOLDER_ICON_SIZE + px(3.)) @@ -2216,10 +2545,21 @@ mod paging_tests { user: &str, ) -> Entity { let chat = cx.new(|cx| ChatScreen::new_without_start(backend, user.into(), cx)); - chat.read_with(cx, |chat, _| chat.sidebar.clone()) + let sidebar = chat.read_with(cx, |chat, _| chat.sidebar.clone()); + sidebar.update(cx, |sidebar, _| { + sidebar.reset_persisted_for_test(); + sidebar.view = SidebarView { + group: SidebarGroup::Project, + sort: SidebarSort::Manual, + }; + }); + sidebar } fn seed_browse_pages(sidebar: &mut Sidebar) { + // These cache-transfer fixtures use exact desktop Manual cursors. The + // separate merged-prefix tests cover the host timestamp frontier. + sidebar.view.sort = SidebarSort::Manual; let mut local = computer(); local.backend = Some(sidebar.backend.clone()); local.sessions.clear(); @@ -2418,6 +2758,7 @@ mod paging_tests { source.update(cx, |sidebar, cx| { sidebar.filter = "different query".into(); sidebar.ensure_computer_pages(cx); + sidebar.rebuild_sections(); assert!( sidebar .group_pages @@ -2529,3 +2870,286 @@ mod paging_tests { assert_eq!(page.groups[0].tasks[0].uuid, "head"); } } + +#[cfg(test)] +mod revalidation_tests { + use super::*; + + #[gpui::test] + fn manual_activity_coalesces_into_one_trailing_snapshot(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.view.sort = SidebarSort::Manual; + sidebar.computers = Arc::new(Vec::new()); + sidebar.group_pages.scope = Some((String::new(), None, false)); + let generation = sidebar.group_pages.generation; + for key in [None, Some("remote".into())] { + sidebar.group_pages.hosts.insert( + key, + ComputerHostPage { + loading: HashSet::from(["groups".into()]), + ..Default::default() + }, + ); + } + for _ in 0..20 { + sidebar.revalidate_computer_pages(cx); + } + assert_eq!(sidebar.group_pages.generation, generation); + assert!(sidebar.group_pages.revalidation_pending); + let action = |computer| ComputerMore { + computer, + generation, + kind: ComputerPageKind::Groups, + cursor: None, + label: "Loading tasks…".into(), + loading: true, + }; + sidebar.apply_computer_page( + action(None), + Ok(ComputerPageReply::Groups(Default::default())), + cx, + ); + assert_eq!(sidebar.group_pages.generation, generation); + assert!(sidebar.group_pages.revalidation_pending); + sidebar.apply_computer_page( + action(Some("remote".into())), + Ok(ComputerPageReply::Groups(Default::default())), + cx, + ); + assert_eq!(sidebar.group_pages.generation, generation.wrapping_add(1)); + assert!(!sidebar.group_pages.revalidation_pending); + assert!(!sidebar.group_pages.manual_reads_pending()); + // An older callback cannot refill the replacement generation. + sidebar.apply_computer_page( + action(Some("remote".into())), + Err("stale failure".into()), + cx, + ); + assert!( + sidebar + .group_pages + .hosts + .values() + .all(|page| page.errors.is_empty()) + ); + }); + } + + #[test] + fn metadata_reads_do_not_delay_a_trailing_manual_snapshot() { + let pages = ComputerPages { + hosts: HashMap::from([( + None, + ComputerHostPage { + loading: HashSet::from(["projects".into(), "archive-count".into()]), + ..Default::default() + }, + )]), + ..Default::default() + }; + assert!(!pages.manual_reads_pending()); + } +} + +#[cfg(test)] +mod merged_prefix_tests { + use super::*; + + fn task(id: &str, state: &str, updated: i64) -> TaskSummary { + TaskSummary { + uuid: id.into(), + title: id.into(), + project_root: "/tmp/project".into(), + project_display_name: None, + state: state.into(), + kind: "desktop".into(), + pinned_ms: None, + web_enabled: false, + model: None, + message_count: 1, + created_ms: updated, + updated_ms: updated, + truncated: false, + } + } + + fn shown(sidebar: &Sidebar) -> Vec { + sidebar + .entries + .iter() + .filter_map(|entry| match entry { + SidebarEntry::Task(task) => Some(sidebar.sessions[task.session].id.clone()), + SidebarEntry::OtherTask(index) => { + let Destination::Task(id) = &sidebar.other_tasks[*index].request.destination + else { + return None; + }; + Some(id.clone()) + } + _ => None, + }) + .collect() + } + + #[gpui::test] + fn unread_remote_rows_do_not_fill_gaps_in_another_computers_list( + 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, _| { + sidebar.reset_persisted_for_test(); + sidebar.view = SidebarView { + group: SidebarGroup::List, + sort: SidebarSort::Updated, + }; + sidebar.selected = None; + sidebar.running.clear(); + let mut local = Computer::pending("local".into(), "This Computer".into(), None); + local.key = None; + local.backend = Some(sidebar.backend.clone()); + let mut remote = Computer::pending("remote".into(), "Remote".into(), None); + remote.backend = Some(Arc::new(AgentBackend::for_remote_test("remote").unwrap())); + sidebar.computers = Arc::new(vec![local, remote]); + let local_tasks = vec![ + task("local-900", "active", 900), + task("local-800", "active", 800), + ]; + sidebar.sessions = local_tasks.iter().map(session_from_summary).collect(); + sidebar.group_pages.hosts.insert( + None, + ComputerHostPage { + active: local_tasks, + ..Default::default() + }, + ); + let remote_key = Some("remote".into()); + sidebar.group_pages.hosts.insert( + remote_key.clone(), + ComputerHostPage { + active: vec![ + task("remote-1000", "active", 1000), + task("remote-950", "active", 950), + ], + next_active: Some("next".into()), + ..Default::default() + }, + ); + sidebar.rebuild_sections(); + let before = shown(sidebar); + assert_eq!(before, ["remote-1000"]); + assert_eq!(sidebar.group_pages.more.len(), 1); + assert_eq!(sidebar.group_pages.more[0].computer, remote_key); + assert!(matches!( + sidebar.group_pages.more[0].kind, + ComputerPageKind::Tasks { settled: false } + )); + let remote = sidebar.group_pages.hosts.get_mut(&remote_key).unwrap(); + remote.active.extend([ + task("remote-940", "active", 940), + task("remote-850", "active", 850), + ]); + remote.next_active = None; + sidebar.rebuild_sections(); + let after = shown(sidebar); + assert_eq!(&after[..before.len()], before); + assert_eq!( + after, + [ + "remote-1000", + "remote-950", + "remote-940", + "local-900", + "remote-850", + "local-800" + ] + ); + assert!(sidebar.group_pages.more.is_empty()); + }); + } + + #[gpui::test] + fn project_more_advances_only_the_state_limiting_its_merged_prefix( + 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, _| { + sidebar.reset_persisted_for_test(); + sidebar.view = SidebarView::default(); + sidebar.selected = None; + let mut remote = Computer::pending("remote".into(), "Remote".into(), None); + remote.backend = Some(Arc::new(AgentBackend::for_remote_test("remote").unwrap())); + sidebar.computers = Arc::new(vec![remote]); + let remote_key = Some("remote".into()); + let mut group = NavigationProjectGroup { + project: ProjectSummary { + root: "/tmp/project".into(), + display_name: None, + position: None, + last_used_ms: 110, + name_revision: 0, + is_default_workspace: false, + truncated: false, + }, + tasks: vec![ + task("active-80", "active", 80), + task("active-70", "active", 70), + task("settled-110", "settled", 110), + task("settled-100", "settled", 100), + ], + more: Default::default(), + }; + group.more.settled = Some("next".into()); + sidebar.group_pages.hosts.insert( + remote_key.clone(), + ComputerHostPage { + groups: vec![group], + ..Default::default() + }, + ); + sidebar.rebuild_sections(); + assert_eq!(shown(sidebar), ["settled-110"]); + assert_eq!(sidebar.group_pages.more.len(), 1); + assert!(matches!( + sidebar.group_pages.more[0].kind, + ComputerPageKind::Project { settled: true, .. } + )); + let group = &mut sidebar + .group_pages + .hosts + .get_mut(&remote_key) + .unwrap() + .groups[0]; + group.tasks.push(task("settled-90", "settled", 90)); + group.more.settled = None; + sidebar.rebuild_sections(); + assert_eq!( + shown(sidebar), + [ + "settled-110", + "settled-100", + "settled-90", + "active-80", + "active-70" + ] + ); + assert!(sidebar.group_pages.more.is_empty()); + }); + } +} diff --git a/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs b/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs index 6e0db2f5f..bb31f49e1 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs @@ -30,7 +30,7 @@ impl ReorderRow { impl Sidebar { pub(super) fn connection_grouping_available(&self) -> bool { - self.uses_computer_projection() + self.has_added_computer() } pub(super) fn load_order(&mut self, cx: &mut Context) { @@ -85,6 +85,23 @@ impl Sidebar { ); } + pub(super) fn forget_task_order(&mut self, ids: &[String], cx: &mut Context) { + if !self.order_loaded { + return; + } + self.sync_cached_order(); + let computer = self.backend.remote_host(); + let keep = + |key: &SidebarItemKey| key.computer.as_deref() != computer || !ids.contains(&key.id); + let before = self.order.tasks.len() + self.order.pinned_tasks.len(); + self.order.tasks.retain(keep); + self.order.pinned_tasks.retain(keep); + if self.order.tasks.len() + self.order.pinned_tasks.len() != before { + self.rebuild_order_ranks(); + self.save_order(cx); + } + } + pub(super) fn rebuild_order_ranks(&mut self) { self.task_ranks.clear(); for (rank, task) in self.order.tasks.iter().enumerate() { @@ -93,6 +110,22 @@ impl Sidebar { .or_default() .insert(task.id.clone(), rank); } + self.pinned_task_ranks.clear(); + for (rank, task) in self.order.pinned_tasks.iter().enumerate() { + self.pinned_task_ranks + .entry(task.computer.clone()) + .or_default() + .insert(task.id.clone(), rank); + } + // Older saved orders had one rank list. Keep their pinned placement + // until the user arranges Pinned independently for the first time. + for (rank, task) in self.order.tasks.iter().enumerate() { + self.pinned_task_ranks + .entry(task.computer.clone()) + .or_default() + .entry(task.id.clone()) + .or_insert(self.order.pinned_tasks.len() + rank); + } self.project_ranks.clear(); for (rank, project) in self.order.projects.iter().enumerate() { self.project_ranks @@ -126,15 +159,57 @@ impl Sidebar { &self, computer: &Option, ) -> Option { - (self.view.sort == SidebarSort::Manual && self.order_loaded).then(|| catalog::ManualOrder { - cutoff: self.order.created_before_ms, - tasks: self - .order - .tasks - .iter() - .filter(|task| &task.computer == computer) - .map(|task| task.id.clone()) - .collect(), + self.manual_order_for(computer, false) + } + + pub(super) fn local_pinned_manual_order( + &self, + computer: &Option, + ) -> Option { + self.manual_order_for(computer, true) + } + + pub(super) fn pinned_task_order_key( + &self, + computer: &Option, + id: &str, + created: i64, + updated: i64, + ) -> (u8, i64) { + if self.view.sort == SidebarSort::Updated { + return (0, updated.saturating_neg()); + } + catalog::manual_order_key( + self.pinned_task_ranks + .get(computer) + .and_then(|ranks| ranks.get(id)) + .copied(), + created, + self.order.created_before_ms, + ) + } + + fn manual_order_for( + &self, + computer: &Option, + pinned: bool, + ) -> Option { + (self.view.sort == SidebarSort::Manual && self.order_loaded).then(|| { + let mut seen = HashSet::new(); + let preferred = if pinned { + self.order.pinned_tasks.as_slice() + } else { + &[] + }; + catalog::ManualOrder { + cutoff: self.order.created_before_ms, + tasks: preferred + .iter() + .chain(&self.order.tasks) + .filter(|task| &task.computer == computer && seen.insert(task.id.clone())) + .map(|task| task.id.clone()) + .collect(), + } }) } @@ -152,6 +227,7 @@ impl Sidebar { { if self.view.sort == SidebarSort::Manual && (self.order.tasks != order.tasks + || self.order.pinned_tasks != order.pinned_tasks || self.order.created_before_ms != order.created_before_ms) { self.order_query_dirty = true; @@ -221,7 +297,7 @@ impl Sidebar { } } - pub(super) fn set_view(&mut self, mut view: SidebarView, cx: &mut Context) { + pub(crate) fn set_view(&mut self, mut view: SidebarView, cx: &mut Context) { self.sync_cached_order(); // Changing task order must preserve a saved Connection grouping while // the desktop is still loading its paired-computer inventory. @@ -451,7 +527,7 @@ impl Sidebar { ( SidebarDragItem::Task(self.reorder_rows.get(row)?.task.clone()?), session.title.clone().into(), - "message-square", + "square-pen", ) } SidebarEntry::OtherTask(index) => { @@ -459,7 +535,7 @@ impl Sidebar { ( SidebarDragItem::Task(self.reorder_rows.get(row)?.task.clone()?), task.session.title.clone().into(), - "message-square", + "square-pen", ) } _ => return None, @@ -553,43 +629,121 @@ impl Sidebar { true } - fn seed_task_order(&mut self) { + fn loaded_task_order_seeds(&self) -> HashMap { let computer = self.backend.remote_host().map(str::to_owned); - let mut seen = HashSet::new(); - let visible = self - .entries - .iter() - .filter_map(|entry| match *entry { - SidebarEntry::Task(task) => { - self.sessions - .get(task.session) - .map(|session| SidebarItemKey { - computer: computer.clone(), - id: session.id.clone(), + let mut loaded = HashMap::::new(); + for (key, created, updated, pinned) in self.group_pages.task_order_seeds() { + loaded + .entry(key) + .and_modify(|(_, known, _)| *known = (*known).max(updated)) + .or_insert((created, updated, pinned)); + } + for host in self.computers.iter() { + for task in &host.sessions { + if task.message_count > 0 || host.running.contains(&task.id) { + loaded + .entry(SidebarItemKey { + computer: host.key.clone(), + id: task.id.clone(), }) + .and_modify(|(_, known, _)| *known = (*known).max(task.updated_ms)) + .or_insert(( + task.created_ms, + task.updated_ms, + host.pinned.contains(&task.id), + )); } - SidebarEntry::OtherTask(index) => { - self.other_tasks.get(index).map(|task| SidebarItemKey { - computer: task.request.computer.clone(), - id: task.session.id.clone(), + } + } + for task in self.sessions.iter() { + if task.message_count > 0 || self.running.contains(&task.id) { + loaded + .entry(SidebarItemKey { + computer: computer.clone(), + id: task.id.clone(), }) - } - _ => None, - }) - .filter(|key| seen.insert(key.clone())) + .and_modify(|(_, known, _)| *known = (*known).max(task.updated_ms)) + .or_insert(( + task.created_ms, + task.updated_ms, + self.pinned_tasks.contains(&task.id), + )); + } + } + for (key, (_, _, pinned)) in &mut loaded { + *pinned = if key.computer == computer { + self.pinned_tasks.contains(&key.id) + } else { + self.computers + .iter() + .find(|host| host.key == key.computer) + .is_some_and(|host| host.pinned.contains(&key.id)) + }; + } + loaded + } + + fn seed_task_order(&mut self) { + let mut loaded = self + .loaded_task_order_seeds() + .into_iter() + .collect::>(); + loaded.sort_by(|(a, (_, updated_a, _)), (b, (_, updated_b, _))| { + updated_b + .cmp(updated_a) + .then_with(|| a.computer.cmp(&b.computer)) + .then_with(|| a.id.cmp(&b.id)) + }); + let visible = loaded + .iter() + .map(|(key, _)| key.clone()) .collect::>(); + let seen = visible.iter().collect::>(); let remaining = self .order .tasks .iter() - .filter(|key| !seen.contains(*key)) + .filter(|key| !seen.contains(key)) .cloned() .collect::>(); self.order.tasks = visible.into_iter().chain(remaining).collect(); + self.order.pinned_tasks = loaded + .into_iter() + .filter(|(_, (_, _, pinned))| *pinned) + .map(|(key, _)| key) + .collect(); self.order.created_before_ms = crate::settings::timestamp_ms(); self.rebuild_order_ranks(); } + fn materialize_loaded_task_order(&mut self) { + let known = self.order.tasks.iter().collect::>(); + let mut missing = self + .loaded_task_order_seeds() + .into_iter() + .filter(|(key, _)| !known.contains(key)) + .collect::>(); + missing.sort_by(|(a, (created_a, _, _)), (b, (created_b, _, _))| { + catalog::manual_order_key(None, *created_a, self.order.created_before_ms) + .cmp(&catalog::manual_order_key( + None, + *created_b, + self.order.created_before_ms, + )) + .then_with(|| a.computer.cmp(&b.computer)) + .then_with(|| a.id.cmp(&b.id)) + }); + let split = + missing.partition_point(|(_, (created, _, _))| *created > self.order.created_before_ms); + let older = missing.split_off(split); + self.order.tasks = missing + .into_iter() + .map(|(key, _)| key) + .chain(std::mem::take(&mut self.order.tasks)) + .chain(older.into_iter().map(|(key, _)| key)) + .collect(); + } + pub(super) fn commit_reorder( &mut self, drag: &SidebarDrag, @@ -618,6 +772,9 @@ impl Sidebar { if task_move && self.order.created_before_ms == 0 { self.seed_task_order(); } + if task_move && drag.group != Some(TaskGroup::Pinned) { + self.materialize_loaded_task_order(); + } match (&drag.item, target) { (SidebarDragItem::Project(source), SidebarDragItem::Project(target)) => { let source = SidebarItemKey { @@ -634,15 +791,22 @@ impl Sidebar { move_item(&mut self.order.connections, source, target, after) } (SidebarDragItem::Task(source), SidebarDragItem::Task(target)) => { - let mut desired = sequence + let visible = sequence .iter() .filter_map(|item| match item { SidebarDragItem::Task(key) => Some(key.clone()), _ => None, }) .collect::>(); - move_item(&mut desired, source, target, after); - self.order.tasks = merge_task_subset(&self.order.tasks, &desired); + let saved = if drag.group == Some(TaskGroup::Pinned) { + &mut self.order.pinned_tasks + } else { + &mut self.order.tasks + }; + include_unranked_tasks(saved, &visible); + // Moving one row must not swap the other members of its + // visible group through unrelated projects' global slots. + move_item(saved, source, target, after); self.view.sort = SidebarSort::Manual; } _ => return, @@ -744,33 +908,239 @@ fn move_item(items: &mut Vec, source: &T, target: &T, after: bo fn order_fits(order: &SidebarOrder) -> bool { order.tasks.len() <= 8192 + && order.pinned_tasks.len() <= 8192 && order.projects.len() <= 8192 && order.connections.len() <= 256 && serde_json::to_vec(order).is_ok_and(|json| json.len() <= 2 * 1024 * 1024) } -/// Replace only the visible group's slots. Other groups and folded/unloaded -/// tasks keep their relative arrangement, even after switching grouping views. -fn merge_task_subset(saved: &[SidebarItemKey], desired: &[SidebarItemKey]) -> Vec { - let subset = desired.iter().collect::>(); - let mut slots = saved.iter().filter(|key| subset.contains(key)).count(); - if slots == 0 { - return desired.iter().chain(saved).cloned().collect(); - } - let mut remaining = desired.iter(); - let mut result = Vec::with_capacity(saved.len() + desired.len()); - for key in saved { - if subset.contains(key) { - if let Some(next) = remaining.next() { - result.push(next.clone()); +/// Materialize new rows in their current relative positions before moving one. +/// Already ranked rows retain their positions, including hidden group members. +fn include_unranked_tasks(saved: &mut Vec, visible: &[SidebarItemKey]) { + for (index, key) in visible.iter().enumerate() { + if saved.contains(key) { + continue; + } + let next = visible[index + 1..] + .iter() + .find_map(|next| saved.iter().position(|saved| saved == next)); + let previous = visible[..index] + .iter() + .rev() + .find_map(|previous| saved.iter().position(|saved| saved == previous)); + let at = next + .or_else(|| previous.map(|previous| previous + 1)) + .unwrap_or(0); + saved.insert(at, key.clone()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(id: &str) -> SidebarItemKey { + SidebarItemKey { + computer: None, + id: id.into(), + } + } + + fn task(id: &str, root: &str, updated_ms: i64) -> AgentSessionSummary { + AgentSessionSummary { + id: id.into(), + title: id.into(), + project_root: root.into(), + created_ms: 1, + updated_ms, + message_count: 1, + model: None, + web_enabled: true, + state: AgentTaskState::Active, + acp: false, + } + } + + #[test] + fn moving_one_group_member_preserves_all_other_tasks_relative_order() { + let mut saved = ["a1", "other1", "a2", "other2", "a3"].map(key).to_vec(); + include_unranked_tasks(&mut saved, &[key("a1"), key("a2"), key("a3")]); + move_item(&mut saved, &key("a3"), &key("a1"), false); + assert_eq!(saved, ["a3", "a1", "other1", "a2", "other2"].map(key)); + } + + #[test] + fn moving_a_new_task_keeps_unranked_neighbors_and_hidden_tasks_in_order() { + let mut saved = ["old1", "hidden", "old2"].map(key).to_vec(); + include_unranked_tasks( + &mut saved, + &[key("new2"), key("new1"), key("old1"), key("old2")], + ); + move_item(&mut saved, &key("new2"), &key("old2"), true); + assert_eq!(saved, ["new1", "old1", "hidden", "old2", "new2"].map(key)); + } + + #[gpui::test] + fn pinned_drag_preserves_the_tasks_ordinary_slots(cx: &mut gpui::TestAppContext) { + let _guard = crate::settings::SETTINGS_IO_LOCK.lock(); + crate::settings::install_test_settings(crate::settings::AppSettings::default()); + 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.update(cx, |chat, _| chat.sidebar.clone()); + sidebar.update(cx, |sidebar, cx| { + sidebar.reset_persisted_for_test(); + sidebar.view = SidebarView { + group: SidebarGroup::List, + sort: SidebarSort::Manual, + }; + sidebar.order.created_before_ms = 100; + sidebar.order.tasks = ["a", "b", "c", "d"].map(key).to_vec(); + sidebar.pinned_tasks = vec!["a".into(), "c".into()]; + sidebar.rebuild_order_ranks(); + sidebar.set_sessions( + vec![ + task("a", "/a", 4), + task("b", "/a", 3), + task("c", "/b", 2), + task("d", "/b", 1), + ], + cx, + ); + let ordinary = sidebar.order.tasks.clone(); + let drag = SidebarDrag { + sidebar: cx.entity_id(), + item: SidebarDragItem::Task(key("c")), + group: Some(TaskGroup::Pinned), + name: "c".into(), + icon: "square-pen", + }; + sidebar.commit_reorder(&drag, &SidebarDragItem::Task(key("a")), false, cx); + assert_eq!(sidebar.order.tasks, ordinary); + assert_eq!(sidebar.order.pinned_tasks, [key("c"), key("a")]); + assert!( + sidebar.pinned_task_order_key(&None, "c", 1, 2) + < sidebar.pinned_task_order_key(&None, "a", 1, 4) + ); + sidebar.archived_expanded = true; + for task in &mut sidebar.sessions { + if task.id == "a" || task.id == "c" { + task.state = AgentTaskState::Archived; + } } - slots -= 1; - if slots == 0 { - result.extend(remaining.by_ref().cloned()); + sidebar.rebuild_sections(); + let archived = sidebar + .entries + .iter() + .filter_map(|entry| match entry { + SidebarEntry::Task(row) if row.archived => { + Some(sidebar.sessions[row.session].id.as_str()) + } + _ => None, + }) + .collect::>(); + assert_eq!(archived, ["a", "c"]); + for task in &mut sidebar.sessions { + task.state = AgentTaskState::Active; } - } else { - result.push(key.clone()); - } + sidebar.pinned_tasks.clear(); + sidebar.rebuild_sections(); + let unpinned = sidebar + .entries + .iter() + .filter_map(|entry| match entry { + SidebarEntry::Task(row) => Some(sidebar.sessions[row.session].id.as_str()), + _ => None, + }) + .collect::>(); + assert_eq!(unpinned, ["a", "b", "c", "d"]); + // Deleting a local task releases its saved slots, but a task with + // the same identifier on another computer remains independent. + let remote = SidebarItemKey { + computer: Some("remote".into()), + id: "c".into(), + }; + sidebar.order.tasks.push(remote.clone()); + sidebar.save_order(cx); + sidebar.forget_tasks(&["c".into()], cx); + assert_eq!(sidebar.order.tasks, [key("a"), key("b"), key("d"), remote]); + assert_eq!(sidebar.order.pinned_tasks, [key("a")]); + }); + crate::settings::install_test_settings(crate::settings::AppSettings::default()); + } + + #[gpui::test] + fn initial_manual_order_includes_tasks_in_folded_projects(cx: &mut gpui::TestAppContext) { + let _guard = crate::settings::SETTINGS_IO_LOCK.lock(); + crate::settings::install_test_settings(crate::settings::AppSettings::default()); + 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.update(cx, |chat, _| chat.sidebar.clone()); + sidebar.update(cx, |sidebar, cx| { + sidebar.reset_persisted_for_test(); + sidebar.view.group = SidebarGroup::Project; + sidebar.collapsed_projects.insert(ProjectTarget { + computer: None, + path: "/folded".into(), + }); + sidebar.set_sessions( + vec![ + task("older", "/folded", 10), + task("newer", "/folded", 30), + task("visible", "/open", 20), + ], + cx, + ); + sidebar.seed_task_order(); + assert_eq!( + sidebar.order.tasks, + [key("newer"), key("visible"), key("older")] + ); + + sidebar.reset_persisted_for_test(); + sidebar.view = SidebarView { + group: SidebarGroup::Project, + sort: SidebarSort::Manual, + }; + sidebar.order.created_before_ms = 100; + sidebar.order.tasks = [key("old-a"), key("old-b")].to_vec(); + sidebar.collapsed_projects.insert(ProjectTarget { + computer: None, + path: "/b".into(), + }); + let mut new_a = task("new-a", "/a", 300); + new_a.created_ms = 300; + let mut new_b = task("new-b", "/b", 200); + new_b.created_ms = 200; + sidebar.rebuild_order_ranks(); + sidebar.set_sessions( + vec![ + new_a, + new_b, + task("old-a", "/a", 50), + task("old-b", "/b", 40), + ], + cx, + ); + let drag = SidebarDrag { + sidebar: cx.entity_id(), + item: SidebarDragItem::Task(key("old-a")), + group: Some(TaskGroup::Project(ProjectTarget { + computer: None, + path: "/a".into(), + })), + name: "old-a".into(), + icon: "square-pen", + }; + sidebar.commit_reorder(&drag, &SidebarDragItem::Task(key("new-a")), false, cx); + assert_eq!( + sidebar.order.tasks, + [key("old-a"), key("new-a"), key("new-b"), key("old-b")] + ); + }); + crate::settings::install_test_settings(crate::settings::AppSettings::default()); } - result } 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 b5d3f2afa..dd6533316 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_paging.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_paging.rs @@ -220,6 +220,14 @@ mod tests { }], false, ); + // The Active response is the only nonempty page in this + // scoped fixture. Confirm the other task states empty before + // inspecting the merged Project view's stable prefix. + sidebar.pages.loading.clear(); + sidebar + .pages + .ready + .extend([PageKind::Active, PageKind::Settled, PageKind::Pinned]); sidebar.archived_expanded = true; sidebar.rebuild_sections(); let shown: Vec<_> = sidebar @@ -351,7 +359,11 @@ mod tests { false, ); sidebar.rebuild_sections(); - assert_eq!(sidebar.grouped_project_names(), ["b", "a"]); + assert_eq!( + sidebar.grouped_project_names(), + ["a", "b"], + "loading tasks does not reorder project containers" + ); sidebar.set_filter_for_test("a", cx); assert_eq!(sidebar.grouped_project_names(), ["a"]); sidebar.set_filter_for_test("unmatched", cx); @@ -378,6 +390,13 @@ mod tests { sidebar.selected = None; let row = |id: &str, root: &str| TaskSummary { project_root: root.into(), + updated_ms: match id { + "a1" => 40, + "a2" => 30, + "b1" => 20, + "c1" => 10, + _ => 1, + }, ..page_summary(id) }; sidebar.apply_task_page(PageKind::Pinned, vec![row("pin", "/a")], false); @@ -425,7 +444,8 @@ mod tests { .iter() .map(|(_, rows)| rows.len()) .sum::(), - 4 + 2, + "rows beyond the unread Settled frontier stay cached" ); let more = |sidebar: &Sidebar| { sidebar @@ -441,7 +461,6 @@ mod tests { more(sidebar), [ PageKind::Pinned, - PageKind::Active, PageKind::Settled, PageKind::Projects, PageKind::Archived @@ -465,8 +484,10 @@ mod tests { assert!(more(sidebar).contains(&PageKind::Settled)); sidebar.view.group = SidebarGroup::List; sidebar.rebuild_sections(); - assert!(more(sidebar).contains(&PageKind::Active)); - assert_eq!(sidebar.section_rows(SidebarSection::List).len(), 4); + assert_eq!(sidebar.pages.ids[&PageKind::Settled], ["b1"]); + assert!(more(sidebar).contains(&PageKind::Settled)); + assert!(!more(sidebar).contains(&PageKind::Active)); + assert_eq!(sidebar.section_rows(SidebarSection::List).len(), 2); }); } @@ -591,6 +612,20 @@ pub(super) struct SidebarPages { } impl SidebarPages { + pub(super) fn kind_contains(&self, kind: PageKind, id: &str) -> bool { + self.ids + .get(&kind) + .is_some_and(|ids| ids.iter().any(|current| current == id)) + } + + pub(super) fn kind_complete(&self, kind: PageKind) -> bool { + !self.has_more(kind) + } + + pub(super) fn is_live(&self, id: &str) -> bool { + self.live.contains_key(id) + } + pub(super) fn has_more(&self, kind: PageKind) -> bool { self.next.contains_key(&kind) || self.failed.contains(&kind) @@ -968,9 +1003,13 @@ impl Sidebar { let user = self.user_id.clone(); let project = self.project_filter.clone(); let filter = self.filter.clone(); - let manual = self - .local_manual_order(&self.backend.remote_host().map(str::to_owned)) - .map(|order| (self.manual_catalog.reader(), order)); + let computer = self.backend.remote_host().map(str::to_owned); + let manual = if kind == PageKind::Pinned { + self.local_pinned_manual_order(&computer) + } else { + self.local_manual_order(&computer) + } + .map(|order| (self.manual_catalog.reader(), order)); let search = if kind == PageKind::Search { if self.pages.search.is_none() { let roots = self @@ -1386,3 +1425,167 @@ impl Sidebar { self.load_project_page(cx); } } + +#[cfg(test)] +mod merged_prefix_tests { + use super::*; + + fn summary(id: &str, state: &str, updated: i64) -> TaskSummary { + TaskSummary { + uuid: id.into(), + title: id.into(), + project_root: "/tmp/project".into(), + project_display_name: None, + state: state.into(), + kind: "desktop".into(), + pinned_ms: None, + web_enabled: false, + model: None, + message_count: 1, + created_ms: updated, + updated_ms: updated, + truncated: false, + } + } + + fn shown(sidebar: &Sidebar) -> Vec { + sidebar + .entries + .iter() + .filter_map(|entry| match entry { + SidebarEntry::Task(task) => Some(sidebar.sessions[task.session].id.clone()), + _ => None, + }) + .collect() + } + + #[gpui::test] + fn loading_more_appends_to_the_merged_list_instead_of_filling_gaps( + 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, _| { + sidebar.reset_persisted_for_test(); + sidebar.view = SidebarView { + group: SidebarGroup::List, + sort: SidebarSort::Updated, + }; + sidebar.selected = None; + sidebar.running.clear(); + sidebar.computers = Arc::new(Vec::new()); + sidebar.pages.enabled = true; + for (kind, state, newest) in [ + (PageKind::Active, "active", 200), + (PageKind::Settled, "settled", 1000), + ] { + sidebar.apply_task_page( + kind, + (0..50) + .map(|ix| summary(&format!("{state}-{ix}"), state, newest - ix)) + .collect(), + false, + ); + sidebar.pages.next.insert(kind, "next".into()); + } + sidebar.rebuild_sections(); + let first = shown(sidebar); + assert_eq!(first.len(), 49); + assert!(first.iter().all(|id| id.starts_with("settled"))); + let more = sidebar + .entries + .iter() + .filter_map(|entry| match entry { + SidebarEntry::LoadMore(kind) => Some(*kind), + _ => None, + }) + .collect::>(); + assert_eq!(more, [PageKind::Settled]); + // A later Settled page belongs ahead of the fetched Active rows. + sidebar.apply_task_page( + PageKind::Settled, + (50..100) + .map(|ix| summary(&format!("settled-{ix}"), "settled", 1000 - ix)) + .collect(), + true, + ); + sidebar.pages.next.remove(&PageKind::Settled); + sidebar.rebuild_sections(); + let second = shown(sidebar); + assert_eq!(&second[..first.len()], first); + assert_eq!(second.len(), 149); + assert!(second[..100].iter().all(|id| id.starts_with("settled"))); + assert!( + sidebar + .entries + .contains(&SidebarEntry::LoadMore(PageKind::Active)) + ); + assert!( + !sidebar + .entries + .contains(&SidebarEntry::LoadMore(PageKind::Settled)) + ); + // Exhausting Active releases the complete remaining merged tail. + sidebar.apply_task_page( + PageKind::Active, + (50..100) + .map(|ix| summary(&format!("active-{ix}"), "active", 200 - ix)) + .collect(), + true, + ); + sidebar.pages.next.remove(&PageKind::Active); + sidebar.rebuild_sections(); + let third = shown(sidebar); + assert_eq!(&third[..second.len()], second); + assert_eq!(third.len(), 200); + }); + } + + #[gpui::test] + fn cursor_timestamp_ties_are_sorted_once_when_the_boundary_is_complete( + 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, _| { + sidebar.reset_persisted_for_test(); + sidebar.view = SidebarView { + group: SidebarGroup::Project, + sort: SidebarSort::Updated, + }; + sidebar.selected = None; + sidebar.running.clear(); + sidebar.computers = Arc::new(Vec::new()); + sidebar.pages.enabled = true; + sidebar.apply_task_page(PageKind::Active, vec![summary("z", "active", 100)], false); + sidebar.apply_task_page( + PageKind::Settled, + vec![summary("settled", "settled", 101)], + false, + ); + sidebar.pages.next.insert(PageKind::Active, "tie".into()); + sidebar.rebuild_sections(); + assert_eq!(shown(sidebar), ["settled"]); + sidebar.apply_task_page(PageKind::Active, vec![summary("a", "active", 100)], true); + sidebar.rebuild_sections(); + assert_eq!(shown(sidebar), ["settled"]); + sidebar.pages.next.remove(&PageKind::Active); + sidebar.rebuild_sections(); + assert_eq!(shown(sidebar), ["settled", "a", "z"]); + // A selected/running task remains reachable outside the prefix. + sidebar.pages.next.insert(PageKind::Settled, "more".into()); + sidebar.selected = Some("a".into()); + sidebar.running.insert("z".into()); + sidebar.rebuild_sections(); + assert_eq!(shown(sidebar), ["a", "z"]); + }); + } +} diff --git a/apps/maple-agent/app/src/ui/chat/sidebar_prefix.rs b/apps/maple-agent/app/src/ui/chat/sidebar_prefix.rs new file mode 100644 index 000000000..88d8b4333 --- /dev/null +++ b/apps/maple-agent/app/src/ui/chat/sidebar_prefix.rs @@ -0,0 +1,236 @@ +//! Merge independently paged task states without displaying rows beyond an +//! unread state's frontier. Cursors and summaries stay on the viewing desktop. +use super::*; + +pub(super) type Position = ((u8, i64), Option, String); + +pub(super) struct Stream { + pub(super) source: T, + pub(super) end: Option, + pub(super) complete: bool, +} + +pub(super) struct Prefix { + boundary: Option, + unknown: bool, + exact: bool, + pub(super) next: Vec, +} + +impl Prefix { + pub(super) fn new(streams: impl IntoIterator>, exact: bool) -> Self { + let mut result = Self { + boundary: None, + unknown: false, + exact, + next: Vec::new(), + }; + for stream in streams { + if stream.complete { + continue; + } + let Some(end) = stream.end else { + if !result.unknown { + result.next.clear(); + result.unknown = true; + } + result.next.push(stream.source); + continue; + }; + if result.unknown { + continue; + } + let ordering = result.boundary.as_ref().map(|boundary| { + if exact { + end.cmp(boundary) + } else { + end.0.cmp(&boundary.0) + } + }); + match ordering { + None | Some(std::cmp::Ordering::Less) => { + result.boundary = Some(end); + result.next.clear(); + result.next.push(stream.source); + } + Some(std::cmp::Ordering::Equal) => result.next.push(stream.source), + Some(std::cmp::Ordering::Greater) => {} + } + } + result + } + + pub(super) fn allows(&self, position: &Position) -> bool { + if self.unknown { + return false; + } + self.boundary.as_ref().is_none_or(|boundary| { + if self.exact { + position <= boundary + } else { + // Protocol seven orders timestamp ties by its private row id. + // Drain the boundary timestamp before exposing its UUID order. + position.0 < boundary.0 + } + }) + } +} + +impl Sidebar { + pub(super) fn task_prefix_position( + &self, + computer: &Option, + id: &str, + created: i64, + updated: i64, + ) -> Position { + ( + self.task_order_key(computer, id, created, updated), + computer.clone(), + id.to_owned(), + ) + } + + pub(super) fn local_task_prefix(&self) -> Option> { + if !self.pages.enabled || !self.filter.is_empty() || self.view.group == SidebarGroup::Status + { + return None; + } + let computer = self.backend.remote_host().map(str::to_owned); + Some(Prefix::new( + [PageKind::Active, PageKind::Settled].map(|kind| Stream { + source: kind, + end: self + .sessions + .iter() + .filter(|task| self.pages.kind_contains(kind, &task.id)) + .map(|task| { + self.task_prefix_position( + &computer, + &task.id, + task.created_ms, + task.updated_ms, + ) + }) + .max(), + complete: self.pages.kind_complete(kind), + }), + self.view.sort == SidebarSort::Manual, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn position(updated: i64, id: &str) -> Position { + ((0, -updated), None, id.into()) + } + + #[test] + fn a_merged_page_holds_rows_behind_the_earliest_unread_state() { + let prefix = Prefix::new( + [ + Stream { + source: "active", + end: Some(position(10, "a")), + complete: false, + }, + Stream { + source: "settled", + end: Some(position(100, "s")), + complete: false, + }, + ], + false, + ); + assert!(prefix.allows(&position(110, "new"))); + assert!(!prefix.allows(&position(90, "older-loaded"))); + assert_eq!(prefix.next, ["settled"]); + } + + #[test] + fn exhausting_a_state_releases_the_other_states_loaded_prefix() { + let prefix = Prefix::new( + [ + Stream { + source: "active", + end: Some(position(10, "a")), + complete: false, + }, + Stream { + source: "settled", + end: Some(position(100, "s")), + complete: true, + }, + ], + false, + ); + assert!(prefix.allows(&position(90, "older-loaded"))); + assert_eq!(prefix.next, ["active"]); + } + + #[test] + fn timestamp_ties_wait_until_their_host_cursor_is_drained() { + let prefix = Prefix::new( + [Stream { + source: "active", + end: Some(position(100, "z")), + complete: false, + }], + false, + ); + assert!(!prefix.allows(&position(100, "a"))); + assert!(!prefix.allows(&position(100, "z"))); + assert!(prefix.allows(&position(101, "a"))); + } + + #[test] + fn exact_manual_frontiers_include_the_computer_and_task_tiebreakers() { + let boundary = ((1, 2), Some("b".into()), "b".into()); + let prefix = Prefix::new( + [Stream { + source: "b", + end: Some(boundary), + complete: false, + }], + true, + ); + assert!(prefix.allows(&((1, 2), Some("a".into()), "z".into()))); + assert!(prefix.allows(&((1, 2), Some("b".into()), "a".into()))); + assert!(!prefix.allows(&((1, 2), Some("b".into()), "c".into()))); + } + + #[test] + fn manual_positions_are_exact_and_unknown_streams_advance_first() { + let prefix = Prefix::new( + [Stream { + source: "active", + end: Some(position(100, "b")), + complete: false, + }], + true, + ); + assert!(prefix.allows(&position(100, "a"))); + assert!(prefix.allows(&position(100, "b"))); + assert!(!prefix.allows(&position(100, "c"))); + let unknown = Prefix::new( + [ + Stream { + source: "active", + end: Some(position(100, "a")), + complete: false, + }, + Stream { + source: "settled", + end: None, + complete: false, + }, + ], + true, + ); + assert_eq!(unknown.next, ["settled"]); + assert!(!unknown.allows(&position(101, "a"))); + } +} diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 0598feb9e..cbfb7272d 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -61,6 +61,38 @@ mod state_tests { } } + /// Model a connected catalog without asking its empty fixture stores to + /// replace the supplied summary pages. Disconnected snapshots deliberately + /// cannot expose actionable tasks in the real sidebar. + fn set_computer_snapshots( + chat: &mut ChatScreen, + computers: Arc>, + cx: &mut Context, + ) { + let mut computers = computers.as_ref().clone(); + for computer in &mut computers { + if computer.backend.is_none() { + computer.backend = Some( + chat.computers + .iter() + .find(|existing| existing.key == computer.key) + .and_then(|existing| existing.backend.clone()) + .unwrap_or_else(|| { + if let Some(key) = &computer.key { + Arc::new(AgentBackend::for_remote_test(key).unwrap()) + } else { + chat.backend.clone() + } + }), + ); + } + } + chat.set_computers(Arc::new(computers), cx); + chat.sidebar.update(cx, |sidebar, _| { + sidebar.seed_computer_snapshot_pages_for_test(); + }); + } + #[gpui::test] fn computer_catalog_buffers_summary_changes_without_loading_transcripts( cx: &mut TestAppContext, @@ -170,6 +202,9 @@ mod state_tests { use super::super::sidebar::SidebarEntry; let chat = screen(cx); chat.update(cx, |this, cx| { + this.sidebar.update(cx, |sidebar, cx| { + sidebar.set_view(crate::settings::SidebarView::default(), cx); + }); this.sessions = vec![summary("same-id", "Local task")]; this.recent_roots = vec![recent_root("/tmp/proj")]; this.sync_sidebar(cx); @@ -191,7 +226,7 @@ mod state_tests { loading: false, error: None, }; - this.set_computers(Arc::new(vec![local, remote]), cx); + set_computer_snapshots(this, Arc::new(vec![local, remote]), cx); this.sidebar.update(cx, |sidebar, _| { assert_eq!( sidebar @@ -240,10 +275,14 @@ mod state_tests { this.sidebar.update(cx, |sidebar, cx| { sidebar.set_view(crate::settings::SidebarView::default(), cx); sidebar.set_project_filter(Some("/tmp/proj".into()), cx); - assert!(sidebar.grouped_project_names().is_empty()); + assert_eq!(sidebar.grouped_project_names(), ["proj"]); + assert!(!sidebar.entries().iter().any(|entry| matches!( + entry, + SidebarEntry::OtherTask(_) | SidebarEntry::ComputerProject(_) + ))); }); let local = this.computer_snapshot("This computer".into(), cx); - this.set_computers(Arc::new(vec![local]), cx); + set_computer_snapshots(this, Arc::new(vec![local]), cx); assert_eq!(this.sidebar.read(cx).project_filter(), None); assert_eq!( this.sidebar.read(cx).grouped_project_names(), @@ -271,7 +310,7 @@ mod state_tests { label: "Remote folder".into(), }]; remote.sessions = vec![summary_at("remote", "Before title", "/remote")]; - this.set_computers(Arc::new(vec![local.clone(), remote.clone()]), cx); + set_computer_snapshots(this, Arc::new(vec![local.clone(), remote.clone()]), cx); this.set_sidebar_filter("after title", cx); assert!( !this @@ -282,7 +321,7 @@ mod state_tests { .any(|e| matches!(e, SidebarEntry::OtherTask(_))) ); remote.sessions[0].title = "After title".into(); - this.set_computers(Arc::new(vec![local, remote]), cx); + set_computer_snapshots(this, Arc::new(vec![local, remote]), cx); assert!( this.sidebar .read(cx) @@ -771,7 +810,7 @@ mod state_tests { label: "Old remote label".into(), }]; remote.sessions = vec![summary("s1", "Remote task")]; - this.set_computers(Arc::new(vec![local, remote]), cx); + set_computer_snapshots(this, Arc::new(vec![local, remote]), cx); }); let requests = Rc::new(RefCell::new(Vec::new())); let seen = requests.clone(); @@ -783,7 +822,7 @@ mod state_tests { chat.update(cx, |this, cx| { let mut catalog = this.computers.as_ref().clone(); catalog[1].projects[0].label = "Renamed remote project".into(); - this.set_computers(Arc::new(catalog), cx); + set_computer_snapshots(this, Arc::new(catalog), cx); assert_eq!( this.project_label(cx), "Local name", @@ -6335,11 +6374,12 @@ mod state_tests { assert_eq!(view(cx).sort, SidebarSort::Manual); closed(cx); } - assert!(sidebar.update(cx, |sidebar, _| sidebar.entries().contains( - &crate::ui::chat::sidebar::SidebarEntry::SectionLabel( - crate::ui::chat::sidebar::SidebarSection::List - ) - ))); + assert!(sidebar.update(cx, |sidebar, _| { + sidebar + .entries() + .contains(&crate::ui::chat::sidebar::SidebarEntry::ProjectsHeader) + && sidebar.section_rows(crate::ui::chat::sidebar::SidebarSection::List) == [0] + })); press(cx, "view-options"); press(cx, "sidebar-sort"); press(cx, "sort-updated"); @@ -6358,6 +6398,42 @@ mod state_tests { closed(cx); } + #[gpui::test] + fn new_project_computer_menu_opens_right_in_a_scoped_project(cx: &mut TestAppContext) { + use std::cell::RefCell; + use std::rc::Rc; + let (chat, cx) = chat_window(cx, |this, cx| { + this.sessions = vec![summary("s1", "One")]; + this.sync_sidebar(cx); + this.set_computers(identical_path_project_catalog(), cx); + this.sidebar.update(cx, |sidebar, cx| { + sidebar.set_project_filter(Some("/tmp/proj".into()), cx); + }); + }); + let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); + let events = Rc::new(RefCell::new(Vec::new())); + let observed = events.clone(); + let _subscription = cx.update(|_, app| { + app.subscribe(&sidebar, move |_, event: &SidebarEvent, _| { + observed.borrow_mut().push(event.clone()); + }) + }); + press(cx, "new-project"); + assert!( + cx.debug_bounds("new-project-local").unwrap().left() + >= cx.debug_bounds("new-project").unwrap().left(), + "the computer chooser opens toward the main pane" + ); + assert!(cx.debug_bounds("new-project-east").is_some()); + cx.simulate_keystrokes("down down enter"); + assert!(events.borrow().iter().any(|event| matches!( + event, + SidebarEvent::Navigate(request) + if request.computer.as_deref() == Some("east") + && request.destination == Destination::NewProject + ))); + } + /// A folder header's menu opens from its "…" button, /// and its compose button starts a task in that project. #[gpui::test] @@ -6383,6 +6459,11 @@ mod state_tests { }); press(cx, "menu-project-/tmp/proj"); assert_eq!(open_menu(&chat, cx), "ProjectMenu(/tmp/proj)"); + assert!( + cx.debug_bounds("rename-project-/tmp/proj").unwrap().left() + >= cx.debug_bounds("menu-project-/tmp/proj").unwrap().left(), + "project actions leave the sidebar to the left of the menu" + ); press(cx, "menu-project-/tmp/proj"); assert_eq!(open_menu(&chat, cx), ""); press(cx, "new-task-in-/tmp/proj"); @@ -6645,20 +6726,20 @@ mod state_tests { SidebarTarget::NewTask, SidebarTarget::Search, SidebarTarget::Projects, - SidebarTarget::Project("/b".into()), - task("s2"), SidebarTarget::Project("/a".into()), task("s1"), task("s3"), + SidebarTarget::Project("/b".into()), + task("s2"), ] ); chat.update(cx, |this, cx| { - this.selected_session = Some("s2".into()); + this.selected_session = Some("s3".into()); this.sync_sidebar_selection(cx); assert_eq!( this.task_step_target(1, cx) .map(|(_, request)| request.destination), - Some(crate::ui::chat::computers::Destination::Task("s1".into())) + Some(crate::ui::chat::computers::Destination::Task("s2".into())) ); }); sidebar.update(cx, |sidebar, cx| { @@ -6728,21 +6809,17 @@ mod state_tests { }; let local = screen(here, "here", "/a"); let peer = screen(there, "there", "/b"); - let mut computers = vec![ + let computers = vec![ local.read_with(cx, |chat, cx| { chat.computer_snapshot("This computer".into(), cx) }), peer.read_with(cx, |chat, cx| chat.computer_snapshot("Peer".into(), cx)), ]; - // Rows come from these snapshots alone. With a backend, the sidebar - // also reads pages from the fixtures' empty stores, and those replies - // remove the rows whenever they happen to land. - for computer in &mut computers { - computer.backend = None; - } let computers = Arc::new(computers); for chat in [&local, &peer] { - chat.update(cx, |chat, cx| chat.set_computers(computers.clone(), cx)); + chat.update(cx, |chat, cx| { + set_computer_snapshots(chat, computers.clone(), cx) + }); } let (shell, cx) = cx.add_window_view(|_, _| Shell { chat: local.clone(), @@ -7502,6 +7579,10 @@ mod state_tests { let (chat, cx) = chat_window(cx, |this, cx| { this.sessions = vec![summary("s1", "One")]; this.models = vec!["voxtral-small-24b".to_string()]; + // Keep the composer below the right-opening task menu. This + // checks switching between visible triggers, not clicking through + // an occluding menu onto the centered empty-task composer. + this.replace_timeline(vec![user_item("existing-message", "Earlier message")]); this.sync_sidebar(cx); }); for (first, then, open) in [ @@ -7513,6 +7594,14 @@ mod state_tests { ("root-picker", "mcp-menu", "Integrations"), ] { press(cx, first); + if first == "menu-session-s1" && then == "model-picker" { + let menu = cx.debug_bounds("task-menu-s1").expect("task menu"); + let model = cx.debug_bounds("model-picker").expect("model picker"); + assert!( + !menu.contains(&model.center()), + "the second trigger is visible" + ); + } press(cx, then); assert_eq!(open_menu(&chat, cx), open, "{first} then {then}"); chat.update(cx, |this, cx| { @@ -7556,9 +7645,8 @@ mod state_tests { assert_eq!(open_menu(&chat, cx), "Project"); } - /// 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. + /// Task actions open toward the main pane, keeping the task list visible. + /// A press on an item still reaches the intended action. #[gpui::test] fn test_a_press_on_a_menu_item_reaches_the_item(cx: &mut TestAppContext) { let (chat, cx) = chat_window(cx, |this, cx| { @@ -7570,13 +7658,18 @@ mod state_tests { this.sync_sidebar(cx); }); press(cx, "menu-session-s1"); + let trigger = cx.debug_bounds("menu-session-s1").expect("s1's button"); let beneath = cx.debug_bounds("menu-session-s2").expect("s2's button"); 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" + rename.left() >= trigger.left(), + "the menu opens to the right of its trigger" + ); + assert!( + !rename.contains(&beneath.center()), + "the menu leaves the next task's controls visible" ); - cx.simulate_click(beneath.center(), gpui::Modifiers::default()); + cx.simulate_click(rename.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!( @@ -7879,9 +7972,12 @@ mod state_tests { this.selected_session = None; this.draft = true; this.project_root = Some("/shared".into()); + this.sidebar.update(cx, |sidebar, cx| { + sidebar.set_view(crate::settings::SidebarView::default(), cx); + }); this.sessions = vec![summary_at("same-id", "Existing task", "/shared")]; this.apply_recent_roots(vec![named_root("/shared", "Local project", 1)], cx); - this.set_computers(identical_path_project_catalog(), cx); + set_computer_snapshots(this, identical_path_project_catalog(), cx); this.composer .clone() .unwrap() @@ -7896,15 +7992,18 @@ mod state_tests { observed.borrow_mut().push(event.clone()); }) }); - sidebar.update(cx, |sidebar, cx| { + sidebar.update(cx, |sidebar, _| { for computer in [Some("east"), Some("west")] { let target = ProjectTarget { computer: computer.map(str::to_string), path: "/shared".into(), }; - assert_eq!( - sidebar.project_menu_labels_for_test(&target), - vec!["Rename project"] + let labels = sidebar.project_menu_labels_for_test(&target); + assert_eq!(labels.first(), Some(&"Rename project")); + assert!( + labels + .iter() + .all(|label| matches!(*label, "Rename project" | "Move up" | "Move down")) ); } let local = ProjectTarget { @@ -7915,18 +8014,17 @@ mod state_tests { assert!(own_menu.contains(&"Rename project")); assert!(own_menu.contains(&"Open folder")); assert!(own_menu.contains(&"Remove project")); - sidebar.set_header_menu_open(true, cx); }); cx.update(|window, _| window.refresh()); cx.run_until_parked(); let local = cx - .debug_bounds("switcher-/shared") + .debug_bounds("folder-/shared") .expect("local project row"); let east = cx - .debug_bounds("switcher-computer-east-/shared") + .debug_bounds("folder-computer-east-/shared") .expect("east project row"); let west = cx - .debug_bounds("switcher-computer-west-/shared") + .debug_bounds("folder-computer-west-/shared") .expect("west project row"); assert_ne!(local.center(), east.center()); assert_ne!(east.center(), west.center()); diff --git a/apps/maple-agent/app/src/ui/chat/upload_tests.rs b/apps/maple-agent/app/src/ui/chat/upload_tests.rs index 64599cb0c..648dcf8ed 100644 --- a/apps/maple-agent/app/src/ui/chat/upload_tests.rs +++ b/apps/maple-agent/app/src/ui/chat/upload_tests.rs @@ -64,11 +64,11 @@ impl UploadHost { send_id: request.send_id, params: request.params, }); - if matches!(request.method.as_str(), "import_attachment" | "attachment_upload_status" | "send_message" | "send_outcome") { + if matches!(request.method.as_str(), "import_attachment" | "attachment_upload_status" | "send_message" | "send_outcome" | "stop_runtime") { continue; } let result = match request.method.as_str() { - "cancel_run" | "cancel_attachment_upload" | "set_ui_settings" | "set_remote_sidebar" | "flush_ui_settings" | "detach" => Ok(Value::Null), + "cancel_run" | "cancel_attachment_upload" | "set_ui_settings" | "set_remote_sidebar" | "flush_ui_settings" | "logout_and_clear" | "detach" => Ok(Value::Null), "get_ui_settings" => Ok(serde_json::to_value(crate::settings::AppSettings::default()).unwrap()), "get_remote_sidebars" => Ok(json!({})), "list_slash_commands" => Ok(json!([])), @@ -471,3 +471,57 @@ fn uploaded_prompt_waits_for_history_recovery_before_admission(cx: &mut TestAppC }); assert_eq!(host.requests("send_message").len(), 1); } + +#[gpui::test] +fn sign_out_ignores_preference_failure_but_preserves_runtime_failure(cx: &mut TestAppContext) { + let host = UploadHost::new(); + let (chat, cx) = chat_window(&host, cx); + let _guard = crate::settings::SETTINGS_IO_LOCK.lock(); + crate::settings::install_test_settings(crate::settings::AppSettings::default()); + // No settings sink exists, so the queued presentation edit cannot flush. + crate::settings::update_settings_and_wait(|settings| settings.theme = "dark".into()); + + chat.update(cx, |chat, cx| chat.sign_out(cx)); + wait_for(cx, "runtime shutdown despite failed preferences", |_| { + host.requests("stop_runtime").len() == 1 + }); + host.reply( + &host.requests("stop_runtime")[0], + Err("Synthetic runtime shutdown failure".into()), + ); + wait_for(cx, "authoritative shutdown failure", |cx| { + chat.update(cx, |chat, _| !chat.sign_out_pending) + }); + chat.update(cx, |chat, _| { + assert!(chat.accept_send_results); + assert_eq!( + chat.notice.as_deref(), + Some("Synthetic runtime shutdown failure") + ); + chat.notice = None; + }); + assert!(host.requests("logout_and_clear").is_empty()); + + chat.update(cx, |chat, cx| chat.sign_out(cx)); + wait_for(cx, "second runtime shutdown", |_| { + host.requests("stop_runtime").len() == 2 + }); + host.reply( + &host.requests("stop_runtime")[1], + Ok(json!({ + "running": false, + "projectRoot": null, + "model": null, + "activeRuns": {} + })), + ); + wait_for(cx, "logout despite failed preferences", |cx| { + host.requests("logout_and_clear").len() == 1 + && chat.update(cx, |chat, _| !chat.sign_out_pending) + }); + chat.update(cx, |chat, _| { + assert!(!chat.accept_send_results); + assert!(chat.notice.is_none()); + }); + crate::settings::install_test_settings(crate::settings::AppSettings::default()); +} diff --git a/apps/maple-agent/docs/remote-access.md b/apps/maple-agent/docs/remote-access.md index 335c5d3da..d5d7b9d00 100644 --- a/apps/maple-agent/docs/remote-access.md +++ b/apps/maple-agent/docs/remote-access.md @@ -39,8 +39,20 @@ be rearranged by dragging. Dragging a task silently selects Manual order; this changes only presentation order, preserving its project, execution computer and status. The manual task order carries across grouping changes and restarts on the viewing desktop. New tasks appear at the top; activity on existing tasks -does not reshuffle their manual order. These preferences belong to the viewing -desktop's signed-in account. +does not reshuffle their manual order. Manual ranks and personal pins belong +to the viewing desktop's signed-in account; grouping and sorting choices are +saved on that desktop. + +Pinned tasks have their own presentation order, so dragging them does not +change where they return when unpinned. Dragging in Status or In one list +moves only that task; the relative order of other tasks stays intact. The +initial Manual order includes loaded tasks in folded groups too. + +Manual refreshes coalesce repeated activity into one trailing reload, and +replacing a reload cancels its stale scans. Pagination exposes a complete +sorted prefix across Active and Settled; loading more extends that prefix +instead of inserting older-state pages into gaps above existing rows. These +are viewing-desktop behaviors and do not require a different remote protocol. Project display names belong to the execution computer and the signed-in account. **Rename project** in a sidebar project's menu updates that one name from From 564a30a3bb15d2baf9343dd98f02313f2a09e462 Mon Sep 17 00:00:00 2001 From: marks Date: Tue, 27 Oct 2026 22:38:28 -0500 Subject: [PATCH 28/29] Align sidebar paging tests with coalesced refreshes --- .../app/src/ui/chat/sidebar_computers.rs | 34 ++++++++++++++----- .../app/src/ui/chat/sidebar_paging.rs | 20 ++++++++--- 2 files changed, 40 insertions(+), 14 deletions(-) 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 2f63b1669..b775812d9 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs @@ -2574,7 +2574,8 @@ mod paging_tests { for (mut computer, id) in [(local, "local-cached"), (remote, "remote-cached")] { computer.sessions.push(session_from_summary(&task(id))); let mut page = snapshot_page(&computer, "", false); - page.loaded.extend(["groups".into(), "projects".into()]); + page.loaded + .extend(["archive-count".into(), "groups".into(), "projects".into()]); page.next_groups = Some(format!("next-{id}")); page.groups[0].more.active = Some(format!("active-{id}")); sidebar.group_pages.hosts.insert(computer.key, page); @@ -2691,9 +2692,11 @@ mod paging_tests { ); assert_eq!(visible_tasks(sidebar), ["local-cached", "remote-cached"]); let stale = group_reply_action(sidebar); - let mut computers = sidebar.computers.as_ref().clone(); - computers[0].label = "Updated computer".into(); - sidebar.set_computers(Arc::new(computers), cx); + // Explicit order/scope refreshes replace pending request authority + // immediately. Label-only activity is deliberately coalesced in + // Manual mode and is covered separately below. + sidebar.refresh_computer_pages(cx); + sidebar.rebuild_sections(); assert_eq!(visible_tasks(sidebar), ["local-cached", "remote-cached"]); for page in sidebar.group_pages.hosts.values() { assert!(page.next_groups.is_none()); @@ -2885,8 +2888,16 @@ mod revalidation_tests { let sidebar = chat.read_with(cx, |chat, _| chat.sidebar.clone()); sidebar.update(cx, |sidebar, cx| { sidebar.reset_persisted_for_test(); - sidebar.view.sort = SidebarSort::Manual; - sidebar.computers = Arc::new(Vec::new()); + sidebar.view = SidebarView { + group: SidebarGroup::Project, + sort: SidebarSort::Manual, + }; + let mut local = Computer::pending("local".into(), "This Computer".into(), None); + local.key = None; + local.backend = Some(sidebar.backend.clone()); + let mut remote = Computer::pending("remote".into(), "Remote".into(), None); + remote.backend = Some(Arc::new(AgentBackend::for_remote_test("remote").unwrap())); + sidebar.computers = Arc::new(vec![local, remote]); sidebar.group_pages.scope = Some((String::new(), None, false)); let generation = sidebar.group_pages.generation; for key in [None, Some("remote".into())] { @@ -2898,8 +2909,10 @@ mod revalidation_tests { }, ); } - for _ in 0..20 { - sidebar.revalidate_computer_pages(cx); + for revision in 0..20 { + let mut computers = sidebar.computers.as_ref().clone(); + computers[0].label = format!("Updated computer {revision}"); + sidebar.set_computers(Arc::new(computers), cx); } assert_eq!(sidebar.group_pages.generation, generation); assert!(sidebar.group_pages.revalidation_pending); @@ -2925,7 +2938,10 @@ mod revalidation_tests { ); assert_eq!(sidebar.group_pages.generation, generation.wrapping_add(1)); assert!(!sidebar.group_pages.revalidation_pending); - assert!(!sidebar.group_pages.manual_reads_pending()); + assert!( + sidebar.group_pages.manual_reads_pending(), + "one trailing reload started" + ); // An older callback cannot refill the replacement generation. sidebar.apply_computer_page( action(Some("remote".into())), 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 dd6533316..d4ad8f346 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_paging.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_paging.rs @@ -186,7 +186,10 @@ mod tests { let sidebar = chat.read_with(cx, |chat, _| chat.sidebar.clone()); sidebar.update(cx, |sidebar, cx| { sidebar.reset_persisted_for_test(); - sidebar.view = SidebarView::default(); + sidebar.view = SidebarView { + group: SidebarGroup::Status, + ..SidebarView::default() + }; sidebar.computers = Arc::new(vec![ Computer::pending("one".into(), "One".into(), None), Computer::pending("two".into(), "Two".into(), None), @@ -220,9 +223,7 @@ mod tests { }], false, ); - // The Active response is the only nonempty page in this - // scoped fixture. Confirm the other task states empty before - // inspecting the merged Project view's stable prefix. + // Active is the only nonempty task reply in this scoped fixture. sidebar.pages.loading.clear(); sidebar .pages @@ -260,7 +261,16 @@ mod tests { 2, "only live A tasks survive refresh" ); - assert_eq!(sidebar.grouped_project_names(), ["a"]); + assert_eq!(sidebar.view.group, SidebarGroup::Status); + assert!(sidebar.grouped_project_names().is_empty()); + assert_eq!( + sidebar + .project_roots + .iter() + .map(|project| project.root.as_str()) + .collect::>(), + ["/a"] + ); sidebar.set_computers( Arc::new(vec![ Computer::pending("one".into(), "One".into(), None), From 5588a0708b45dc57a97ce2bd371fe04cc4067817 Mon Sep 17 00:00:00 2001 From: marks Date: Tue, 27 Oct 2026 22:48:05 -0500 Subject: [PATCH 29/29] Embed the sidebar arrow icon and correct reload test label --- apps/maple-agent/app/src/assets.rs | 1 + apps/maple-agent/app/src/ui/chat/sidebar_computers.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/maple-agent/app/src/assets.rs b/apps/maple-agent/app/src/assets.rs index e9ce6ed25..acd5c3222 100644 --- a/apps/maple-agent/app/src/assets.rs +++ b/apps/maple-agent/app/src/assets.rs @@ -16,6 +16,7 @@ macro_rules! assets { assets!( "icons/archive-restore.svg", "icons/archive.svg", + "icons/arrow-down.svg", "icons/arrow-left.svg", "icons/arrow-up.svg", "icons/brain.svg", 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 b775812d9..642f8f31c 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar_computers.rs @@ -2911,7 +2911,7 @@ mod revalidation_tests { } for revision in 0..20 { let mut computers = sidebar.computers.as_ref().clone(); - computers[0].label = format!("Updated computer {revision}"); + computers[0].label = format!("Updated computer {revision}").into(); sidebar.set_computers(Arc::new(computers), cx); } assert_eq!(sidebar.group_pages.generation, generation);