diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index a43b7deb3..1ff4be7d6 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -126,7 +126,17 @@ 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. Pinned + 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/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/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 bbb74b98c..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", @@ -34,6 +35,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", @@ -41,6 +43,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/backend.rs b/apps/maple-agent/app/src/backend.rs index 185795da4..ce69ac746 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_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)) -> (); @@ -521,6 +523,8 @@ impl AgentBackend { | "account" | "get_remote_sidebars" | "set_remote_sidebar" + | "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 ca8e80031..0fddf643c 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,75 @@ pub struct AuthSession { pub user_id: String, } +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_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.pinned_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 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), + (&order.pinned_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()); + } + } + } + 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) + .chain(&order.pinned_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(()) +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct SelectedProject { @@ -1312,6 +1381,54 @@ impl LocalBackend { .map_err(|_| "Could not save remote sidebar settings".to_string())? } + /// Sidebar order is account-owned controller presentation state, independent + /// of the execution host's project ordering and navigation cursors. + pub async fn get_sidebar_order( + &self, + user_id: &str, + ) -> Result { + let (store, account) = self.presentation_account(user_id)?; + tokio::task::spawn_blocking(move || { + 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 order".to_string())? + } + + pub async fn set_sidebar_order( + &self, + user_id: &str, + order: crate::settings::SidebarOrder, + ) -> Result<(), String> { + 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 || { + store + .set_account_setting( + account, + "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 order".to_string())? + } + pub async fn set_task_pinned( &self, user_id: &str, diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index 7cb9bd000..26904658b 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, @@ -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; @@ -1159,12 +1163,15 @@ 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; 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(_)) { @@ -1190,6 +1197,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() @@ -1203,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); @@ -1293,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)); @@ -1361,10 +1376,13 @@ impl MapleApp { } fn sync_computers(&mut self, cx: &mut Context) { + if !self.remote_inventory_loaded { + return; + } 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 @@ -1709,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(); @@ -1785,17 +1805,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 +1861,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(); @@ -3280,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, @@ -3370,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(); @@ -3407,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) @@ -4016,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/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/settings.rs b/apps/maple-agent/app/src/settings.rs index db4b5b941..d5de9748b 100644 --- a/apps/maple-agent/app/src/settings.rs +++ b/apps/maple-agent/app/src/settings.rs @@ -13,6 +13,66 @@ 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 { + #[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 { + 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, +} + +/// 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, + /// Pinned presentation order is independent of each task's ordinary slot. + pub pinned_tasks: Vec, + pub created_before_ms: i64, +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct AppSettings { /// Whether tool cards show input/output payloads by default. @@ -47,6 +107,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, deserialize_with = "view_preference_or_default")] + pub sidebar_layout: SidebarView, /// Whether run completion and questions raise desktop notifications /// while the window is not focused. #[serde(default = "default_desktop_notifications")] @@ -246,6 +308,7 @@ impl Default for AppSettings { pinned_tasks: Vec::new(), project_names: std::collections::HashMap::new(), remote_sidebars: std::collections::HashMap::new(), + sidebar_layout: SidebarView::default(), desktop_notifications: default_desktop_notifications(), automatic_update_downloads: default_automatic_update_downloads(), password_notification_details: false, @@ -270,11 +333,21 @@ enum SettingsSink { Test(std::sync::Arc), } +#[derive(Clone)] +struct PendingSidebarOrder { + backend: std::sync::Arc, + order: SidebarOrder, +} + struct SettingsCache { generation: u64, 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_sidebar_orders: std::collections::BTreeMap, sink: Option, } @@ -285,6 +358,8 @@ static CACHE: std::sync::LazyLock> = value: AppSettings::default(), pending: Default::default(), words: Default::default(), + sidebar_orders: Default::default(), + pending_sidebar_orders: Default::default(), sink: None, }) }); @@ -306,6 +381,10 @@ 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_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; @@ -319,6 +398,8 @@ pub fn discard_decrypted_cache() { cache.value = AppSettings::default(); cache.pending.clear(); cache.words.clear(); + cache.pending_sidebar_orders.clear(); + cache.sidebar_orders.clear(); cache.sink = None; cache.generation = cache.generation.wrapping_add(1); } @@ -408,6 +489,51 @@ pub fn update_settings_in_background(update: impl FnOnce(&mut AppSettings) + Sen queue_flush(None); } +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_order( + backend: std::sync::Arc, + user: String, + order: SidebarOrder, +) -> tokio::sync::oneshot::Receiver> { + 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 +} + /// A synchronous barrier for existing GUI state tests. Runtime callers use /// the asynchronous flush_pending barrier before ordinary Quit. #[cfg(test)] @@ -496,9 +622,12 @@ fn queue_flush(ack: Option) { } fn synchronize_once() -> Result<(), String> { - let (generation, sink, patch, words) = { + 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() { + if cache.pending.is_empty() + && cache.words.is_empty() + && cache.pending_sidebar_orders.is_empty() + { return Ok(()); } ( @@ -506,6 +635,7 @@ fn synchronize_once() -> Result<(), String> { cache.sink.clone(), cache.pending.clone(), cache.words.clone(), + cache.pending_sidebar_orders.clone(), ) }; match sink.ok_or("Maple host is unavailable; settings edits remain pending")? { @@ -537,19 +667,66 @@ 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 sidebar_orders.is_empty() { + Ok(()) + } else { + Err("Maple settings state changed; sidebar 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, 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; sidebar 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_order(&user, pending.order.clone()), + ) + .await + .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()); + if cache.generation == generation + && cache + .pending_sidebar_orders + .get(&user) + .is_some_and(|current| { + current.order == pending.order + && std::sync::Arc::ptr_eq(¤t.backend, &pending.backend) + }) + { + cache.pending_sidebar_orders.remove(&user); + } + } + 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) { @@ -782,6 +959,8 @@ pub(crate) fn install_test_settings(value: AppSettings) { value, pending: Default::default(), words: Default::default(), + sidebar_orders: Default::default(), + pending_sidebar_orders: Default::default(), sink: None, }; } @@ -796,6 +975,8 @@ pub(crate) fn install_test_store(store: std::sync::Arc, ) { + if matches!( + command, + ChatCommand::Escape | ChatCommand::ApplicationVim(ApplicationVimCommand::Escape) + ) && self + .sidebar + .update(cx, |sidebar, cx| sidebar.cancel_sidebar_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/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 da5e5eba7..ed4556f5b 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, @@ -1584,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); @@ -1955,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| { @@ -4106,7 +4116,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); @@ -5395,6 +5406,47 @@ 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. Those edits are + // best effort: a failed flush must never keep an account signed in. + self.accept_send_results = false; + self.call( + async { + match tokio::time::timeout( + std::time::Duration::from_secs(10), + crate::settings::flush_pending(), + ) + .await + { + 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, _, cx| this.sign_out_after_settings_flush(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; @@ -5419,8 +5471,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), }, ); } @@ -6495,6 +6552,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 7a33b4071..f23932cf2 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. + Project(String), Task(String), Archived, LoadMore(String), @@ -564,10 +566,20 @@ 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); + let open = sidebar.header_menu_open(); + sidebar.set_header_menu_open(!open, cx); }), - Some(SidebarTarget::Computer(request)) => cx.emit(request), + Some(SidebarTarget::Project(root)) => self.sidebar.update(cx, |sidebar, cx| { + sidebar.set_project_expanded(&root, None, cx); + }), + 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 @@ -984,9 +996,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..81281ffc2 100644 --- a/apps/maple-agent/app/src/ui/chat/sidebar.rs +++ b/apps/maple-agent/app/src/ui/chat/sidebar.rs @@ -10,12 +10,18 @@ //! 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"] +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, ProjectHeading}; +use computers::{ComputerPages, OtherTask}; use paging::{PageKind, SidebarPages}; pub(super) fn project_from_summary( @@ -52,11 +58,15 @@ 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 /// computer, so it travels in that form. vim_selected: Option, + catalog: catalog::Catalog, computers: computers::ComputerBrowseState, } @@ -76,6 +86,7 @@ use super::commands::ChatCommand; use super::navigation::SidebarTarget; use super::{ChatScreen, section_label}; use crate::backend::AgentBackend; +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; @@ -198,14 +209,16 @@ 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 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()), @@ -225,23 +238,47 @@ 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(), } } } -/// The switcher's "All projects" row. -const ALL_PROJECTS_ID: &str = "switcher-all-projects"; +// 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.); + +/// 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 -/// 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 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), } +#[derive(Clone, Copy, PartialEq, Eq)] +enum ViewSubmenu { + Group, + Sort, +} + /// 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. @@ -254,11 +291,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. + ProjectEmpty(usize), ArchivedHeader, /// Placeholder row when no task row is listed. Empty(SidebarEmpty), @@ -274,6 +313,13 @@ pub(super) struct SidebarTaskEntry { pub(super) archived: 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. /// The hover button steps one rung forward; the overflow menu reaches /// every later rung and comes back only to active. The pinned section @@ -348,13 +394,25 @@ pub(super) enum SidebarEmpty { NoMatches, } -/// A named section of the inbox: pinned tasks, active work, and the -/// settled rest. -#[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 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. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SidebarHoverSection { + Projects, + Section(SidebarSection), + Archived, } impl SidebarSection { @@ -363,6 +421,8 @@ impl SidebarSection { Self::Pinned => "Pinned", Self::Active => "Active", Self::Settled => "Settled", + Self::List => "Tasks", + Self::Project(_) => "Tasks", } } @@ -371,6 +431,8 @@ impl SidebarSection { Self::Pinned => "section-toggle-pinned", Self::Active => "section-toggle-active", Self::Settled => "section-toggle-settled", + Self::List => "section-toggle-tasks", + Self::Project(_) => "section-toggle-tasks", } } } @@ -386,23 +448,99 @@ 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. -pub(super) struct SwitcherRoot { +/// 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, + ordering: 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. +pub(super) struct ProjectRow { target: ProjectTarget, root: String, name: SharedString, - row_id: SharedString, + /// 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, +} + +#[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 SidebarDrop { + target: SidebarDragItem, + after: bool, + hover_row: usize, + line_row: usize, +} + +#[derive(Clone)] +struct SidebarDrag { + sidebar: gpui::EntityId, + item: SidebarDragItem, + group: Option, + name: SharedString, + icon: &'static str, +} + +impl Render for SidebarDrag { + fn render(&mut self, _: &mut Window, _: &mut Context) -> 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(self.icon, FOLDER_ICON_SIZE, theme::text_secondary())) + .child(self.name.clone()) + } } 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, @@ -423,24 +561,46 @@ pub(super) struct Sidebar { // The list and its sections. list: gpui::ListState, entries: Vec, - heading_gaps: Vec, + reorder_rows: Vec, + drag_ranges: HashMap, + dragging: bool, + drop_target: Option, + row_spacing: 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, + /// Folded sections other than folders, which fold by root below. + collapsed: HashSet, + collapsed_projects: HashSet, + collapsed_computers: HashSet>, + /// Whether the list under the Projects header shows. + projects_expanded: bool, archived_expanded: bool, - switcher_roots: Vec, - computer_switcher_roots: Vec>, - scope_label: SharedString, + view: SidebarView, + order: SidebarOrder, + order_loaded: bool, + 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, + computer_project_roots: Vec>, project_filter: Option, // Menus. popup: Popup, /// 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. @@ -489,13 +649,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"), target, name, } @@ -532,8 +695,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| { @@ -545,7 +708,7 @@ impl Sidebar { computer: computer.key.clone(), path: project.root.clone(), }; - SwitcherRoot::new( + ProjectRow::new( target, self.computer_project_name(computer, project).into(), ) @@ -561,7 +724,7 @@ impl Sidebar { path: project.path.clone(), }; let name = self.project_name(&target).into(); - SwitcherRoot::new(target, name) + ProjectRow::new(target, name) }) .collect() } @@ -587,6 +750,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 { @@ -596,6 +762,7 @@ impl Sidebar { }), target => target, }), + catalog: self.manual_catalog.clone(), computers: self.computer_browse_state(), } } @@ -604,6 +771,20 @@ 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; + 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.pinned_tasks != order.pinned_tasks + || self.order.created_before_ms != order.created_before_ms); + self.order = order; + self.order_loaded = true; + self.rebuild_order_ranks(); + } let filter = state.search.trim().to_lowercase(); let scope_changed = self.filter != filter || self.archived_expanded != state.archived_expanded @@ -619,6 +800,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. @@ -669,7 +853,26 @@ impl Sidebar { this.search_changed(&input, cx) }) .detach(); - Self { + 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); + this.set_drop_target(None, cx); + } + }) + .ok(); + }); + }); + let mut sidebar = Self { pages: SidebarPages::default(), group_pages: ComputerPages::default(), computers: Arc::new(Vec::new()), @@ -686,24 +889,42 @@ 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(), + reorder_rows: Vec::new(), + drag_ranges: HashMap::new(), + dragging: false, + drop_target: None, + row_spacing: 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: HashSet::new(), + collapsed_projects: HashSet::new(), + collapsed_computers: HashSet::new(), + projects_expanded: true, archived_expanded: 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, + 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(), + 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), + project_menu_at: None, + view_popup: Popup::new(|this| &mut this.view_popup, cx), menu_trust: None, filter: String::new(), search_input, @@ -726,7 +947,9 @@ impl Sidebar { vim_by_row: Vec::new(), vim_order: Vec::new(), bridged_tasks: RefCell::new(Vec::new()), - } + }; + sidebar.load_order(cx); + sidebar } // ---- Inputs from the screen ------------------------------------------ @@ -897,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 @@ -1032,7 +1256,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 +1266,52 @@ 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) -> &[usize] { - &self.active_rows + self.section_rows(SidebarSection::Active) } #[cfg(test)] pub(super) fn settled_rows(&self) -> &[usize] { - &self.settled_rows + self.section_rows(SidebarSection::Settled) + } + + /// The folder groups' project names, in display order. + #[cfg(test)] + pub(super) fn grouped_project_names(&self) -> Vec<&str> { + self.sections + .iter() + .filter_map(|(section, _)| match section { + SidebarSection::Project(index) => Some(self.project_roots[*index].name.as_ref()), + _ => None, + }) + .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)] @@ -1071,8 +1329,17 @@ impl Sidebar { self.project_filter.as_deref() } - pub(super) fn switcher_menu_open(&self) -> bool { - self.popup.is_open(&SidebarPopup::Switcher) + 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::View) + } + + #[cfg(test)] + pub(super) fn view_submenu_open(&self) -> bool { + self.view_popup.open_key().is_some() } #[cfg(test)] @@ -1090,8 +1357,12 @@ 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.ordering) + .chain(items.remove) .map(|item| item.label) .collect() } @@ -1140,7 +1411,19 @@ 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, + ..SidebarView::default() + }; self.pinned_tasks.clear(); + self.order = SidebarOrder::default(); + 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(); self.removed_projects.clear(); @@ -1184,15 +1467,20 @@ 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() } + #[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 +1517,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,21 +1549,18 @@ 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 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(); + 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 pinned_ids: HashSet<&str> = self.pinned_tasks.iter().map(String::as_str).collect(); + let project_scope = self.project_filter.clone(); + let scoped = project_scope.as_deref(); + let pinned_ids: HashSet = self.pinned_tasks.iter().cloned().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,49 +1601,112 @@ 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() - .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() .copied() + .chain(self.project_metadata.keys().map(String::as_str)) .filter(|root| !recent_roots.contains(root)) .collect(); fresh_roots.sort_unstable(); - self.switcher_roots = self + fresh_roots.dedup(); + self.project_roots = self .recent_roots .iter() .map(String::as_str) .chain(fresh_roots.iter().copied()) - .filter(|root| seen_roots.contains(root) || recent_roots.contains(root)) - .map(|root| SwitcherRoot::new(self.project_target(root), self.root_name(root).into())) + .filter(|root| !self.removed_projects.contains(*root)) + .map(|root| ProjectRow::new(self.project_target(root), self.root_name(root).into())) .collect(); - self.rebuild_computer_switcher_roots(); + 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]; + 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] + .cmp(&keys[b]) + .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(); + match 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::List => sections.push((SidebarSection::List, listed)), + SidebarGroup::Project | SidebarGroup::Connection => { + let folder_of: HashMap<&str, usize> = self + .project_roots + .iter() + .enumerate() + .map(|(index, root)| (root.root.as_str(), index)) + .collect(); + 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); + } + } + let mut groups: Vec<_> = groups + .into_iter() + .enumerate() + .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(); + groups.sort_by_key(|(index, _)| { + self.project_order_rank(&self.project_roots[*index].target) + }); + sections.extend( + groups + .into_iter() + .map(|(index, rows)| (SidebarSection::Project(index), rows)), + ); + } + } + self.pinned_rows = pinned; + self.sections = sections; + self.archived_rows = archived; + self.rebuild_computer_project_roots(); + self.sync_container_order(); self.rebuild_entries(); } @@ -1369,52 +1717,77 @@ 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) { - 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() { + /// 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 + .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::Project(_)) { return; } - entries.push(SidebarEntry::SectionLabel(section)); - if expanded { + if section != SidebarSection::List { + entries.push(SidebarEntry::SectionLabel(section)); + } + if self.section_expanded(section) { + if rows.is_empty() + && empty_known + && let SidebarSection::Project(index) = section + && !pinned_roots.contains(self.project_roots[index].root.as_str()) + { + entries.push(SidebarEntry::ProjectEmpty(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, }) })); } }; - 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), - ); + // 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 { + let prefix = self.local_task_prefix(); + let next_state = prefix.as_ref().and_then(|prefix| prefix.next.first()); + 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), + ] { + 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)); + } + } + } + } if !self.archived_rows.is_empty() || (self.pages.enabled && self.filter.is_empty()) { entries.push(SidebarEntry::ArchivedHeader); if self.archived_expanded { @@ -1428,48 +1801,75 @@ impl Sidebar { })); } } - if self.pages.enabled { - for (kind, expanded) in [ - (PageKind::Pinned, self.pinned_expanded), - (PageKind::Active, self.active_expanded), - (PageKind::Settled, self.settled_expanded), - (PageKind::Archived, self.archived_expanded), - (PageKind::Search, !self.filter.is_empty()), - ] { - if expanded && self.pages.has_more(kind) { - entries.push(SidebarEntry::LoadMore(kind)); - } - } + if self.archived_expanded && self.pages.has_more(PageKind::Archived) { + entries.push(SidebarEntry::LoadMore(PageKind::Archived)); } - if !entries.iter().any(|entry| { - matches!(entry, SidebarEntry::Task(_)) - || (!self.pages.enabled && matches!(entry, SidebarEntry::ArchivedHeader)) - }) && !self.pages.any_loading() - { + // Folded sections still hold tasks; only an empty list says so. + 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 } else { SidebarEmpty::NoMatches })); } - if self.computers.len() > 1 && self.project_filter.is_none() { - entries = self.computer_entries(); - } - let heading_gaps = entries + 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 entries = if self.uses_computer_projection() { + self.computer_entries() + } else { + self.local_entries() + }; + 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 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 { self.rebuild_vim_targets(); } @@ -1495,11 +1895,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); } } @@ -1520,10 +1927,10 @@ impl Sidebar { .get(task.session) .is_some_and(|session| session.id == *id)) }), - 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); @@ -1533,10 +1940,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 { @@ -1545,7 +1959,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(); } @@ -1583,7 +1999,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. @@ -1658,46 +2075,90 @@ impl Sidebar { /// Whether a collapsible section shows its rows. fn section_expanded(&self, section: SidebarSection) -> bool { match section { - SidebarSection::Pinned => self.pinned_expanded, - SidebarSection::Active => self.active_expanded, - SidebarSection::Settled => self.settled_expanded, + SidebarSection::Project(index) => self + .project_roots + .get(index) + .is_none_or(|root| !self.collapsed_projects.contains(&root.target)), + section => !self.collapsed.contains(§ion), } } 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::Project(index) = section { + let Some(root) = self + .project_roots + .get(index) + .map(|root| root.target.clone()) + else { + return; + }; + if expanded { + self.collapsed_projects.remove(&root); + } else { + self.collapsed_projects.insert(root); + } + } else if expanded { + self.collapsed.remove(§ion); + } else { + self.collapsed.insert(section); } 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) { - self.project_popup.close(cx); - self.popup.toggle(SidebarPopup::Switcher, cx); + /// Show or fold one folder group; `None` flips it. + pub(super) fn set_project_expanded( + &mut self, + root: &str, + expanded: Option, + cx: &mut Context, + ) { + let Some(index) = self.project_roots.iter().position(|item| item.root == root) else { + return; + }; + let section = SidebarSection::Project(index); + let expanded = expanded.unwrap_or(!self.section_expanded(section)); + self.set_section_expanded(section, expanded, 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() { - return; + /// 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(); } + } + + /// Project context is supplied by a project header in grouped views. + fn show_metadata(&self) -> bool { + 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); + self.popup.toggle(SidebarPopup::View, cx); + } + + /// Keyboard and pointer open the same sidebar preferences menu. + pub(super) fn set_header_menu_open(&mut self, open: bool, cx: &mut Context) { self.project_popup.close(cx); 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); + self.popup.open(SidebarPopup::View, cx); } else { self.popup.close(cx); } @@ -1707,9 +2168,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.switcher_menu_open() { - self.popup.close(cx); - } + self.popup.close(cx); if self.pages.enabled { self.refresh_pages(cx); return; @@ -1739,14 +2198,16 @@ 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(); 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(_)); let application_vim_enabled = self.application_vim_enabled; let input = cx.new(move |cx| { let mut input = TextInput::new("Name", cx) @@ -1758,27 +2219,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(); @@ -1808,19 +2258,15 @@ 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 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 matches!(target, RenameTarget::Project(_)) { - 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 +2376,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; @@ -1960,33 +2423,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 => &[ ( @@ -2001,12 +2464,6 @@ impl Sidebar { "archive", "Archive task", ), - ( - TaskMove::Deleted, - &row.menu_delete_id, - "trash-2", - "Delete task", - ), ], TaskRung::Settled => &[ ( @@ -2021,38 +2478,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 @@ -2092,9 +2549,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() }) @@ -2107,11 +2568,12 @@ impl Sidebar { .map(|task| TaskRung::of(task).step().row_label) } - /// The overflow menu of one switcher project row. - fn project_menu_items(&self, target: &ProjectTarget) -> Vec { + /// The actions of one project header, scoped to its execution computer. + 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", @@ -2119,51 +2581,70 @@ impl Sidebar { this.begin_rename(RenameTarget::Project(rename_target.clone()), 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() { - 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, - }); - }), + // 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, + }) + }), + }); + } } - 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 mut ordering = Vec::new(); + if self.can_reorder() { + for (forward, id, icon, label) in [ + (false, "move-project-up", "arrow-up", "Move up"), + (true, "move-project-down", "arrow-down", "Move down"), + ] { + if self.project_move_available(target, forward) { + let target = target.clone(); + ordering.push(SidebarMenuItem { + id: target.element_id(id), + icon, + label, + on_click: Box::new(move |this, cx| this.move_project(&target, forward, cx)), + }); + } + } + } + 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, + ordering, + remove, + } } /// The task entry whose overflow menu is open. @@ -2193,7 +2674,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::Project(index)) => self + .project_roots + .get(index) + .map(|root| SidebarTarget::Project(root.root.clone())), + SidebarEntry::SectionLabel(_) + | SidebarEntry::Empty(_) + | SidebarEntry::ProjectEmpty(_) => None, SidebarEntry::LoadMore(kind) => { Some(SidebarTarget::LoadMore(format!("page:{}", kind.label()))) } @@ -2267,7 +2754,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 +2772,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 +2784,27 @@ 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) => self.set_projects_expanded(expanded, cx), + Some(SidebarTarget::Project(root)) => { + self.set_project_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,14 +2905,16 @@ impl Sidebar { ) -> AnyElement { let selected = self.selected.as_deref(); let chat = self.chat.clone(); - let heading_gap = self.heading_gaps.get(ix).copied().unwrap_or(false); + // Adjacent folded headings keep only their pill margins. + 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(_) | 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)) => { @@ -2487,23 +2979,35 @@ impl Sidebar { .into_any_element() } Some(SidebarEntry::ProjectsHeader) => self.render_projects_header(window, cx), + Some(SidebarEntry::ProjectEmpty(index)) => { + let id = self.project_roots[index].empty_id.clone(); + 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()) - .child(row) + self.render_task_row(task, selected, application_selected, window, cx) .into_any_element() } + Some(SidebarEntry::SectionLabel(SidebarSection::Project(index))) => { + self.render_local_project_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 +3025,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() } @@ -2574,13 +3075,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}+") @@ -2594,6 +3095,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 +3116,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,19 +3125,26 @@ 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() } 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) @@ -2643,14 +3153,98 @@ 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 drag = self.drag_for_row(ix, cx.entity_id()); + let drop_row = self.reorder_rows.get(ix).cloned().unwrap_or_default(); + let insertion = self + .drop_target + .as_ref() + .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_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| { + if event.bounds.contains(&event.event.position) { + let drag = event.drag(cx).clone(); + this.update_drop_target( + ix, + event.event.position, + event.bounds, + &drag, + cx, + ); + } else if this + .drop_target + .as_ref() + .is_some_and(|drop| drop.hover_row == ix) + { + this.set_drop_target(None, cx); + } + }, + )) + .can_drop(move |value, _, _| { + 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: &SidebarDrag, window, cx| { + if drag.sidebar != cx.entity_id() { + return; + } + if let Some(bounds) = this.list.bounds_for_item(ix) { + 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_sidebar_drag(cx); + }, + )) + }) + .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() { @@ -2667,52 +3261,68 @@ 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() } - /// 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. - fn render_projects_header(&self, window: &mut Window, cx: &mut Context) -> AnyElement { - let open = self.switcher_menu_open(); - let menu = open.then(|| { - let menu = self.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()) + /// 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() - .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() + .opacity(if !expanded || self.section_hovered(section) { + 1. + } else { + 0. }) - .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 { + if expanded { "chevron-down" } else { "chevron-right" @@ -2720,152 +3330,447 @@ impl Sidebar { px(14.), theme::text_secondary(), )); + chevron.into_any_element() + } + + /// 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 { + let open = self.popup.is_open(&SidebarPopup::View); + let menu = open.then(|| { + self.popup.render( + self.view_menu(window, cx), + Placement::BelowStart, + window, + cx, + ) + }); + 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::BelowStart, + 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", + SidebarGroup::Connection => "Connections", + SidebarGroup::Status | SidebarGroup::List => "Tasks", + }; div() - .relative() + .group("projects-header-actions") + .mt_3() + .mb_1() + .px_4() .w_full() - .mb_3() - .child( - self.popup - .trigger(SidebarPopup::Switcher, header, cx, |this, _, cx| { - this.toggle_switcher_menu(cx) - }), - ) + .flex() + .items_center() + .gap_1() .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") + .id("projects-header") .role(gpui::Role::Button) - .aria_label("New project") - .occlude() - .absolute() - .right_4() - .top_0() - .bottom_0() - .my_auto() - .size_6() + .aria_label(label) + .aria_expanded(expanded) + .debug_selector(|| "projects-header".to_string()) + .flex_1() .flex() .items_center() - .justify_center() + .gap_1() + .py_1() .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); + .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(icon("folder-plus", px(16.), theme::text_secondary())), + .child(section_label(label)) + .child(self.section_chevron( + expanded, + "projects-header-actions", + SidebarHoverSection::Projects, + )), ) - .children(menu) + .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() } - /// 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); + 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.)) + }) + } + + /// Grouping and task order are independent sidebar preferences. + fn view_menu(&self, window: &mut Window, cx: &mut Context) -> Menu { + let view = self.view; + 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 [ + ("group-project", "Project", SidebarGroup::Project), + ("group-connection", "Connection", SidebarGroup::Connection), + ("group-status", "Status", SidebarGroup::Status), + ("group-list", "In one list", SidebarGroup::List), + ] { + 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 menu = Menu::new("switcher-menu", px(260.)) - .label("Projects") + 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 [ + ("sort-updated", "Last updated", SidebarSort::Updated), + ("sort-manual", "Manual order", SidebarSort::Manual), + ] { + 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), + ); + } + Menu::new("sidebar-view-menu", px(240.)) + .label("Sidebar") + .application_vim(self.application_vim_enabled) + .item( + self.view_popup + .submenu( + ViewSubmenu::Group, + "sidebar-group", + "Group tasks by", + groups, + window, + cx, + ) + .value(match view.group { + SidebarGroup::Project => "Project", + SidebarGroup::Connection => "Connection", + SidebarGroup::Status => "Status", + SidebarGroup::List => "In one list", + }), + ) + .item( + self.view_popup + .submenu( + ViewSubmenu::Sort, + "sidebar-sort", + "Sort tasks by", + sorts, + window, + cx, + ) + .value(match view.sort { + SidebarSort::Updated => "Last updated", + SidebarSort::Manual => "Manual order", + }), + ) + } + + /// 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) + .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 { + let mut menu = Menu::new("new-project-computer-menu", px(260.)) + .label("New project on") .max_height(px(360.)) .application_vim(self.application_vim_enabled) + .header("New project on") .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()), + MenuItem::new("new-project-local", "This Computer", |_, _, cx| { + cx.emit(SidebarEvent::Navigate(Navigate { + computer: None, + destination: Destination::NewProject, + })); + }) + .icon("monitor"), ); - for root in &self.switcher_roots { - let pick = root.root.clone(); - let group = root.row_group.clone(); + 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( - root.row_id.clone(), - root.name.clone(), - move |this: &mut Self, _: &mut Window, cx: &mut Context| { - this.set_project_filter(Some(pick.clone()), cx); - }, + SharedString::from(format!( + "new-project-{}", + computer.key.as_deref().expect("remote computer") + )), + computer.label.clone(), + move |_, _, cx| cx.emit(SidebarEvent::Navigate(request.clone())), ) - .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); + .icon("monitor"); + if computer.loading { + item = item.note("Connecting…"); + } else if computer.error.is_some() { + item = item.note("Offline"); } 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"), - ) + 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( + &self, + index: usize, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let Some(root) = self.project_roots.get(index) else { + return div().into_any_element(); + }; + 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); + })) + }); + 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| { + if self.dragging { + style + } else { + style.bg(gpui::rgb(theme::bg_sidebar_row_hover())) + } + }) + }) + .on_click(cx.listener(move |this, _event, _window, 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( + 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_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| { + 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() } - /// 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 { 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") - .level(2) .application_vim(self.application_vim_enabled) - .items( - self.project_menu_items(&root.target) - .into_iter() - .map(menu_item), - ); - self.project_popup - .render(menu, Placement::BelowEnd, window, cx) + .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)); + } + let placement = self + .project_menu_at + .map_or(Placement::BelowStart, 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(!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) } - /// 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, @@ -2876,21 +3781,33 @@ 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)); - self.popup.render(menu, Placement::BelowEnd, window, cx) + .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::BelowStart, window, cx) }); 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,6 +3871,7 @@ impl Sidebar { pinned: is_pinned, settled: _, archived, + .. } = task; let row = &self.rows[index]; let is_selected = selected == Some(&*row.id); @@ -2965,7 +3883,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 { @@ -2979,18 +3897,18 @@ 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()) - .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()) .flex() .items_center() .gap_1p5() - .when(archived, |row| row.pl_4()) - .when(!archived, |row| row.pl_6()) + .pl(task_row_padding(window)) .pr_4() .py_1() .rounded(ROW_RADIUS) @@ -3001,14 +3919,13 @@ 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.dragging, |el| { el.hover(|style| style.bg(gpui::rgb(hover_bg)).cursor_pointer()) }) .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() @@ -3020,7 +3937,7 @@ impl Sidebar { None => column .child(div().line_clamp(1).text_ellipsis().child(row.title.clone())), }) - .when(self.computers.len() <= 1, |column| { + .when(self.show_task_metadata(is_pinned, archived), |column| { column.child( div() .text_xs() @@ -3031,17 +3948,19 @@ impl Sidebar { })) .line_clamp(1) .text_ellipsis() - .child(row.project_name.clone()), + .child(row.metadata.clone()), ) }), ) .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()) + .role(gpui::Role::Button) + .aria_label("Unpin task") .size_5() .flex() .items_center() @@ -3055,27 +3974,19 @@ 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())), ) }) - .when(!renaming, |el| { + .when(!renaming && !self.dragging, |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(), @@ -3106,6 +4017,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); } } @@ -3114,28 +4026,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); - // 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() { - 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); - // 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(_))) { - 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(); @@ -3178,6 +4079,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 +4117,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 +4145,50 @@ 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) + 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. 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) + .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() + }) .when(open, |button| { - button - .opacity(1.) - .bg(gpui::rgb(theme::bg_sidebar_row_selected())) + 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())) } /// The small icon button a sidebar row shows while it is hovered. @@ -3336,14 +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.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}"), @@ -3364,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 new file mode 100644 index 000000000..c87891d91 --- /dev/null +++ b/apps/maple-agent/app/src/ui/chat/sidebar_catalog.rs @@ -0,0 +1,677 @@ +//! 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; + +/// 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, + 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) = manual_order_key( + self.tasks.get(task.uuid.as_str()).copied(), + task.created_ms, + self.cutoff, + ); + 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) + && 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) + } + + 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, + 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 _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() { + 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) + } +} + +#[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 7e0077475..642f8f31c 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}; @@ -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) @@ -38,6 +74,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 +87,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 +103,7 @@ struct ComputerHostPage { loading: HashSet, errors: HashMap, loaded: HashSet, + retained_heads: HashSet, } impl ComputerHostPage { @@ -68,6 +111,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 +122,16 @@ 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::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(), + 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 @@ -113,8 +162,10 @@ struct ComputerMore { #[derive(Clone)] enum ComputerPageKind { + ArchiveCount, Projects, Groups, + Tasks { settled: bool }, Search, Project { root: String, settled: bool }, Archived, @@ -123,8 +174,10 @@ 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}"), Self::Search => "search".into(), Self::Project { root, settled } => format!("project:{settled}:{root}"), Self::Archived => "archived".into(), @@ -133,11 +186,104 @@ impl ComputerPageKind { } enum ComputerPageReply { + ArchiveCount(u64), Projects(NavigationProjectPage), Groups(NavigationGroupPage), 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 { @@ -161,6 +307,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(); @@ -229,6 +376,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 +423,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) @@ -305,29 +466,194 @@ fn snapshot_page(computer: &Computer, query: &str, archived_expanded: bool) -> C page } -pub(super) struct ProjectHeading { - request: Navigate, - id: SharedString, - label: SharedString, - remote: bool, -} 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 { + /// 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; + } + // 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 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.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 @@ -338,13 +664,25 @@ impl Sidebar { }); } self.computers = computers; - if changed { - self.revalidate_computer_pages(cx); + 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 { + if owners_changed { + self.start_computer_revalidation(cx); + } else { + 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(); } @@ -373,6 +711,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 @@ -380,6 +719,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 @@ -393,10 +733,16 @@ 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 }); + 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(); @@ -410,6 +756,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| { @@ -435,10 +783,30 @@ 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) { + // 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() { page.invalidate(); @@ -447,15 +815,23 @@ 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); - 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 = ( @@ -464,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); @@ -482,48 +860,72 @@ impl Sidebar { .collect::>(); for computer in hosts { let page = self.group_pages.hosts.entry(computer.clone()).or_default(); - if !page.loaded.contains("projects") - && !page.loading.contains("projects") - && !page.errors.contains_key("projects") + 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::Projects, + kind: ComputerPageKind::ArchiveCount, cursor: None, - label: "Loading projects…".into(), + label: "Loading archived task count…".into(), loading: false, }, cx, ); } - 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 - } else { - ComputerPageKind::Search - }; - let key = kind.key(); - if !page.loaded.contains(&key) - && !page.loading.contains(&key) - && !page.errors.contains_key(&key) + if !page.loaded.contains("projects") + && !page.loading.contains("projects") + && !page.errors.contains_key("projects") { self.load_computer_page( ComputerMore { computer: computer.clone(), generation: self.group_pages.generation, - kind, + kind: ComputerPageKind::Projects, cursor: None, - label: "Loading tasks…".into(), + label: "Loading projects…".into(), loading: false, }, cx, ); } + if self.project_filter.is_some() { + continue; + } + 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 { + vec![ComputerPageKind::Groups] + }; + 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(); if !page.loaded.contains("archived") @@ -547,6 +949,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; } @@ -556,6 +962,8 @@ impl Sidebar { let Some(backend) = computer.backend.clone() else { return; }; + let manual = self.local_manual_order(&action.computer); + let reader = self.manual_catalog.reader(); let key = action.kind.key(); let page = self .group_pages @@ -599,70 +1007,98 @@ impl Sidebar { self.call( async move { match &request.kind { - ComputerPageKind::Projects => backend - .list_projects( - &user, - ProjectPageRequest { - cursor: request.cursor.clone(), - limit: 50, - }, - ) - .await - .map(ComputerPageReply::Projects), - ComputerPageKind::Groups => backend + ComputerPageKind::ArchiveCount => backend .list_project_groups( &user, NavigationGroupQuery { - limit_groups: 20, - per_project: 20, - cursor: request.cursor.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, + limit_groups: 1, + per_project: 1, + cursor: None, }, ) .await - .map(ComputerPageReply::Tasks), - ComputerPageKind::Project { root, settled } => backend - .list_tasks( + .map(|page| ComputerPageReply::ArchiveCount(page.archived_count)), + ComputerPageKind::Projects => backend + .list_projects( &user, - NavigationTaskQuery { - section: TaskSection::State(if *settled { - TaskState::Settled - } else { - TaskState::Active - }), - project: Some(root.clone()), - pins: PinFilter::Ignore, + ProjectPageRequest { cursor: request.cursor.clone(), limit: 50, }, ) .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, + .map(ComputerPageReply::Projects), + ComputerPageKind::Groups => { + let groups = backend + .list_project_groups( + &user, + NavigationGroupQuery { + limit_groups: 20, + per_project: 20, + cursor: request.cursor.clone(), + }, + ) + .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, @@ -691,19 +1127,26 @@ 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.finish_computer_revalidation(cx); self.rebuild_entries(); cx.notify(); 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::ArchiveCount(count) => page.archived_count = count, ComputerPageReply::Projects(reply) => { - if action.cursor.is_none() { + if action.cursor.is_none() && !retain_head { page.projects.clear(); } page.next_projects = reply.next_cursor; @@ -718,12 +1161,28 @@ 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; + 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. + // 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| { @@ -751,15 +1210,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; @@ -779,7 +1250,9 @@ impl Sidebar { merge_tasks(&mut group.tasks, reply.tasks); } } - ComputerPageKind::Groups | ComputerPageKind::Projects => {} + ComputerPageKind::ArchiveCount + | ComputerPageKind::Groups + | ComputerPageKind::Projects => {} } } } @@ -787,7 +1260,9 @@ impl Sidebar { self.remember_task_project_labels(own_labels); self.merge_page_sessions(own_tasks); } - self.rebuild_computer_switcher_roots(); + 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()); cx.notify(); @@ -828,194 +1303,709 @@ impl Sidebar { entries.push(SidebarEntry::ComputerMore(index)); } - pub(super) fn computer_entries(&mut self) -> Vec { - self.computer_projects.clear(); - 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 archived_entries = Vec::new(); - // These small clones let rendering append row projections without - // borrowing the per-host paging cache for the whole rebuild. - 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 Some(page) = self.group_pages.hosts.get(&computer.key) else { - entries.push(SidebarEntry::Computer(computer_index)); - continue; - }; - 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() - } else { - page.next_groups.clone() - }; - let next_archived = page.next_archived.clone(); - if searching { - groups.clear(); - for task in search { - if task.state == "archived" && !self.archived_expanded { - continue; - } - 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(), - }); - } - } + /// 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", } - if groups.is_empty() && (computer.backend.is_none() || computer.error.is_some()) { - entries.push(SidebarEntry::Computer(computer_index)); + .into(); + } + 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 + } + + 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(), + }); } - for mut group in groups { - if own - && self - .project_filter - .as_ref() - .is_some_and(|root| root != &group.project.root) - { + } + if searching { + for summary in &page.search { + let task = self.current_computer_task(computer, summary); + if task.state == "archived" { continue; } - 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); - } - 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, + 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, }, - group.more.settled, - "more settled tasks", - ); + tasks: vec![task], + more: Default::default(), + }); } } - 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], - )); - } + } 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(); + } + } + 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); } - 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, - ); + } 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 + .project_filter + .as_ref() + .is_none_or(|root| own && root == &group.project.root) + }); + groups + } + + fn compare_computer_tasks( + &self, + left: &Option, + a: &TaskSummary, + right: &Option, + b: &TaskSummary, + ) -> std::cmp::Ordering { + 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)) + } + + fn sort_computer_tasks(&self, tasks: &mut [(usize, TaskSummary)]) { + tasks.sort_by(|(left, a), (right, b)| { + self.compare_computer_tasks( + &self.computers[*left].key, + a, + &self.computers[*right].key, + b, + ) + }); + } + + 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, + computer: &Computer, + mut group: NavigationProjectGroup, + ) { + let target = ProjectTarget { + computer: computer.key.clone(), + path: group.project.root.clone(), + }; + self.push_computer_heading(entries, computer, &group.project); + 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)); + for task in &group.tasks { + self.push_computer_task(entries, computer, task); + } + 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( - &mut archived_entries, + entries, &computer.key, - ComputerPageKind::Archived, - next_archived, - "more archived tasks", + ComputerPageKind::Project { + root: target.path.clone(), + settled, + }, + cursor, + "more tasks", ); } } - if self.computer_archived_count > 0 || !self.filter.is_empty() { + } + + 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(); + self.group_pages.more.clear(); + self.computer_archived_count = 0; + 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(); + 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 { + 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)); + // 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 pins = self.computer_pinned_summaries(computer); + let source = if searching { + page.search.iter().chain(pins.iter()).collect::>() + } else { + page.active + .iter() + .chain(&page.settled) + .chain(pins.iter()) + .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 { + 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)); + } + } + } + } + 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(); + 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(), + path: group.project.root.clone(), + }; + ( + 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, &computers[index], group); + } + for &index in &hosts { + self.push_computer_flat_more(&mut entries, index, None); + } + entries.extend(unavailable.iter().copied().map(SidebarEntry::Computer)); + } + 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, + &computers[*owner], + group.clone(), + ); + } + self.push_computer_flat_more(&mut entries, index, None); + } + } + 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, &computers[*index], task); + } + if !searching { + 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)); + } + } + } + if searching { + for &index in &hosts { + self.push_computer_flat_more(&mut entries, index, None); + } + } + entries.extend(unavailable.iter().copied().map(SidebarEntry::Computer)); + } + SidebarGroup::List => { + for (index, task) in &tasks { + self.push_computer_task(&mut entries, &computers[*index], task); + } + if searching { + for &index in &hosts { + self.push_computer_flat_more(&mut entries, index, None); + } + } 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)); + } + } + } + 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); - entries.extend(archived_entries); + 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, &computers[index], &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), + ); + } + } + } + } + if entries.len() == group_start && !has_pins && self.projects_expanded { + entries.push(SidebarEntry::Empty(if searching { + SidebarEmpty::NoMatches + } else { + SidebarEmpty::NoTasks + })); } entries } @@ -1023,22 +2013,18 @@ impl Sidebar { fn push_computer_heading( &mut self, entries: &mut Vec, - 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); - 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(), - remote: computer.key.is_some(), - }); + let target = ProjectTarget { + computer: computer.key.clone(), + path: root.clone(), + }; + self.computer_projects + .push(ProjectRow::new(target, label.into())); entries.push(SidebarEntry::ComputerProject(heading)); } @@ -1075,7 +2061,6 @@ impl Sidebar { fn push_computer_task( &mut self, entries: &mut Vec, - computer_index: usize, computer: &Computer, task: &TaskSummary, ) { @@ -1088,8 +2073,8 @@ impl Sidebar { } entries.push(SidebarEntry::Task(SidebarTaskEntry { session: index, - pinned: task.pinned_ms.is_some(), - settled: state == AgentTaskState::Settled, + pinned: self.computer_task_pin(computer, task).is_some(), + settled: state == AgentTaskState::Settled && !self.running.contains(&task.uuid), archived: state == AgentTaskState::Archived, })); } @@ -1106,17 +2091,42 @@ impl Sidebar { return; } let index = self.other_tasks.len(); + let session = session_from_summary(task); + let pinned = self.computer_task_pin(computer, task).is_some(); + let metadata = if self.show_task_metadata(pinned, 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(), - pinned: task.pinned_ms.is_some(), + pinned, activity: if computer.running.contains(&task.uuid) { Some(SessionActivity::Running) } else if computer.unread.contains(&task.uuid) { @@ -1138,7 +2148,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 { @@ -1150,6 +2164,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!( @@ -1177,10 +2203,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 { @@ -1188,6 +2242,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(); @@ -1220,44 +2275,7 @@ impl Sidebar { .into_any_element() } SidebarEntry::ComputerProject(index) => { - let project = &self.computer_projects[index]; - let request = project.request.clone(); - div() - .id(project.id.clone()) - .role(gpui::Role::Button) - .aria_label(project.label.clone()) - .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(|s| s.bg(gpui::rgb(hover_bg)).cursor_pointer()) - .on_click(cx.listener(move |_, _, _, cx| { - cx.emit(SidebarEvent::Navigate(request.clone())) - })) - .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]; @@ -1269,11 +2287,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() @@ -1282,7 +2300,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())) })) @@ -1290,9 +2314,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)) @@ -1304,18 +2343,42 @@ 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, }; + let disclosure_request = request.clone(); + 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-{index}"))) + .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)) .mx_2() .my(px(1.)) .mt_3() - .px_2() + .px(FOLDER_HEADER_PADDING - gpui::rems(0.5)) .py_2() .rounded(ROW_RADIUS) .flex() @@ -1323,127 +2386,117 @@ 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() + .w_full() + .flex() + .items_center() + .gap(FOLDER_HEADER_GAP) + .child( + div() + .flex_none() + .relative() + .w(FOLDER_ICON_SIZE) + .h(FOLDER_ICON_SIZE) + .flex() + .items_center() + .justify_center() + .child(icon("monitor", FOLDER_ICON_SIZE, theme::text_muted())) + .when(collapsible, |slot| { + 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.)) + .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" + } else { + "chevron-right" + }, + px(12.), + theme::text_muted(), + )), + ) + }), + ) + .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() } _ => 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)] @@ -1492,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(); @@ -1510,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); @@ -1627,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()); @@ -1694,6 +2761,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 @@ -1805,3 +2873,299 @@ 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 = 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())] { + sidebar.group_pages.hosts.insert( + key, + ComputerHostPage { + loading: HashSet::from(["groups".into()]), + ..Default::default() + }, + ); + } + for revision in 0..20 { + let mut computers = sidebar.computers.as_ref().clone(); + computers[0].label = format!("Updated computer {revision}").into(); + sidebar.set_computers(Arc::new(computers), 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(), + "one trailing reload started" + ); + // 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 new file mode 100644 index 000000000..bb31f49e1 --- /dev/null +++ b/apps/maple-agent/app/src/ui/chat/sidebar_ordering.rs @@ -0,0 +1,1146 @@ +//! 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.has_added_computer() + } + + 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_order_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 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() { + self.task_ranks + .entry(task.computer.clone()) + .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 + .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()); + } + 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( + &self, + computer: &Option, + ) -> Option { + 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(), + } + }) + } + + 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.pinned_tasks != order.pinned_tasks + || self.order.created_before_ms != order.created_before_ms) + { + self.order_query_dirty = true; + } + self.order = order; + self.rebuild_order_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_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( + self.backend.clone(), + self.user_id.clone(), + self.order.clone(), + )); + } + } + + 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. + if view.group != self.view.group + && 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_order_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 state == AgentTaskState::Archived { + TaskGroup::Archived + } else if pinned { + TaskGroup::Pinned + } 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 + && !task.pinned + { + 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 + && !task.pinned + { + 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(), + "square-pen", + ) + } + SidebarEntry::OtherTask(index) => { + let task = self.other_tasks.get(index)?; + ( + SidebarDragItem::Task(self.reorder_rows.get(row)?.task.clone()?), + task.session.title.clone().into(), + "square-pen", + ) + } + _ => 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, _) = *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; + } + // 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, + 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 loaded_task_order_seeds(&self) -> HashMap { + let computer = self.backend.remote_host().map(str::to_owned); + 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), + )); + } + } + } + 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(), + }) + .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)) + .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, + 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(); + } + 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 { + 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 visible = sequence + .iter() + .filter_map(|item| match item { + SidebarDragItem::Task(key) => Some(key.clone()), + _ => None, + }) + .collect::>(); + 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, + } + if !order_fits(&self.order) { + self.order = previous_order; + self.view = previous_view; + 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| { + 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.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) +} + +/// 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; + } + } + 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; + } + 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()); + } +} 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..d4ad8f346 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,413 @@ 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.view = SidebarView::default(); + sidebar.pages.enabled = true; + sidebar.set_application_vim_enabled(true, cx); + sidebar.set_project_name_for_test("/empty", "Empty"); + let note = SidebarEntry::ProjectEmpty(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_project_expanded("/empty", Some(false), cx); + assert!(!sidebar.entries.contains(¬e)); + sidebar.set_project_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.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), + ]); + 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, + ); + // Active is the only nonempty task reply in this scoped fixture. + 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 + .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.grouped_project_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.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), + 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.view = SidebarView::default(); + 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.grouped_project_names(), ["a"]); + assert!( + sidebar + .entries + .contains(&SidebarEntry::SectionLabel(SidebarSection::Project(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.grouped_project_names(), ["a", "b"]); + assert_eq!( + sidebar + .entries + .iter() + .filter(|entry| matches!( + entry, + SidebarEntry::SectionLabel(SidebarSection::Project(_)) + )) + .count(), + 2 + ); + assert!( + !sidebar + .entries + .iter() + .any(|entry| matches!(entry, SidebarEntry::Empty(_))) + ); + for group in [SidebarGroup::Status, SidebarGroup::List] { + sidebar.view.group = group; + sidebar.rebuild_sections(); + assert!( + sidebar + .entries + .contains(&SidebarEntry::Empty(SidebarEmpty::NoTasks)) + ); + } + sidebar.view.group = SidebarGroup::Project; + sidebar.apply_task_page( + PageKind::Active, + vec![TaskSummary { + project_root: "/b".into(), + ..page_summary("b-task") + }], + false, + ); + sidebar.rebuild_sections(); + 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); + assert!(sidebar.grouped_project_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.view = SidebarView::default(); + sidebar.pages = SidebarPages { + enabled: true, + ..SidebarPages::default() + }; + 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); + 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.grouped_project_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.grouped_project_names(), ["a", "b", "c"]); + assert_eq!( + sidebar + .sections + .iter() + .map(|(_, rows)| rows.len()) + .sum::(), + 2, + "rows beyond the unread Settled frontier stay cached" + ); + 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::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"); + 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::List; + sidebar.rebuild_sections(); + 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); + }); + } + #[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 +559,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())); @@ -211,15 +618,37 @@ pub(super) struct SidebarPages { deleted: HashSet, pin_batches: VecDeque>, project_generation: u64, + retain_loaded: bool, } 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) || 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) } @@ -350,11 +779,42 @@ impl Sidebar { /// Also used after reconnect: deeper pages are discarded and visible first /// pages are re-authorized on the current host/account. + /// 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); + 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); } 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 { @@ -418,12 +878,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() { @@ -477,6 +939,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 { @@ -519,6 +982,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; } @@ -537,7 +1004,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); @@ -546,6 +1013,13 @@ impl Sidebar { let user = self.user_id.clone(); let project = self.project_filter.clone(); let filter = self.filter.clone(); + 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 @@ -575,39 +1049,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 { - 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 { - 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, @@ -630,6 +1105,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); @@ -953,3 +1435,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 c3b0d51ed..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 @@ -234,8 +269,25 @@ 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_view(crate::settings::SidebarView::default(), cx); + sidebar.set_project_filter(Some("/tmp/proj".into()), cx); + 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(), + ["proj", "other"] + ); assert!(!this.sidebar.read(cx).entries().iter().any(|e| matches!( e, SidebarEntry::OtherTask(_) | SidebarEntry::ComputerProject(_) @@ -258,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 @@ -269,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) @@ -758,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(); @@ -770,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", @@ -5364,7 +5416,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"]); }); } @@ -5426,7 +5478,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); @@ -5457,26 +5509,301 @@ mod state_tests { }); } - /// The project filter scopes the sections to one project; the - /// switcher still lists every known root. + /// 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_project_filter_scopes_the_sections(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(); 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!(sidebar.show_metadata_for_test()); + sidebar.set_view(SidebarView::default(), cx); + assert!(!sidebar.show_metadata_for_test()); + assert_eq!(sidebar.grouped_project_names(), ["a", "b"]); + assert_eq!(sidebar.pinned_rows(), [3]); + 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 { + session, + pinned: session == 3, + settled: false, + archived: false, + }) + }; + assert_eq!( + sidebar.entries(), + [ + SidebarEntry::NewTask, + SidebarEntry::Search, + SidebarEntry::SectionLabel(SidebarSection::Pinned), + task(3), + SidebarEntry::ProjectsHeader, + 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.grouped_project_names(), ["a", "b"]); + sidebar.set_pinned_tasks_for_test(vec!["s4".into()]); + assert_eq!(sidebar.grouped_project_names(), ["a", "b"]); + }); + }); + } + /// 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_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"), + summary("s3", "Three"), + ]; + this.sessions[0].updated_ms = 1; + this.sessions[1].updated_ms = 2; + this.sessions[2].state = AgentTaskState::Archived; + this.sync_sidebar(cx); this.sidebar.update(cx, |sidebar, cx| { - sidebar.set_project_filter(Some("/b".to_string()), cx); + sidebar.set_view(SidebarView::default(), cx); + assert_eq!(sidebar.grouped_project_names(), ["empty", "proj"]); + sidebar.set_filter_for_test("one", cx); + assert_eq!(sidebar.grouped_project_names(), ["proj"]); + sidebar.set_filter_for_test("", cx); + assert!(!sidebar.show_metadata_for_test()); + sidebar.set_pinned_tasks_for_test(vec!["s1".into()]); + sidebar.set_view( + SidebarView { + group: SidebarGroup::List, + ..SidebarView::default() + }, + cx, + ); + assert_eq!(sidebar.section_rows(SidebarSection::List), [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); + 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_pinned_tasks_for_test(vec!["s3".to_string()]); + sidebar.set_view(SidebarView::default(), cx); + sidebar.set_project_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::Project(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::Project(_)) | SidebarEntry::Empty(_) + )) + ); + }); + } + + /// Manual task ordering snapshots all visible sections and stays stable + /// through activity changes; Last updated then restores activity order. + #[gpui::test] + 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, updated_ms, created_ms| AgentSessionSummary { + updated_ms, + created_ms, + ..summary(id, id) + }; + let archived = |id, updated_ms, created_ms| AgentSessionSummary { + state: AgentTaskState::Archived, + ..at(id, updated_ms, created_ms) + }; + this.sessions = vec![ + 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()]); + 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]); + }); + }); + } + + /// Initial project order follows recent roots, then unseen roots by path, + /// and later task activity leaves those container positions intact. + #[gpui::test] + 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.sync_sidebar(cx); + this.sidebar.update(cx, |sidebar, cx| { + sidebar.set_view(crate::settings::SidebarView::default(), cx) + }); + 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"] + ); + }); + } + + /// 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::settings::SidebarView; + 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_view(SidebarView::default(), 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); + 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"]); }); } @@ -5565,8 +5892,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" @@ -5896,8 +6223,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" @@ -5913,12 +6240,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()) ); } @@ -5937,41 +6264,491 @@ 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]); } - /// 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. + /// Application Vim opens View options from the Projects header and + /// its arrows, j and Enter pick a grouping in a submenu. #[gpui::test] - fn test_application_vim_drives_the_switcher_menu(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_at("s1", "One", "/a")]; - this.recent_roots = vec![recent_root("/b")]; 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()); - sidebar.update(cx, |sidebar, cx| sidebar.set_switcher_menu_open(true, cx)); - cx.run_until_parked(); + 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_eq!(open_menu(&chat, cx), "View"); + // 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("switcher-all-projects".into()) + Some("sidebar-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) + }); + sidebar.update(cx, |sidebar, cx| { + sidebar.vim_select(SidebarTarget::Projects, cx) + }); + cx.simulate_keystrokes("enter"); + assert_eq!(open_menu(&chat, cx), "View"); + cx.simulate_keystrokes("j right k enter"); + assert_eq!( + sidebar.update(cx, |sidebar, _| sidebar.view().group), + SidebarGroup::Connection + ); + assert_eq!(open_menu(&chat, cx), ""); + } + + /// 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_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); + }); + let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); + let view = + |cx: &mut gpui::VisualTestContext| sidebar.update(cx, |sidebar, _| sidebar.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-options"); + 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); + 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::ProjectsHeader) + && sidebar.section_rows(crate::ui::chat::sidebar::SidebarSection::List) == [0] + })); + press(cx, "view-options"); + 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); + } + + #[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] + 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())); + 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)"); + 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" ); - // Rows: "All projects", "/b", "/a", "New project…". - cx.simulate_keystrokes("j enter"); + 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); + 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"); + 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_view(crate::settings::SidebarView::default(), 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}" + ); + } + } + } + + /// 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) { + use crate::settings::SidebarView; + 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); + this.sidebar.update(cx, |sidebar, cx| { + sidebar.set_view(SidebarView::default(), cx) + }); + }); + let sidebar = chat.update(cx, |this, _| this.sidebar.clone()); + for change in ["activity", "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 { + "activity" => 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_project_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::settings::SidebarView; + 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); + 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(); + 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::Project("/a".into()), + task("s1"), + task("s3"), + SidebarTarget::Project("/b".into()), + task("s2"), + ] ); - assert!(!sidebar.update(cx, |sidebar, _| sidebar.switcher_menu_open())); + chat.update(cx, |this, cx| { + 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("s2".into())) + ); + }); + sidebar.update(cx, |sidebar, 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); + assert!(sidebar.vim_targets_for_test().contains(&task("s1"))); + }); } /// Every computer has its own Chat and sidebar, and the window swaps @@ -6032,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(), @@ -6122,8 +6895,8 @@ mod state_tests { if let Some(popup) = this.popup.open_key() { open.push(format!("{popup:?}")); } - if sidebar.switcher_menu_open() { - open.push("Switcher".to_string()); + if sidebar.header_menu_open() { + open.push("View".to_string()); } if let Some(task) = sidebar.task_menu() { open.push(format!("Task({task})")); @@ -6781,7 +7554,7 @@ mod state_tests { this.sync_sidebar(cx); }); for (trigger, menu) in [ - ("projects-header", "Switcher"), + ("view-options", "View"), ("menu-session-s1", "Task(s1)"), ("model-picker", "Model"), ("mcp-menu", "Integrations"), @@ -6796,33 +7569,39 @@ 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 /// 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()]; + // 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 [ - ("projects-header", "model-picker", "Model"), + ("view-options", "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", "view-options", "View"), + ("menu-session-s1", "view-options", "View"), ("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| { @@ -6832,6 +7611,33 @@ 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"); + 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_project_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"); @@ -6839,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| { @@ -6853,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" ); - cx.simulate_click(beneath.center(), gpui::Modifiers::default()); + assert!( + !rename.contains(&beneath.center()), + "the menu leaves the next task's controls visible" + ); + 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!( @@ -7105,6 +7915,55 @@ 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_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"), + 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); + 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]); + }); + }); + } + #[gpui::test] fn inactive_project_rename_controls_are_scoped_and_do_not_navigate(cx: &mut TestAppContext) { use std::cell::RefCell; @@ -7113,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() @@ -7130,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 { @@ -7149,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_switcher_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()); @@ -7505,59 +8369,98 @@ 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); + this.sidebar.update(cx, |sidebar, cx| { + sidebar.set_view(crate::settings::SidebarView::default(), 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 +8485,7 @@ mod state_tests { .find(|project| project.path == root) .unwrap() .name, - "Nice" + "Project outside-click" ); } @@ -7816,9 +8719,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.set_header_menu_open(true, cx)); this.escape(cx); - assert!(!this.sidebar.read(cx).switcher_menu_open()); + 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); 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/app/src/ui/popup.rs b/apps/maple-agent/app/src/ui/popup.rs index fb69e291f..621b87cab 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,65 @@ 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. + 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 +538,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 +658,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 +706,7 @@ impl Popup { cx.propagate(); return; } - get(view).close(cx); + close_menu(view, get, Exit::Return, window, cx); }) }) .on_key_down({ @@ -642,6 +780,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 +801,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 +830,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 +947,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 +1084,7 @@ pub(crate) struct MenuItem { trailing: Option, style: Option, on_select: OnSelect, + submenu: Option>>, } #[derive(Clone, Copy)] @@ -920,6 +1112,7 @@ impl MenuItem { trailing: None, style: None, on_select: Rc::new(on_select), + submenu: None, } } @@ -928,6 +1121,14 @@ impl MenuItem { self } + /// The current choice, shown beside a submenu's chevron. + 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 +1188,7 @@ struct Pick { on_select: OnSelect, enabled: bool, keep_open: bool, + submenu: bool, initial: Option, child: usize, } @@ -1015,6 +1217,7 @@ fn item_row( trailing, style, on_select, + submenu, } = item; let role = match state { None => Role::MenuItem, @@ -1027,11 +1230,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 +1263,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 +1313,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 +1372,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 +1445,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 +1540,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 +1561,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 +1581,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 +1595,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 +1648,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 +1671,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 +1692,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 +1785,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 +1833,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); 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/docs/remote-access.md b/apps/maple-agent/docs/remote-access.md index d0c841d68..d5d7b9d00 100644 --- a/apps/maple-agent/docs/remote-access.md +++ b/apps/maple-agent/docs/remote-access.md @@ -24,15 +24,38 @@ 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. 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 +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. 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 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 @@ -67,8 +90,15 @@ 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. + +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. @@ -188,11 +218,15 @@ 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 and an explicit task-operation allowlist apply. +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 `7.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 @@ -200,7 +234,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 diff --git a/apps/maple-agent/docs/updates.md b/apps/maple-agent/docs/updates.md index babf91d9b..fca8e9e4b 100644 --- a/apps/maple-agent/docs/updates.md +++ b/apps/maple-agent/docs/updates.md @@ -52,6 +52,8 @@ 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