{
- 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