Skip to content

Commit d11071f

Browse files
Merge pull request #1289 from MaplePrivacyLabs/ux/projects-view
Agent: group and order sidebar tasks across computers
2 parents 7cdc65c + 5588a07 commit d11071f

27 files changed

Lines changed: 8552 additions & 1280 deletions

‎apps/maple-agent/README.md‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,17 @@ agent runtime; its desktop Agent page points users to Maple Work.
125125
Renaming from its desktop or a paired controller updates connected views and
126126
persists across restarts without changing the folder path. The owner supplies
127127
the default workspace label too. Personal pins remain desktop preferences.
128-
- Sessions grouped by project, with rename, archive, and restore.
128+
- Sidebar organization by Project, Connection, Status, or In one list, with
129+
Last updated or Manual order for tasks. Connection becomes available after
130+
another computer is added, including while it is offline. Project views keep
131+
known empty projects visible. Project and connection order stays stable;
132+
dragging rearranges the sidebar without changing a task's project, computer
133+
or status. Dragging a task silently selects Manual order, which persists
134+
across grouping changes and restarts; new tasks appear at the top. Pinned
135+
tasks sit above the grouping header; their own drag order does not change
136+
the position they return to when unpinned. Sidebar menus open toward the
137+
main pane so the list stays visible. Tasks also support rename, archive,
138+
and restore. Unknown or malformed View preferences fall back to defaults.
129139
- Settings: General (web tools, appearance, tool call details, desktop
130140
notifications, tool call summaries, composer Vim, application Vim, and the
131141
speech voice and speed), Keyboard Shortcuts, System prompt, Integrations
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 1 addition & 0 deletions
Loading

‎apps/maple-agent/app/src/assets.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ macro_rules! assets {
1616
assets!(
1717
"icons/archive-restore.svg",
1818
"icons/archive.svg",
19+
"icons/arrow-down.svg",
1920
"icons/arrow-left.svg",
2021
"icons/arrow-up.svg",
2122
"icons/brain.svg",
@@ -34,13 +35,15 @@ assets!(
3435
"icons/folder.svg",
3536
"icons/globe.svg",
3637
"icons/image.svg",
38+
"icons/list-filter.svg",
3739
"icons/loader-circle.svg",
3840
"icons/lock.svg",
3941
"icons/mic.svg",
4042
"icons/maple-mark.svg",
4143
"icons/maple-wordmark.svg",
4244
"icons/maximize-2.svg",
4345
"icons/minimize-2.svg",
46+
"icons/monitor.svg",
4447
// OpenAI mark (simple-icons, CC0) for the Codex integration card.
4548
"icons/openai-mark.svg",
4649
"icons/paperclip.svg",

‎apps/maple-agent/app/src/backend.rs‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,8 @@ operations! {
245245
add_word(word: (String)) -> bool;
246246
get_remote_sidebars(user_id: (&str)) -> HashMap<String, crate::settings::RemoteSidebarSettings>;
247247
set_remote_sidebar(user_id: (&str), computer: (String), patch: (serde_json::Value)) -> ();
248+
get_sidebar_order(user_id: (&str)) -> crate::settings::SidebarOrder;
249+
set_sidebar_order(user_id: (&str), order: (crate::settings::SidebarOrder)) -> ();
248250
set_task_pinned(user_id: (&str), task: (&str), pinned: (bool)) -> ();
249251
login(email: (String), password: (String)) -> AuthSession;
250252
logout_and_clear(account_user: (&str)) -> ();
@@ -521,6 +523,8 @@ impl AgentBackend {
521523
| "account"
522524
| "get_remote_sidebars"
523525
| "set_remote_sidebar"
526+
| "get_sidebar_order"
527+
| "set_sidebar_order"
524528
) {
525529
&controller.client
526530
} else {

‎apps/maple-agent/app/src/backend/local.rs‎

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
// there by design.
66
#![cfg_attr(not(feature = "desktop"), allow(dead_code))]
77

8-
use std::collections::HashMap;
8+
use std::collections::{HashMap, HashSet};
99
use std::path::PathBuf;
1010
#[cfg(target_os = "macos")]
1111
use std::process::Command;
@@ -63,6 +63,75 @@ pub struct AuthSession {
6363
pub user_id: String,
6464
}
6565

66+
fn validate_sidebar_order(order: &crate::settings::SidebarOrder) -> Result<(), String> {
67+
const MAX_ITEMS: usize = 8192;
68+
const MAX_CONNECTIONS: usize = 256;
69+
const MAX_ROOT_BYTES: usize = 32768;
70+
const MAX_TASK_ID_BYTES: usize = 256;
71+
const MAX_ORDER_BYTES: usize = 2 * 1024 * 1024;
72+
if order.projects.len() > MAX_ITEMS
73+
|| order.tasks.len() > MAX_ITEMS
74+
|| order.pinned_tasks.len() > MAX_ITEMS
75+
|| order.connections.len() > MAX_CONNECTIONS
76+
{
77+
return Err("Sidebar order is too large".into());
78+
}
79+
if order.created_before_ms < 0 {
80+
return Err("Invalid sidebar order cutoff".into());
81+
}
82+
let mut bytes = 0_usize;
83+
let mut validate_computer = |computer: &Option<String>| -> Result<(), String> {
84+
if let Some(computer) = computer {
85+
if computer.is_empty() || computer.len() > 128 || computer.chars().any(char::is_control)
86+
{
87+
return Err("Invalid sidebar computer".into());
88+
}
89+
bytes = bytes.saturating_add(computer.len());
90+
}
91+
Ok(())
92+
};
93+
for (items, maximum, is_task) in [
94+
(&order.projects, MAX_ROOT_BYTES, false),
95+
(&order.tasks, MAX_TASK_ID_BYTES, true),
96+
(&order.pinned_tasks, MAX_TASK_ID_BYTES, true),
97+
] {
98+
let mut seen = HashSet::new();
99+
for item in items {
100+
validate_computer(&item.computer)?;
101+
if item.id.is_empty()
102+
|| item.id.len() > maximum
103+
|| item.id.contains('\0')
104+
|| (is_task && item.id.chars().any(char::is_control))
105+
|| !seen.insert(item)
106+
{
107+
return Err("Invalid sidebar order item".into());
108+
}
109+
}
110+
}
111+
let mut connections = HashSet::new();
112+
for computer in &order.connections {
113+
validate_computer(computer)?;
114+
if !connections.insert(computer) {
115+
return Err("Duplicate sidebar connection".into());
116+
}
117+
}
118+
bytes = order
119+
.projects
120+
.iter()
121+
.chain(&order.tasks)
122+
.chain(&order.pinned_tasks)
123+
.fold(bytes, |bytes, item| bytes.saturating_add(item.id.len()));
124+
if bytes > MAX_ORDER_BYTES
125+
|| serde_json::to_vec(order)
126+
.map_err(|_| "Could not encode sidebar order".to_string())?
127+
.len()
128+
> MAX_ORDER_BYTES
129+
{
130+
return Err("Sidebar order is too large".into());
131+
}
132+
Ok(())
133+
}
134+
66135
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
67136
#[serde(rename_all = "camelCase")]
68137
pub struct SelectedProject {
@@ -1312,6 +1381,54 @@ impl LocalBackend {
13121381
.map_err(|_| "Could not save remote sidebar settings".to_string())?
13131382
}
13141383

1384+
/// Sidebar order is account-owned controller presentation state, independent
1385+
/// of the execution host's project ordering and navigation cursors.
1386+
pub async fn get_sidebar_order(
1387+
&self,
1388+
user_id: &str,
1389+
) -> Result<crate::settings::SidebarOrder, String> {
1390+
let (store, account) = self.presentation_account(user_id)?;
1391+
tokio::task::spawn_blocking(move || {
1392+
let order = match store
1393+
.account_setting(account, "sidebar.order")
1394+
.map_err(|error| error.to_string())?
1395+
{
1396+
Some(value) => serde_json::from_value(value)
1397+
.map_err(|_| "Saved sidebar order is invalid".to_string())?,
1398+
None => crate::settings::SidebarOrder::default(),
1399+
};
1400+
validate_sidebar_order(&order)?;
1401+
Ok(order)
1402+
})
1403+
.await
1404+
.map_err(|_| "Could not read sidebar order".to_string())?
1405+
}
1406+
1407+
pub async fn set_sidebar_order(
1408+
&self,
1409+
user_id: &str,
1410+
order: crate::settings::SidebarOrder,
1411+
) -> Result<(), String> {
1412+
validate_sidebar_order(&order)?;
1413+
// Host dispatch already holds the account read gate. Reuse the
1414+
// presentation writer gate without taking a nested account read lock.
1415+
let _gate = self.ui_settings_gate.lock().await;
1416+
let (store, account) = self.presentation_account(user_id)?;
1417+
tokio::task::spawn_blocking(move || {
1418+
store
1419+
.set_account_setting(
1420+
account,
1421+
"sidebar.order",
1422+
&serde_json::to_value(order)
1423+
.map_err(|_| "Could not encode sidebar order".to_string())?,
1424+
crate::settings::timestamp_ms(),
1425+
)
1426+
.map_err(|error| error.to_string())
1427+
})
1428+
.await
1429+
.map_err(|_| "Could not save sidebar order".to_string())?
1430+
}
1431+
13151432
pub async fn set_task_pinned(
13161433
&self,
13171434
user_id: &str,

0 commit comments

Comments
 (0)