From 7dcdfe251d9a811bf5266741312f5527ccac16a3 Mon Sep 17 00:00:00 2001 From: marks Date: Thu, 16 Jul 2026 12:31:54 -0500 Subject: [PATCH 1/2] Add manual Agent project ordering --- frontend/src-tauri/src/agent.rs | 564 ++++++++++++++- frontend/src-tauri/src/lib.rs | 1 + frontend/src/components/AgentMode.tsx | 642 +++++++++++++++--- .../src/services/agentOperationFence.test.ts | 23 + .../src/services/agentProjectOrdering.test.ts | 187 +++++ frontend/src/services/agentProjectOrdering.ts | 189 ++++++ frontend/src/services/agentRuntimeService.ts | 7 + 7 files changed, 1492 insertions(+), 121 deletions(-) create mode 100644 frontend/src/services/agentProjectOrdering.test.ts create mode 100644 frontend/src/services/agentProjectOrdering.ts diff --git a/frontend/src-tauri/src/agent.rs b/frontend/src-tauri/src/agent.rs index abdc29a9..00ace89d 100644 --- a/frontend/src-tauri/src/agent.rs +++ b/frontend/src-tauri/src/agent.rs @@ -196,7 +196,7 @@ pub struct AgentRuntimeStatus { pub active_runs: HashMap, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RecentProjectRoot { pub path: String, @@ -746,7 +746,9 @@ async fn start_runtime_for_user( *guard = Some(runtime); } - let _ = save_recent_project_root_inner(&app_handle, &user_id, &project_root); + // Starting a runtime is project use, not an explicit folder add. In particular, a + // session-derived root may be absent from a legacy capped recent-roots file; registering it + // here would incorrectly move that visible project to the top of the manual order. agent_config.default_project_root = Some(path_string(&project_root)); agent_config.default_model = model; let _ = save_agent_config_inner(&app_handle, &user_id, &agent_config); @@ -940,7 +942,22 @@ pub async fn agent_save_recent_project_root( let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; ensure_account_generation(&state, &account_scope, generation).await?; let project_root = normalize_project_root(Path::new(&path))?; - save_recent_project_root_inner(&app_handle, &user_id, &project_root).map_err(|e| e.to_string()) + register_explicit_project_root_inner(&app_handle, &user_id, &project_root) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn agent_save_project_root_order( + app_handle: AppHandle, + state: State<'_, AgentRuntimeState>, + user_id: String, + paths: Vec, +) -> Result, String> { + let account_scope = account_scope(&user_id)?; + let generation = account_generation(&state, &account_scope).await; + let _runtime_lifecycle_guard = state.runtime_lifecycle.lock().await; + ensure_account_generation(&state, &account_scope, generation).await?; + save_project_root_order_inner(&app_handle, &user_id, paths).map_err(|e| e.to_string()) } #[tauri::command] @@ -1039,7 +1056,8 @@ pub async fn agent_create_session( } } let summary = session_summary(&session); - let _ = save_recent_project_root_inner(&app_handle, &user_id, &root); + // Session creation must not mutate project order. Only explicit folder-add and reorder + // commands may change the persisted project list. let detail = AgentSessionDetail { session: summary.clone(), timeline: Vec::new(), @@ -1102,7 +1120,7 @@ pub async fn agent_list_sessions( }) .map(|session| session_summary(&session)) .collect::>(); - sessions.sort_by(|a, b| b.updated_ms.cmp(&a.updated_ms)); + sort_sessions_newest_first(&mut sessions); Ok(sessions) } @@ -3219,6 +3237,10 @@ fn session_summary(session: &Session) -> AgentSessionSummary { } } +fn sort_sessions_newest_first(sessions: &mut [AgentSessionSummary]) { + sessions.sort_by(|a, b| b.updated_ms.cmp(&a.updated_ms)); +} + fn emit_timeline_item( app_handle: &AppHandle, session_id: &str, @@ -3968,6 +3990,10 @@ fn load_recent_project_roots_inner( user_id: &str, ) -> Result, anyhow::Error> { let path = agent_config_dir(app_handle, user_id)?.join("recent_roots.json"); + load_recent_project_roots_file(&path) +} + +fn read_recent_project_roots_file(path: &Path) -> Result, anyhow::Error> { if !path.exists() { return Ok(Vec::new()); } @@ -3975,33 +4001,139 @@ fn load_recent_project_roots_inner( Ok(serde_json::from_str(&contents)?) } -fn save_recent_project_root_inner( +fn load_recent_project_roots_file(path: &Path) -> Result, anyhow::Error> { + Ok(sanitize_recent_project_roots( + read_recent_project_roots_file(path)?, + )) +} + +fn structurally_valid_project_root(path: &str) -> bool { + !path.is_empty() && !path.contains('\0') && Path::new(path).is_absolute() +} + +fn sanitize_recent_project_roots(roots: Vec) -> Vec { + let mut seen = HashSet::new(); + roots + .into_iter() + .filter(|root| { + structurally_valid_project_root(&root.path) && seen.insert(root.path.clone()) + }) + .collect() +} + +fn project_root_record(path: String, last_used_ms: u128) -> RecentProjectRoot { + let name = Path::new(&path) + .file_name() + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()) + .unwrap_or(&path) + .to_string(); + RecentProjectRoot { + path, + name, + last_used_ms, + } +} + +fn register_explicit_project_root( + roots: Vec, + project_root: &Path, + last_used_ms: u128, +) -> (Vec, bool) { + let original_len = roots.len(); + let mut roots = sanitize_recent_project_roots(roots); + let sanitized = roots.len() != original_len; + let path = path_string(project_root); + if roots.iter().any(|root| root.path == path) { + return (roots, sanitized); + } + + roots.insert(0, project_root_record(path, last_used_ms)); + (roots, true) +} + +fn register_explicit_project_root_file( + file_path: &Path, + project_root: &Path, + last_used_ms: u128, +) -> Result, anyhow::Error> { + let roots = read_recent_project_roots_file(file_path)?; + let (roots, changed) = register_explicit_project_root(roots, project_root, last_used_ms); + if changed { + write_json_file(file_path, &roots)?; + } + Ok(roots) +} + +fn register_explicit_project_root_inner( app_handle: &AppHandle, user_id: &str, project_root: &Path, ) -> Result, anyhow::Error> { - let mut roots = load_recent_project_roots_inner(app_handle, user_id).unwrap_or_default(); - let path = path_string(project_root); - roots.retain(|root| root.path != path); - roots.insert( - 0, - RecentProjectRoot { - name: project_root - .file_name() - .and_then(|value| value.to_str()) - .unwrap_or(&path) - .to_string(), - path, - last_used_ms: unix_ms(), - }, - ); - roots.truncate(20); - let file_path = agent_config_dir(app_handle, user_id)?.join("recent_roots.json"); - write_json_file(&file_path, &roots)?; + register_explicit_project_root_file(&file_path, project_root, unix_ms()) +} + +fn apply_project_root_order( + roots: Vec, + paths: Vec, + last_used_ms: u128, +) -> Result, String> { + let roots = sanitize_recent_project_roots(roots); + let mut requested_paths = Vec::new(); + let mut requested_set = HashSet::new(); + for path in paths { + if structurally_valid_project_root(&path) && requested_set.insert(path.clone()) { + requested_paths.push(path); + } + } + + let missing_paths = roots + .iter() + .filter(|root| !requested_set.contains(&root.path)) + .map(|root| root.path.clone()) + .collect::>(); + if !missing_paths.is_empty() { + return Err(format!( + "Project order is stale and omitted known project roots: {}", + missing_paths.join(", ") + )); + } + + let mut roots_by_path = roots + .into_iter() + .map(|root| (root.path.clone(), root)) + .collect::>(); + Ok(requested_paths + .into_iter() + .map(|path| { + roots_by_path + .remove(&path) + .unwrap_or_else(|| project_root_record(path, last_used_ms)) + }) + .collect()) +} + +fn save_project_root_order_file( + file_path: &Path, + paths: Vec, + last_used_ms: u128, +) -> Result, anyhow::Error> { + let roots = read_recent_project_roots_file(file_path)?; + let roots = apply_project_root_order(roots, paths, last_used_ms).map_err(anyhow::Error::msg)?; + write_json_file(file_path, &roots)?; Ok(roots) } +fn save_project_root_order_inner( + app_handle: &AppHandle, + user_id: &str, + paths: Vec, +) -> Result, anyhow::Error> { + let file_path = agent_config_dir(app_handle, user_id)?.join("recent_roots.json"); + save_project_root_order_file(&file_path, paths, unix_ms()) +} + fn has_active_session_run(active_runs: &HashMap, session_id: &str) -> bool { active_runs.values().any(|run| run.session_id == session_id) } @@ -4048,6 +4180,29 @@ fn path_string(path: &Path) -> String { mod tests { use super::*; + fn recent_roots_test_dir(label: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "maple-agent-recent-roots-{label}-{}-{}", + std::process::id(), + NEXT_RUN_ID.fetch_add(1, Ordering::Relaxed) + )) + } + + fn test_project_path(label: &str) -> String { + std::env::temp_dir() + .join(format!("maple-agent-project-{label}")) + .to_string_lossy() + .to_string() + } + + fn test_recent_root(label: &str, last_used_ms: u128) -> RecentProjectRoot { + project_root_record(test_project_path(label), last_used_ms) + } + + fn recent_root_paths(roots: &[RecentProjectRoot]) -> Vec { + roots.iter().map(|root| root.path.clone()).collect() + } + #[test] fn fresh_agent_config_defaults_to_glm() { assert_eq!(AgentConfig::default().default_model, DEFAULT_AGENT_MODEL); @@ -4285,6 +4440,367 @@ mod tests { let _ = fs::remove_dir_all(test_root); } + #[test] + fn legacy_recent_project_root_order_is_preserved_while_invalid_duplicates_are_sanitized() { + let test_root = recent_roots_test_dir("legacy-order"); + let path = test_root.join("recent_roots.json"); + let mut first = test_recent_root("legacy-first", 10); + first.name = "Preserved first metadata".to_string(); + let second = test_recent_root("legacy-second", 20); + let mut duplicate_first = first.clone(); + duplicate_first.name = "Discarded duplicate metadata".to_string(); + duplicate_first.last_used_ms = 999; + let invalid = RecentProjectRoot { + path: "relative/project".to_string(), + name: "invalid".to_string(), + last_used_ms: 30, + }; + write_json_file( + &path, + &vec![first.clone(), invalid, second.clone(), duplicate_first], + ) + .unwrap(); + + let loaded = load_recent_project_roots_file(&path).unwrap(); + + assert_eq!(loaded, vec![first.clone(), second.clone()]); + let registered = + register_explicit_project_root_file(&path, Path::new(&second.path), 1_000).unwrap(); + assert_eq!(registered, vec![first.clone(), second.clone()]); + assert_eq!( + read_recent_project_roots_file(&path).unwrap(), + vec![first, second] + ); + let _ = fs::remove_dir_all(test_root); + } + + #[test] + fn registering_recent_project_roots_adds_only_genuinely_new_projects_at_the_top() { + let test_root = recent_roots_test_dir("registration"); + let file_path = test_root.join("recent_roots.json"); + let first = test_recent_root("register-first", 10); + let second = test_recent_root("register-second", 20); + let third_path = test_project_path("register-third"); + write_json_file(&file_path, &vec![first.clone(), second.clone()]).unwrap(); + + let original_bytes = fs::read(&file_path).unwrap(); + let existing = + register_explicit_project_root_file(&file_path, Path::new(&second.path), 2_000) + .unwrap(); + assert_eq!(existing, vec![first.clone(), second.clone()]); + assert_eq!(fs::read(&file_path).unwrap(), original_bytes); + + let added = + register_explicit_project_root_file(&file_path, Path::new(&third_path), 3_000).unwrap(); + assert_eq!( + recent_root_paths(&added), + vec![third_path.clone(), first.path.clone(), second.path.clone()] + ); + assert_eq!(added[0].last_used_ms, 3_000); + + let after_add_bytes = fs::read(&file_path).unwrap(); + let touched_again = + register_explicit_project_root_file(&file_path, Path::new(&first.path), 4_000).unwrap(); + assert_eq!(touched_again, added); + assert_eq!(fs::read(&file_path).unwrap(), after_add_bytes); + + let _ = fs::remove_dir_all(test_root); + } + + #[test] + fn only_explicit_folder_add_can_call_recent_project_registration() { + // Starting a runtime and creating/loading a session need the full Goose/Tauri stack in + // command tests. Guard the stronger architectural invariant instead: the registration + // helper has exactly one caller (agent_save_recent_project_root) plus its definition. + // Any attempt to touch recent-root membership from a use/session path fails this test. + let registration_helper = concat!("register_explicit_project_root", "_inner("); + assert_eq!( + include_str!("agent.rs") + .matches(registration_helper) + .count(), + 2 + ); + } + + #[test] + fn resolving_legacy_session_derived_root_preserves_position_when_explicitly_saved() { + let test_root = recent_roots_test_dir("legacy-capped-session-root"); + let file_path = test_root.join("recent_roots.json"); + let saved_roots = (0..20) + .map(|index| { + let path = test_root.join(format!("saved-{index}")); + fs::create_dir_all(&path).unwrap(); + project_root_record(path.to_string_lossy().to_string(), index) + }) + .collect::>(); + let session_derived_root = test_root.join("session-derived"); + fs::create_dir_all(&session_derived_root).unwrap(); + write_json_file(&file_path, &saved_roots).unwrap(); + let original = fs::read(&file_path).unwrap(); + + let resolved = resolve_project_root( + Some(&session_derived_root.to_string_lossy()), + &AgentConfig::default(), + ) + .unwrap(); + + assert_eq!(resolved, session_derived_root.canonicalize().unwrap()); + assert_eq!(fs::read(&file_path).unwrap(), original); + assert_eq!( + load_recent_project_roots_file(&file_path).unwrap(), + saved_roots + ); + + let mut visible_order = recent_root_paths(&saved_roots); + visible_order.push(path_string(&resolved)); + let explicitly_saved = + save_project_root_order_file(&file_path, visible_order.clone(), 2_000).unwrap(); + assert_eq!(recent_root_paths(&explicitly_saved), visible_order); + assert_eq!( + explicitly_saved.last().unwrap().path, + path_string(&resolved) + ); + + let _ = fs::remove_dir_all(test_root); + } + + #[test] + fn explicit_project_root_order_round_trips_first_middle_and_last_positions() { + let test_root = recent_roots_test_dir("round-trip"); + let file_path = test_root.join("recent_roots.json"); + let first = test_recent_root("round-trip-first", 10); + let second = test_recent_root("round-trip-second", 20); + let third = test_recent_root("round-trip-third", 30); + write_json_file( + &file_path, + &vec![first.clone(), second.clone(), third.clone()], + ) + .unwrap(); + + let first_to_middle = vec![second.path.clone(), first.path.clone(), third.path.clone()]; + save_project_root_order_file(&file_path, first_to_middle.clone(), 100).unwrap(); + assert_eq!( + recent_root_paths(&load_recent_project_roots_file(&file_path).unwrap()), + first_to_middle + ); + + let middle_to_first = vec![first.path.clone(), second.path.clone(), third.path.clone()]; + save_project_root_order_file(&file_path, middle_to_first.clone(), 200).unwrap(); + assert_eq!( + recent_root_paths(&load_recent_project_roots_file(&file_path).unwrap()), + middle_to_first + ); + + let first_to_last = vec![second.path.clone(), third.path.clone(), first.path.clone()]; + save_project_root_order_file(&file_path, first_to_last.clone(), 300).unwrap(); + assert_eq!( + recent_root_paths(&load_recent_project_roots_file(&file_path).unwrap()), + first_to_last + ); + + let _ = fs::remove_dir_all(test_root); + } + + #[test] + fn explicit_project_root_order_deduplicates_ignores_malformed_and_adds_offline_roots() { + let test_root = recent_roots_test_dir("request-sanitizing"); + let file_path = test_root.join("recent_roots.json"); + let first = test_recent_root("sanitize-first", 10); + let second = test_recent_root("sanitize-second", 20); + let third = test_recent_root("sanitize-third", 30); + let offline_path = test_root + .join("offline-project") + .to_string_lossy() + .to_string(); + assert!(!Path::new(&offline_path).exists()); + write_json_file( + &file_path, + &vec![first.clone(), second.clone(), third.clone()], + ) + .unwrap(); + + let saved = save_project_root_order_file( + &file_path, + vec![ + second.path.clone(), + second.path.clone(), + "relative/project".to_string(), + String::new(), + third.path.clone(), + format!("{}\0invalid", test_project_path("nul")), + first.path.clone(), + offline_path.clone(), + ], + 400, + ) + .unwrap(); + + assert_eq!( + recent_root_paths(&saved), + vec![second.path, third.path, first.path, offline_path.clone()] + ); + assert_eq!(saved.last().unwrap().last_used_ms, 400); + assert_eq!(load_recent_project_roots_file(&file_path).unwrap(), saved); + + let _ = fs::remove_dir_all(test_root); + } + + #[test] + fn stale_project_root_order_requests_are_rejected_without_modifying_the_file() { + let test_root = recent_roots_test_dir("stale-request"); + let file_path = test_root.join("recent_roots.json"); + let first = test_recent_root("stale-first", 10); + let second = test_recent_root("stale-second", 20); + let third = test_recent_root("stale-third", 30); + write_json_file( + &file_path, + &vec![first.clone(), second.clone(), third.clone()], + ) + .unwrap(); + let original = fs::read(&file_path).unwrap(); + + let error = save_project_root_order_file( + &file_path, + vec![ + third.path.clone(), + "relative/ignored".to_string(), + first.path.clone(), + ], + 500, + ) + .unwrap_err(); + + assert!(error.to_string().contains("stale")); + assert!(error.to_string().contains(&second.path)); + assert_eq!(fs::read(&file_path).unwrap(), original.to_vec()); + assert_eq!( + load_recent_project_roots_file(&file_path).unwrap(), + vec![first, second, third] + ); + + let malformed_only = save_project_root_order_file( + &file_path, + vec![String::new(), "still/relative".to_string()], + 600, + ); + assert!(malformed_only.is_err()); + assert_eq!(fs::read(&file_path).unwrap(), original.to_vec()); + + let _ = fs::remove_dir_all(test_root); + } + + #[test] + fn corrupt_recent_project_roots_are_never_overwritten_by_registration_or_reorder() { + let test_root = recent_roots_test_dir("corrupt-json"); + let file_path = test_root.join("recent_roots.json"); + let original = br#"[{"path":"unterminated""#; + fs::create_dir_all(&test_root).unwrap(); + fs::write(&file_path, original).unwrap(); + let project_path = test_project_path("corrupt-new"); + + assert!( + register_explicit_project_root_file(&file_path, Path::new(&project_path), 700).is_err() + ); + assert_eq!(fs::read(&file_path).unwrap(), original); + + assert!(save_project_root_order_file(&file_path, vec![project_path], 800).is_err()); + assert_eq!(fs::read(&file_path).unwrap(), original); + + let _ = fs::remove_dir_all(test_root); + } + + #[test] + fn recent_project_root_persistence_has_no_twenty_project_cap() { + let test_root = recent_roots_test_dir("more-than-twenty"); + let file_path = test_root.join("recent_roots.json"); + let paths = (0..25) + .map(|index| test_project_path(&format!("uncapped-{index}"))) + .collect::>(); + + for (index, path) in paths.iter().enumerate() { + register_explicit_project_root_file(&file_path, Path::new(path), index as u128) + .unwrap(); + } + assert_eq!( + load_recent_project_roots_file(&file_path).unwrap().len(), + 25 + ); + + let saved = save_project_root_order_file(&file_path, paths.clone(), 900).unwrap(); + assert_eq!(recent_root_paths(&saved), paths); + assert_eq!( + load_recent_project_roots_file(&file_path).unwrap().len(), + 25 + ); + + let _ = fs::remove_dir_all(test_root); + } + + #[test] + fn recent_project_root_files_remain_isolated_by_account_scope() { + let test_root = recent_roots_test_dir("account-isolation"); + let first_scope = account_scope("recent-roots-user-a").unwrap(); + let second_scope = account_scope("recent-roots-user-b").unwrap(); + let first_file = test_root + .join("accounts") + .join(first_scope) + .join("recent_roots.json"); + let second_file = test_root + .join("accounts") + .join(second_scope) + .join("recent_roots.json"); + let first_project = test_project_path("account-a-project"); + let second_project = test_project_path("account-b-project"); + + register_explicit_project_root_file(&first_file, Path::new(&first_project), 1_000).unwrap(); + register_explicit_project_root_file(&second_file, Path::new(&second_project), 2_000) + .unwrap(); + + assert_eq!( + recent_root_paths(&load_recent_project_roots_file(&first_file).unwrap()), + vec![first_project] + ); + assert_eq!( + recent_root_paths(&load_recent_project_roots_file(&second_file).unwrap()), + vec![second_project] + ); + + let _ = fs::remove_dir_all(test_root); + } + + #[test] + fn agent_sessions_remain_sorted_by_updated_time_newest_first() { + let summary = |id: &str, updated_ms: i64| AgentSessionSummary { + id: id.to_string(), + title: id.to_string(), + project_root: test_project_path("session-sort"), + created_ms: 0, + updated_ms, + message_count: 0, + model: None, + mode: DEFAULT_GOOSE_MODE.to_string(), + }; + let mut sessions = vec![ + summary("oldest", 10), + summary("newest", 30), + summary("middle", 20), + ]; + + sort_sessions_newest_first(&mut sessions); + + assert_eq!( + sessions + .into_iter() + .map(|session| session.id) + .collect::>(), + vec![ + "newest".to_string(), + "middle".to_string(), + "oldest".to_string() + ] + ); + } + #[test] fn mcp_selection_distinguishes_defaults_from_explicit_empty() { let configured = normalize_mcp_servers(vec![ diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index c5e199c5..43c95fc4 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -84,6 +84,7 @@ pub fn run() { agent::agent_save_mcp_servers, agent::agent_list_recent_project_roots, agent::agent_save_recent_project_root, + agent::agent_save_project_root_order, agent::agent_create_session, agent::agent_list_sessions, agent::agent_load_session, diff --git a/frontend/src/components/AgentMode.tsx b/frontend/src/components/AgentMode.tsx index 36de481b..2d77aa2c 100644 --- a/frontend/src/components/AgentMode.tsx +++ b/frontend/src/components/AgentMode.tsx @@ -1,4 +1,12 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useReducer, + useRef, + useState +} from "react"; import { useOpenSecret } from "@opensecret/react"; import { AlertCircle, @@ -67,6 +75,16 @@ import { type AgentTimelineItem, type RecentProjectRoot } from "@/services/agentRuntimeService"; +import { + createProjectOrderState, + groupAgentSessionsByRoot, + hasExceededProjectDragThreshold, + mergeAgentProjectRoots, + projectInsertionIndex, + projectOrderForExistingRegistration, + projectOrderReducer, + reorderProjectRoots +} from "@/services/agentProjectOrdering"; import { isMcpConnectionErrorEvent, mcpConnectionErrorMessage, @@ -235,7 +253,11 @@ export function AgentMode({ userId }: { userId: string }) { const isCompactLayout = isMobile || isLandscapeMobile; const [isSidebarOpen, setIsSidebarOpen] = useState(!isCompactLayout); const [runtimeStatus, setRuntimeStatus] = useState(null); - const [recentRoots, setRecentRoots] = useState([]); + const [projectOrderState, dispatchProjectOrder] = useReducer( + projectOrderReducer, + createProjectOrderState([]) + ); + const recentRoots = projectOrderState.visible; const [sessions, setSessions] = useState([]); const [sessionToDelete, setSessionToDelete] = useState(null); const [activeSessionId, setActiveSessionId] = useState(null); @@ -262,6 +284,7 @@ export function AgentMode({ userId }: { userId: string }) { const [isReplacingManualProxy, setIsReplacingManualProxy] = useState(false); const [isStarting, setIsStarting] = useState(false); const [isPermissionModeUpdating, setIsPermissionModeUpdating] = useState(false); + const [isProjectRootRegistrationPending, setIsProjectRootRegistrationPending] = useState(false); const [pendingSendSessionIds, setPendingSendSessionIds] = useState>(() => new Set()); const [pendingSessionSelectionId, setPendingSessionSelectionId] = useState(null); const [activeRunsBySession, setActiveRunsBySession] = useState>({}); @@ -291,6 +314,7 @@ export function AgentMode({ userId }: { userId: string }) { const isAgentModelLockedRef = useRef(false); const mcpSessionLoadGenerationRef = useRef(0); const mcpToggleGenerationRef = useRef(0); + const projectOrderRequestIdRef = useRef(0); const applyAuthoritativeMode = useCallback((value: AgentPermissionMode) => { selectedModeRef.current = value; @@ -343,10 +367,18 @@ export function AgentMode({ userId }: { userId: string }) { return () => cancelAnimationFrame(frame); }, [scrollTimelineToBottom, timelineItems]); + const visibleProjectRoots = useMemo( + () => mergeAgentProjectRoots(recentRoots, projectRoot, sessions), + [projectRoot, recentRoots, sessions] + ); + const visibleProjectRootsRef = useRef([]); + visibleProjectRootsRef.current = visibleProjectRoots; const activeRootLabel = useMemo(() => { if (!projectRoot) return "Select folder"; - return recentRoots.find((root) => root.path === projectRoot)?.name || basename(projectRoot); - }, [projectRoot, recentRoots]); + return ( + visibleProjectRoots.find((root) => root.path === projectRoot)?.name || basename(projectRoot) + ); + }, [projectRoot, visibleProjectRoots]); const activeSessionTitle = useMemo(() => { const activeSession = sessions.find((session) => session.id === activeSessionId); return activeSession ? sessionTitle(activeSession) : "Agent session"; @@ -355,6 +387,7 @@ export function AgentMode({ userId }: { userId: string }) { const activePendingSendKey = activeSessionId || NEW_SESSION_PENDING_KEY; const isSubmitting = pendingSendSessionIds.has(activePendingSendKey); const isSessionSelectionPending = pendingSessionSelectionId !== null; + const isProjectOrderSaving = projectOrderState.pendingRequestId !== null; const areAgentSettingsLocked = !isAuthTransitionReady || isInitializing || @@ -362,6 +395,8 @@ export function AgentMode({ userId }: { userId: string }) { isPermissionModeUpdating || isSessionSelectionPending || isSubmitting || + isProjectOrderSaving || + isProjectRootRegistrationPending || isReplacingManualProxy || hasManualProxyConflict; const hasStartedAgentSession = @@ -588,15 +623,50 @@ export function AgentMode({ userId }: { userId: string }) { } }, [createApiKey, deleteApiKey, trackAgentWorkflow, userId]); - const persistProjectRoot = useCallback( - async (path: string): Promise => { + const enqueueProjectRootMutation = useCallback( + async (mutation: () => Promise): Promise => { const previousOperation = projectRootPersistenceRef.current; - const operation = trackAgentWorkflow(async () => { + const operation = (async () => { await previousOperation; - const [config, roots] = await Promise.all([ - agentRuntimeService.loadConfig(userId), - agentRuntimeService.saveRecentProjectRoot(userId, path) - ]); + return await trackAgentWorkflow(mutation); + })(); + projectRootPersistenceRef.current = operation.then( + () => undefined, + () => undefined + ); + return await operation; + }, + [trackAgentWorkflow] + ); + + const persistSelectedProjectRoot = useCallback( + async (path: string): Promise => { + await enqueueProjectRootMutation(async () => { + const config = await agentRuntimeService.loadConfig(userId); + const nextConfig: AgentConfig = { + ...config, + defaultProjectRoot: path + }; + await agentRuntimeService.saveConfig(userId, nextConfig); + }); + }, + [enqueueProjectRootMutation, userId] + ); + + const registerProjectRoot = useCallback( + async ( + path: string, + orderedVisibleRoots: RecentProjectRoot[] + ): Promise => { + return await enqueueProjectRootMutation(async () => { + const config = await agentRuntimeService.loadConfig(userId); + const existingOrder = projectOrderForExistingRegistration(orderedVisibleRoots, path); + // A legacy-capped project can already be visible through session history without being in + // recent_roots.json. Persist the whole visible order in place instead of promoting that + // existing project as though it were a genuinely new folder. + const roots = existingOrder + ? await agentRuntimeService.saveProjectRootOrder(userId, existingOrder) + : await agentRuntimeService.saveRecentProjectRoot(userId, path); const nextConfig: AgentConfig = { ...config, defaultProjectRoot: path @@ -604,13 +674,41 @@ export function AgentMode({ userId }: { userId: string }) { await agentRuntimeService.saveConfig(userId, nextConfig); return roots; }); - projectRootPersistenceRef.current = operation.then( - () => undefined, - () => undefined + }, + [enqueueProjectRootMutation, userId] + ); + + const persistProjectRootOrder = useCallback( + async (roots: RecentProjectRoot[]): Promise => { + return await enqueueProjectRootMutation(async () => { + return await agentRuntimeService.saveProjectRootOrder( + userId, + roots.map((root) => root.path) + ); + }); + }, + [enqueueProjectRootMutation, userId] + ); + + const saveProjectRootOrder = useCallback( + (nextRoots: RecentProjectRoot[]) => { + if (projectOrderState.pendingRequestId !== null) return; + const requestId = projectOrderRequestIdRef.current + 1; + projectOrderRequestIdRef.current = requestId; + setError(null); + dispatchProjectOrder({ type: "optimistic", requestId, roots: nextRoots }); + + void persistProjectRootOrder(nextRoots).then( + (roots) => { + dispatchProjectOrder({ type: "confirmed", requestId, roots }); + }, + (orderError) => { + dispatchProjectOrder({ type: "rejected", requestId }); + setError(errorMessage(orderError)); + } ); - return await operation; }, - [trackAgentWorkflow, userId] + [persistProjectRootOrder, projectOrderState.pendingRequestId] ); const refreshSessionList = useCallback(async () => { @@ -761,7 +859,7 @@ export function AgentMode({ userId }: { userId: string }) { } applyRuntimeStatus(status, runStateGeneration); - setRecentRoots(roots); + dispatchProjectOrder({ type: "replace", roots }); setMcpServers(savedMcpServers); setNewChatMcpServerNames( new Set(savedMcpServers.filter((server) => server.enabled).map((server) => server.name)) @@ -856,22 +954,24 @@ export function AgentMode({ userId }: { userId: string }) { }); if (typeof selected === "string") { invalidateSessionSelection(); - const interactionGeneration = interactionGenerationRef.current; shouldAutoScrollRef.current = true; setProjectRoot(selected); activeSessionIdRef.current = null; setActiveSessionId(null); setTimelineItems([]); - const roots = await persistProjectRoot(selected); - if (interactionGenerationRef.current === interactionGeneration) { - setRecentRoots(roots); + setIsProjectRootRegistrationPending(true); + try { + const roots = await registerProjectRoot(selected, visibleProjectRootsRef.current); + dispatchProjectOrder({ type: "replace", roots }); + } finally { + setIsProjectRootRegistrationPending(false); } } }); } catch (chooseError) { setError(errorMessage(chooseError)); } - }, [invalidateSessionSelection, persistProjectRoot, trackAgentWorkflow]); + }, [invalidateSessionSelection, registerProjectRoot, trackAgentWorkflow]); const selectProjectRoot = useCallback( (value: string) => { @@ -884,9 +984,8 @@ export function AgentMode({ userId }: { userId: string }) { shouldAutoScrollRef.current = true; void (async () => { try { - const roots = await persistProjectRoot(value); + await persistSelectedProjectRoot(value); if (interactionGenerationRef.current === interactionGeneration) { - setRecentRoots(roots); await refreshSessions(); } } catch (selectError) { @@ -896,7 +995,7 @@ export function AgentMode({ userId }: { userId: string }) { } })(); }, - [invalidateSessionSelection, persistProjectRoot, refreshSessions] + [invalidateSessionSelection, persistSelectedProjectRoot, refreshSessions] ); const selectModel = useCallback((value: string) => { @@ -981,7 +1080,6 @@ export function AgentMode({ userId }: { userId: string }) { const status = restart ? await agentRuntimeService.restartRuntime(userId, request) : await agentRuntimeService.startRuntime(userId, request); - const roots = await agentRuntimeService.listRecentProjectRoots(userId); if ( startRequestGenerationRef.current !== requestGeneration || interactionGenerationRef.current !== interactionGeneration @@ -992,7 +1090,6 @@ export function AgentMode({ userId }: { userId: string }) { setProjectRoot(status.projectRoot || projectRoot); setModel(status.model || model || DEFAULT_MODEL); applyAuthoritativeMode(normalizeAgentPermissionMode(status.mode || requestedMode)); - setRecentRoots(roots); await refreshSessions(); return status; }); @@ -1241,14 +1338,7 @@ export function AgentMode({ userId }: { userId: string }) { finishSessionSelection(selectionGeneration); try { - const roots = await persistProjectRoot(detail.session.projectRoot); - if ( - sessionSelectionGenerationRef.current === selectionGeneration && - interactionGenerationRef.current === interactionGeneration && - activeSessionIdRef.current === detail.session.id - ) { - setRecentRoots(roots); - } + await persistSelectedProjectRoot(detail.session.projectRoot); } catch (persistError) { if ( sessionSelectionGenerationRef.current === selectionGeneration && @@ -1274,7 +1364,7 @@ export function AgentMode({ userId }: { userId: string }) { beginSessionSelection, clearCompletedUnreadSession, finishSessionSelection, - persistProjectRoot, + persistSelectedProjectRoot, replaceSessionTimeline, trackAgentWorkflow, userId @@ -1675,13 +1765,14 @@ export function AgentMode({ userId }: { userId: string }) { } isCompactLayout={isCompactLayout} projectRoot={projectRoot} - recentRoots={recentRoots} + recentRoots={visibleProjectRoots} completedUnreadSessionIds={completedUnreadSessionIds} disabled={areAgentSettingsLocked} runningSessionIds={runningSessionIds} sessions={sessions} onChooseProjectRoot={chooseProjectRoot} onCreateSession={() => void createSession()} + onProjectOrderChange={saveProjectRootOrder} onProjectRootChange={selectProjectRoot} onSessionDelete={setSessionToDelete} onSessionSelect={(sessionId) => void loadSession(sessionId)} @@ -1797,7 +1888,7 @@ export function AgentMode({ userId }: { userId: string }) { mode={mode} model={model} projectRoot={projectRoot} - recentRoots={recentRoots} + recentRoots={visibleProjectRoots} isExpanded={isAgentFullscreen} onCancelPrompt={cancelPrompt} onChooseProjectRoot={chooseProjectRoot} @@ -1839,7 +1930,7 @@ export function AgentMode({ userId }: { userId: string }) { mode={mode} model={model} projectRoot={projectRoot} - recentRoots={recentRoots} + recentRoots={visibleProjectRoots} onCancelPrompt={cancelPrompt} onChooseProjectRoot={chooseProjectRoot} onInputChange={setInput} @@ -1902,11 +1993,31 @@ interface AgentSidebarContentProps { sessions: AgentSessionSummary[]; onChooseProjectRoot: () => void; onCreateSession: () => void; + onProjectOrderChange: (roots: RecentProjectRoot[]) => void; onProjectRootChange: (value: string) => void; onSessionDelete: (session: AgentSessionSummary) => void; onSessionSelect: (sessionId: string) => void; } +interface PendingProjectPointer { + pointerId: number; + path: string; + name: string; + startX: number; + startY: number; + grabOffsetX: number; + grabOffsetY: number; + ghostWidth: number; + headerElement: HTMLElement; +} + +interface ProjectDragState extends PendingProjectPointer { + clientX: number; + clientY: number; + insertionIndex: number | null; + markerTop: number | null; +} + function AgentSidebarContent({ activeSessionId, isCompactLayout, @@ -1918,51 +2029,26 @@ function AgentSidebarContent({ sessions, onChooseProjectRoot, onCreateSession, + onProjectOrderChange, onProjectRootChange, onSessionDelete, onSessionSelect }: AgentSidebarContentProps) { const rowElementsRef = useRef(new Map()); const previousRowTopsRef = useRef(new Map()); + const projectListRef = useRef(null); + const projectGroupElementsRef = useRef(new Map()); + const projectHeaderElementsRef = useRef(new Map()); + const pendingProjectPointerRef = useRef(null); + const projectDragRef = useRef(null); + const autoScrollFrameRef = useRef(null); + const autoScrollDirectionRef = useRef(0); + const suppressProjectClickUntilRef = useRef(0); + const [projectDrag, setProjectDrag] = useState(null); + const isProjectDragging = projectDrag !== null; const [collapsedProjectRoots, setCollapsedProjectRoots] = useState>(() => new Set()); - const { projectRows, sessionsByRoot } = useMemo(() => { - const rootsByPath = new Map(); - const sessionsByProjectRoot = new Map(); - - recentRoots.forEach((root) => rootsByPath.set(root.path, root)); - if (projectRoot && !rootsByPath.has(projectRoot)) { - rootsByPath.set(projectRoot, { - path: projectRoot, - name: basename(projectRoot), - lastUsedMs: Date.now() - }); - } - - sessions.forEach((session) => { - const rootSessions = sessionsByProjectRoot.get(session.projectRoot) || []; - rootSessions.push(session); - sessionsByProjectRoot.set(session.projectRoot, rootSessions); - - const existingRoot = rootsByPath.get(session.projectRoot); - if (!existingRoot || existingRoot.lastUsedMs < session.updatedMs) { - rootsByPath.set(session.projectRoot, { - path: session.projectRoot, - name: basename(session.projectRoot), - lastUsedMs: session.updatedMs - }); - } - }); - - sessionsByProjectRoot.forEach((rootSessions) => { - rootSessions.sort((a, b) => b.updatedMs - a.updatedMs); - }); - - const rows = [...rootsByPath.values()].sort((a, b) => { - return b.lastUsedMs - a.lastUsedMs; - }); - - return { projectRows: rows, sessionsByRoot: sessionsByProjectRoot }; - }, [projectRoot, recentRoots, sessions]); + const projectRows = recentRoots; + const sessionsByRoot = useMemo(() => groupAgentSessionsByRoot(sessions), [sessions]); const setAnimatedRowRef = useCallback((key: string, node: HTMLElement | null) => { if (node) { rowElementsRef.current.set(key, node); @@ -1975,28 +2061,355 @@ function AgentSidebarContent({ const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const previousTops = previousRowTopsRef.current; const nextTops = new Map(); + const deltas = new Map(); + const sessionProjectRoots = new Map( + sessions.map((session) => [session.id, session.projectRoot] as const) + ); rowElementsRef.current.forEach((node, key) => { const nextTop = node.getBoundingClientRect().top; nextTops.set(key, nextTop); - - if (prefersReducedMotion) return; - const previousTop = previousTops.get(key); - if (previousTop === undefined) return; + if (previousTop !== undefined) deltas.set(key, previousTop - nextTop); + }); - const delta = previousTop - nextTop; - if (Math.abs(delta) < 1) return; + if (!prefersReducedMotion) { + rowElementsRef.current.forEach((node, key) => { + let delta = deltas.get(key) || 0; + if (key.startsWith("session:")) { + const sessionId = key.slice("session:".length); + const projectPath = sessionProjectRoots.get(sessionId); + if (projectPath) delta -= deltas.get(`project:${projectPath}`) || 0; + } + if (Math.abs(delta) < 1) return; - node.animate([{ transform: `translateY(${delta}px)` }, { transform: "translateY(0)" }], { - duration: SIDEBAR_REORDER_ANIMATION_MS, - easing: "cubic-bezier(0.2, 0, 0, 1)" + node.animate([{ transform: `translateY(${delta}px)` }, { transform: "translateY(0)" }], { + duration: SIDEBAR_REORDER_ANIMATION_MS, + easing: "cubic-bezier(0.2, 0, 0, 1)" + }); }); - }); + } previousRowTopsRef.current = nextTops; }, [collapsedProjectRoots, projectRows, sessions]); + const setProjectGroupRef = useCallback( + (path: string, node: HTMLDivElement | null) => { + setAnimatedRowRef(`project:${path}`, node); + if (node) { + projectGroupElementsRef.current.set(path, node); + } else { + projectGroupElementsRef.current.delete(path); + } + }, + [setAnimatedRowRef] + ); + + const setProjectHeaderRef = useCallback((path: string, node: HTMLDivElement | null) => { + if (node) { + projectHeaderElementsRef.current.set(path, node); + } else { + projectHeaderElementsRef.current.delete(path); + } + }, []); + + const measureProjectDrop = useCallback( + (clientX: number, clientY: number, draggedPath: string) => { + const list = projectListRef.current; + if (!list) return { insertionIndex: null, markerTop: null }; + const listRect = list.getBoundingClientRect(); + if ( + clientX < listRect.left || + clientX > listRect.right || + clientY < listRect.top || + clientY > listRect.bottom + ) { + return { insertionIndex: null, markerTop: null }; + } + + const centers = projectRows.flatMap((root) => { + const header = projectHeaderElementsRef.current.get(root.path); + if (!header) return []; + const rect = header.getBoundingClientRect(); + return [{ path: root.path, centerY: rect.top + rect.height / 2 }]; + }); + const insertionIndex = projectInsertionIndex(clientY, centers, draggedPath); + if (insertionIndex === null) return { insertionIndex: null, markerTop: null }; + + const remaining = projectRows.filter((root) => root.path !== draggedPath); + let markerViewportY: number; + if (remaining.length === 0) { + markerViewportY = listRect.top + 8; + } else if (insertionIndex < remaining.length) { + const target = projectGroupElementsRef.current.get(remaining[insertionIndex].path); + if (!target) return { insertionIndex: null, markerTop: null }; + markerViewportY = target.getBoundingClientRect().top - 4; + } else { + const target = projectGroupElementsRef.current.get(remaining.at(-1)!.path); + if (!target) return { insertionIndex: null, markerTop: null }; + markerViewportY = target.getBoundingClientRect().bottom + 4; + } + + return { + insertionIndex, + markerTop: Math.max(0, Math.min(listRect.height, markerViewportY - listRect.top)) + }; + }, + [projectRows] + ); + + const stopProjectAutoScroll = useCallback(() => { + autoScrollDirectionRef.current = 0; + if (autoScrollFrameRef.current !== null) { + cancelAnimationFrame(autoScrollFrameRef.current); + autoScrollFrameRef.current = null; + } + }, []); + + const updateProjectDragPosition = useCallback( + (active: ProjectDragState, clientX: number, clientY: number): ProjectDragState => { + const measurement = measureProjectDrop(clientX, clientY, active.path); + const next = { + ...active, + clientX, + clientY, + insertionIndex: measurement.insertionIndex, + markerTop: measurement.markerTop + }; + projectDragRef.current = next; + setProjectDrag(next); + return next; + }, + [measureProjectDrop] + ); + + const updateProjectAutoScroll = useCallback( + (clientX: number, clientY: number) => { + const scrollContainer = projectListRef.current?.closest("nav"); + if (!(scrollContainer instanceof HTMLElement)) { + stopProjectAutoScroll(); + return; + } + const rect = scrollContainer.getBoundingClientRect(); + const edgeSize = 40; + const isInsideHorizontally = clientX >= rect.left && clientX <= rect.right; + const direction = + !isInsideHorizontally || clientY < rect.top || clientY > rect.bottom + ? 0 + : clientY < rect.top + edgeSize + ? -1 + : clientY > rect.bottom - edgeSize + ? 1 + : 0; + autoScrollDirectionRef.current = direction; + if (direction === 0) { + stopProjectAutoScroll(); + return; + } + if (autoScrollFrameRef.current !== null) return; + + const tick = () => { + const active = projectDragRef.current; + const nextDirection = autoScrollDirectionRef.current; + if (!active || nextDirection === 0) { + autoScrollFrameRef.current = null; + return; + } + const previousScrollTop = scrollContainer.scrollTop; + scrollContainer.scrollTop += nextDirection * 10; + if (scrollContainer.scrollTop === previousScrollTop) { + stopProjectAutoScroll(); + return; + } + updateProjectDragPosition(active, active.clientX, active.clientY); + autoScrollFrameRef.current = requestAnimationFrame(tick); + }; + autoScrollFrameRef.current = requestAnimationFrame(tick); + }, + [stopProjectAutoScroll, updateProjectDragPosition] + ); + + const clearProjectDrag = useCallback( + (suppressClick: boolean) => { + const active = projectDragRef.current; + pendingProjectPointerRef.current = null; + projectDragRef.current = null; + setProjectDrag(null); + stopProjectAutoScroll(); + if (active && suppressClick) suppressProjectClickUntilRef.current = Date.now() + 250; + if (active?.headerElement.hasPointerCapture(active.pointerId)) { + active.headerElement.releasePointerCapture(active.pointerId); + } + }, + [stopProjectAutoScroll] + ); + + const beginProjectPointer = useCallback( + (event: React.PointerEvent, root: RecentProjectRoot) => { + if ( + disabled || + !event.isPrimary || + event.button !== 0 || + pendingProjectPointerRef.current || + projectDragRef.current + ) { + return; + } + const rect = event.currentTarget.getBoundingClientRect(); + pendingProjectPointerRef.current = { + pointerId: event.pointerId, + path: root.path, + name: root.name, + startX: event.clientX, + startY: event.clientY, + grabOffsetX: event.clientX - rect.left, + grabOffsetY: event.clientY - rect.top, + ghostWidth: rect.width, + headerElement: event.currentTarget + }; + }, + [disabled] + ); + + const suppressActivatedProjectClick = useCallback((event: React.MouseEvent) => { + if (Date.now() > suppressProjectClickUntilRef.current) return; + suppressProjectClickUntilRef.current = 0; + event.preventDefault(); + event.stopPropagation(); + }, []); + + const finishProjectDrag = useCallback( + (event: PointerEvent) => { + const active = projectDragRef.current; + if (!active || active.pointerId !== event.pointerId) { + if (pendingProjectPointerRef.current?.pointerId === event.pointerId) { + pendingProjectPointerRef.current = null; + } + return; + } + + const measurement = measureProjectDrop(event.clientX, event.clientY, active.path); + const nextRoots = reorderProjectRoots(projectRows, active.path, measurement.insertionIndex); + clearProjectDrag(true); + if (nextRoots !== projectRows) onProjectOrderChange([...nextRoots]); + }, + [clearProjectDrag, measureProjectDrop, onProjectOrderChange, projectRows] + ); + + useEffect(() => { + const handlePointerMove = (event: PointerEvent) => { + const candidate = pendingProjectPointerRef.current; + if (!candidate || candidate.pointerId !== event.pointerId) return; + if (disabled) { + clearProjectDrag(Boolean(projectDragRef.current)); + return; + } + + let active = projectDragRef.current; + if (!active) { + if ( + !hasExceededProjectDragThreshold( + candidate.startX, + candidate.startY, + event.clientX, + event.clientY + ) + ) { + return; + } + const measurement = measureProjectDrop(event.clientX, event.clientY, candidate.path); + active = { + ...candidate, + clientX: event.clientX, + clientY: event.clientY, + insertionIndex: measurement.insertionIndex, + markerTop: measurement.markerTop + }; + projectDragRef.current = active; + setProjectDrag(active); + candidate.headerElement.setPointerCapture(candidate.pointerId); + } else { + active = updateProjectDragPosition(active, event.clientX, event.clientY); + } + + event.preventDefault(); + updateProjectAutoScroll(active.clientX, active.clientY); + }; + const handlePointerUp = (event: PointerEvent) => finishProjectDrag(event); + const handlePointerCancel = (event: PointerEvent) => { + if ( + pendingProjectPointerRef.current?.pointerId === event.pointerId || + projectDragRef.current?.pointerId === event.pointerId + ) { + clearProjectDrag(Boolean(projectDragRef.current)); + } + }; + const handleLostPointerCapture = (event: PointerEvent) => { + if (projectDragRef.current?.pointerId === event.pointerId) clearProjectDrag(true); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape" || !projectDragRef.current) return; + event.preventDefault(); + clearProjectDrag(true); + }; + const handleWindowBlur = () => { + if (projectDragRef.current) clearProjectDrag(true); + else pendingProjectPointerRef.current = null; + }; + + window.addEventListener("pointermove", handlePointerMove, { passive: false }); + window.addEventListener("pointerup", handlePointerUp); + window.addEventListener("pointercancel", handlePointerCancel); + window.addEventListener("lostpointercapture", handleLostPointerCapture); + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("blur", handleWindowBlur); + return () => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + window.removeEventListener("pointercancel", handlePointerCancel); + window.removeEventListener("lostpointercapture", handleLostPointerCapture); + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("blur", handleWindowBlur); + }; + }, [ + clearProjectDrag, + disabled, + finishProjectDrag, + measureProjectDrop, + updateProjectAutoScroll, + updateProjectDragPosition + ]); + + useEffect(() => { + if (!disabled) return; + if (projectDragRef.current) clearProjectDrag(true); + else pendingProjectPointerRef.current = null; + }, [clearProjectDrag, disabled]); + + useEffect(() => { + if (!isProjectDragging) return; + const previousUserSelect = document.body.style.userSelect; + const previousCursor = document.body.style.cursor; + document.body.style.userSelect = "none"; + document.body.style.cursor = "grabbing"; + return () => { + document.body.style.userSelect = previousUserSelect; + document.body.style.cursor = previousCursor; + }; + }, [isProjectDragging]); + + useEffect(() => { + return () => { + const active = projectDragRef.current; + pendingProjectPointerRef.current = null; + projectDragRef.current = null; + stopProjectAutoScroll(); + if (active?.headerElement.hasPointerCapture(active.pointerId)) { + active.headerElement.releasePointerCapture(active.pointerId); + } + }; + }, [stopProjectAutoScroll]); + const toggleProjectCollapsed = useCallback((path: string) => { setCollapsedProjectRoots((current) => { const next = new Set(current); @@ -2039,8 +2452,8 @@ function AgentSidebarContent({ Select a folder ) : ( -
- {projectRows.map((root) => { +
+ {projectRows.map((root, rootIndex) => { const isActive = root.path === projectRoot; const projectSessions = sessionsByRoot.get(root.path) || []; const isCollapsed = collapsedProjectRoots.has(root.path); @@ -2057,10 +2470,24 @@ function AgentSidebarContent({ return (
setAnimatedRowRef(`project:${root.path}`, node)} - className="space-y-2 will-change-transform" + ref={(node) => setProjectGroupRef(root.path, node)} + className={cn( + "space-y-2 will-change-transform", + rootIndex < projectRows.length - 1 && "mb-2", + isProjectDragging && "opacity-25" + )} > -
+
setProjectHeaderRef(root.path, node)} + className={cn( + "flex select-none items-center gap-1 rounded-2xl text-foreground", + !disabled && projectRows.length > 1 && "touch-none", + !disabled && + (projectDrag?.path === root.path ? "cursor-grabbing" : "cursor-grab") + )} + onPointerDown={(event) => beginProjectPointer(event, root)} + onClickCapture={suppressActivatedProjectClick} + >
)} + {projectDrag ? ( + + ) : null} +

Chats @@ -2290,11 +2742,7 @@ function AgentComposer({ onToggleExpanded }: AgentComposerProps) { const textareaRef = useRef(null); - const rootOptions = recentRoots.some((root) => root.path === projectRoot) - ? recentRoots - : projectRoot - ? [{ path: projectRoot, name: activeRootLabel, lastUsedMs: Date.now() }, ...recentRoots] - : recentRoots; + const rootOptions = recentRoots; useLayoutEffect(() => { const textarea = textareaRef.current; diff --git a/frontend/src/services/agentOperationFence.test.ts b/frontend/src/services/agentOperationFence.test.ts index 9dffff2b..9adf4d12 100644 --- a/frontend/src/services/agentOperationFence.test.ts +++ b/frontend/src/services/agentOperationFence.test.ts @@ -71,6 +71,29 @@ describe("AgentOperationFence", () => { block.release(); }); + it("rechecks queued work after its serialized predecessor completes", async () => { + const fence = new AgentOperationFence(); + const predecessor = deferred(); + const firstOperation = fence.run("user-a", async () => await predecessor.promise); + await Promise.resolve(); + + let queuedOperationRan = false; + const queuedOperation = (async () => { + await firstOperation; + return await fence.run("user-a", async () => { + queuedOperationRan = true; + }); + })(); + + const blockPromise = fence.blockAndDrain("user-a"); + predecessor.resolve(); + const block = await blockPromise; + + await expect(queuedOperation).rejects.toBeInstanceOf(AgentOperationsBlockedError); + expect(queuedOperationRan).toBe(false); + block.release(); + }); + it("isolates accounts and keeps blocking until every lease releases", async () => { const fence = new AgentOperationFence(); const first = await fence.blockAndDrain("user-a"); diff --git a/frontend/src/services/agentProjectOrdering.test.ts b/frontend/src/services/agentProjectOrdering.test.ts new file mode 100644 index 00000000..8814837b --- /dev/null +++ b/frontend/src/services/agentProjectOrdering.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, test } from "bun:test"; + +import type { AgentSessionSummary, RecentProjectRoot } from "./agentRuntimeService"; +import { + createProjectOrderState, + groupAgentSessionsByRoot, + hasExceededProjectDragThreshold, + mergeAgentProjectRoots, + projectInsertionIndex, + projectOrderForExistingRegistration, + projectOrderReducer, + reorderProjectRoots +} from "./agentProjectOrdering"; + +function root(path: string, lastUsedMs = 1): RecentProjectRoot { + return { path, name: path.split("/").at(-1) || path, lastUsedMs }; +} + +function session(id: string, projectRoot: string, updatedMs: number): AgentSessionSummary { + return { + id, + title: id, + projectRoot, + createdMs: updatedMs, + updatedMs, + messageCount: 1, + mode: "smart_approve" + }; +} + +describe("mergeAgentProjectRoots", () => { + test("preserves saved order, deduplicates, and appends unseen active and session roots deterministically", () => { + const merged = mergeAgentProjectRoots( + [root("/saved/b", 20), root("/saved/a", 10), root("/saved/b", 30)], + "/active/c", + [session("z", "/session/z", 500), session("d", "/session/d", 100)] + ); + + expect(merged.map((item) => item.path)).toEqual([ + "/saved/b", + "/saved/a", + "/active/c", + "/session/d", + "/session/z" + ]); + expect( + merged + .filter((item) => !item.path.startsWith("/saved/")) + .every((item) => item.lastUsedMs === 0) + ).toBe(true); + }); + + test("keeps an active saved root in its manual position", () => { + const merged = mergeAgentProjectRoots([root("/a"), root("/b")], "/b", []); + expect(merged.map((item) => item.path)).toEqual(["/a", "/b"]); + }); + + test("does not move unsaved session projects when the active session changes", () => { + const saved = [root("/saved")]; + const sessions = [session("x", "/x", 20), session("y", "/y", 10)]; + + expect(mergeAgentProjectRoots(saved, "/x", sessions).map((item) => item.path)).toEqual([ + "/saved", + "/x", + "/y" + ]); + expect(mergeAgentProjectRoots(saved, "/y", sessions).map((item) => item.path)).toEqual([ + "/saved", + "/x", + "/y" + ]); + }); +}); + +describe("projectOrderForExistingRegistration", () => { + test("preserves the visible order when a legacy session-derived project is chosen again", () => { + const visible = mergeAgentProjectRoots([root("/saved/a"), root("/saved/b")], "/saved/a", [ + session("legacy", "/session/legacy", 10) + ]); + + expect(projectOrderForExistingRegistration(visible, "/session/legacy")).toEqual([ + "/saved/a", + "/saved/b", + "/session/legacy" + ]); + expect(projectOrderForExistingRegistration(visible, "/genuinely/new")).toBeNull(); + }); +}); + +describe("groupAgentSessionsByRoot", () => { + test("keeps sessions newest-first inside each project", () => { + const grouped = groupAgentSessionsByRoot([ + session("old", "/a", 1), + session("other", "/b", 3), + session("new", "/a", 2) + ]); + expect(grouped.get("/a")?.map((item) => item.id)).toEqual(["new", "old"]); + expect(grouped.get("/b")?.map((item) => item.id)).toEqual(["other"]); + }); +}); + +describe("project drag helpers", () => { + const roots = [root("/a"), root("/b"), root("/c")]; + + test("uses a six pixel movement threshold", () => { + expect(hasExceededProjectDragThreshold(0, 0, 3, 4)).toBe(false); + expect(hasExceededProjectDragThreshold(0, 0, 6, 0)).toBe(true); + }); + + test("calculates insertion positions before, between, and after non-dragged rows", () => { + const centers = [ + { path: "/a", centerY: 10 }, + { path: "/b", centerY: 30 }, + { path: "/c", centerY: 50 } + ]; + expect(projectInsertionIndex(0, centers, "/b")).toBe(0); + expect(projectInsertionIndex(25, centers, "/b")).toBe(1); + expect(projectInsertionIndex(60, centers, "/b")).toBe(2); + expect(projectInsertionIndex(Number.NaN, centers, "/b")).toBeNull(); + }); + + test("moves projects to first, middle, and last positions", () => { + expect(reorderProjectRoots(roots, "/b", 0).map((item) => item.path)).toEqual([ + "/b", + "/a", + "/c" + ]); + expect(reorderProjectRoots(roots, "/a", 1).map((item) => item.path)).toEqual([ + "/b", + "/a", + "/c" + ]); + expect(reorderProjectRoots(roots, "/a", 2).map((item) => item.path)).toEqual([ + "/b", + "/c", + "/a" + ]); + }); + + test("returns the original order for no-op, cancelled, missing, and invalid drops", () => { + expect(reorderProjectRoots(roots, "/b", 1)).toBe(roots); + expect(reorderProjectRoots(roots, "/b", null)).toBe(roots); + expect(reorderProjectRoots(roots, "/missing", 0)).toBe(roots); + expect(reorderProjectRoots(roots, "/b", 3)).toBe(roots); + }); +}); + +describe("projectOrderReducer", () => { + const original = [root("/a"), root("/b")]; + const reordered = [original[1], original[0]]; + + test("rolls an optimistic order back to the last confirmed order", () => { + const pending = projectOrderReducer(createProjectOrderState(original), { + type: "optimistic", + requestId: 1, + roots: reordered + }); + const rolledBack = projectOrderReducer(pending, { type: "rejected", requestId: 1 }); + expect(rolledBack.visible).toBe(original); + expect(rolledBack.pendingRequestId).toBeNull(); + }); + + test("ignores stale confirmations and failures", () => { + const first = projectOrderReducer(createProjectOrderState(original), { + type: "optimistic", + requestId: 1, + roots: reordered + }); + const second = projectOrderReducer(first, { + type: "optimistic", + requestId: 2, + roots: original + }); + expect(projectOrderReducer(second, { type: "confirmed", requestId: 1, roots: reordered })).toBe( + second + ); + expect(projectOrderReducer(second, { type: "rejected", requestId: 1 })).toBe(second); + + const confirmed = projectOrderReducer(second, { + type: "confirmed", + requestId: 2, + roots: original + }); + expect(confirmed.visible).toBe(original); + expect(confirmed.pendingRequestId).toBeNull(); + }); +}); diff --git a/frontend/src/services/agentProjectOrdering.ts b/frontend/src/services/agentProjectOrdering.ts new file mode 100644 index 00000000..10aedb27 --- /dev/null +++ b/frontend/src/services/agentProjectOrdering.ts @@ -0,0 +1,189 @@ +import type { AgentSessionSummary, RecentProjectRoot } from "@/services/agentRuntimeService"; + +export const PROJECT_DRAG_THRESHOLD_PX = 6; + +export interface ProjectHeaderCenter { + path: string; + centerY: number; +} + +export interface ProjectOrderState { + visible: T[]; + confirmed: T[]; + pendingRequestId: number | null; +} + +export type ProjectOrderAction = + | { type: "replace"; roots: T[] } + | { type: "optimistic"; requestId: number; roots: T[] } + | { type: "confirmed"; requestId: number; roots: T[] } + | { type: "rejected"; requestId: number }; + +export function createProjectOrderState( + roots: T[] +): ProjectOrderState { + return { + visible: roots, + confirmed: roots, + pendingRequestId: null + }; +} + +export function projectOrderReducer( + state: ProjectOrderState, + action: ProjectOrderAction +): ProjectOrderState { + switch (action.type) { + case "replace": + return createProjectOrderState(action.roots); + case "optimistic": + return { + visible: action.roots, + confirmed: state.confirmed, + pendingRequestId: action.requestId + }; + case "confirmed": + if (state.pendingRequestId !== action.requestId) return state; + return createProjectOrderState(action.roots); + case "rejected": + if (state.pendingRequestId !== action.requestId) return state; + return { + visible: state.confirmed, + confirmed: state.confirmed, + pendingRequestId: null + }; + } +} + +export function mergeAgentProjectRoots( + savedRoots: readonly RecentProjectRoot[], + activeRoot: string, + sessions: readonly AgentSessionSummary[] +): RecentProjectRoot[] { + const dedupedSavedRoots: RecentProjectRoot[] = []; + const savedPaths = new Set(); + + for (const root of savedRoots) { + if (!validPath(root.path) || savedPaths.has(root.path)) continue; + savedPaths.add(root.path); + dedupedSavedRoots.push(root); + } + + const merged: RecentProjectRoot[] = []; + const visiblePaths = new Set(); + const addRoot = (root: RecentProjectRoot) => { + if (!validPath(root.path) || visiblePaths.has(root.path)) return; + visiblePaths.add(root.path); + merged.push(root); + }; + + dedupedSavedRoots.forEach(addRoot); + + const unseenPaths = new Set(); + if (validPath(activeRoot) && !visiblePaths.has(activeRoot)) unseenPaths.add(activeRoot); + for (const session of sessions) { + if (validPath(session.projectRoot) && !visiblePaths.has(session.projectRoot)) { + unseenPaths.add(session.projectRoot); + } + } + [...unseenPaths].sort(comparePaths).forEach((path) => addRoot(syntheticRoot(path))); + + return merged; +} + +export function groupAgentSessionsByRoot( + sessions: readonly AgentSessionSummary[] +): Map { + const sessionsByRoot = new Map(); + for (const session of sessions) { + const rootSessions = sessionsByRoot.get(session.projectRoot) || []; + rootSessions.push(session); + sessionsByRoot.set(session.projectRoot, rootSessions); + } + sessionsByRoot.forEach((rootSessions) => { + rootSessions.sort((a, b) => b.updatedMs - a.updatedMs); + }); + return sessionsByRoot; +} + +export function projectOrderForExistingRegistration( + roots: readonly T[], + selectedPath: string +): string[] | null { + if (!validPath(selectedPath) || !roots.some((root) => root.path === selectedPath)) return null; + + const paths: string[] = []; + const seen = new Set(); + for (const root of roots) { + if (!validPath(root.path) || seen.has(root.path)) continue; + seen.add(root.path); + paths.push(root.path); + } + return paths; +} + +export function reorderProjectRoots( + roots: readonly T[], + draggedPath: string, + insertionIndex: number | null +): readonly T[] { + if (insertionIndex === null || !Number.isInteger(insertionIndex) || insertionIndex < 0) { + return roots; + } + + const sourceIndex = roots.findIndex((root) => root.path === draggedPath); + if (sourceIndex < 0) return roots; + + const remaining = roots.filter((_, index) => index !== sourceIndex); + if (insertionIndex > remaining.length) return roots; + + const next = [...remaining]; + next.splice(insertionIndex, 0, roots[sourceIndex]); + if (next.every((root, index) => root === roots[index])) return roots; + return next; +} + +export function projectInsertionIndex( + pointerY: number, + centers: readonly ProjectHeaderCenter[], + draggedPath: string +): number | null { + if (!Number.isFinite(pointerY)) return null; + const candidates = centers.filter( + (row) => row.path !== draggedPath && Number.isFinite(row.centerY) + ); + const index = candidates.findIndex((row) => pointerY < row.centerY); + return index < 0 ? candidates.length : index; +} + +export function hasExceededProjectDragThreshold( + startX: number, + startY: number, + currentX: number, + currentY: number, + threshold = PROJECT_DRAG_THRESHOLD_PX +): boolean { + return Math.hypot(currentX - startX, currentY - startY) >= threshold; +} + +function syntheticRoot(path: string): RecentProjectRoot { + return { + path, + name: basename(path), + lastUsedMs: 0 + }; +} + +function validPath(path: string): boolean { + return typeof path === "string" && path.trim().length > 0; +} + +function comparePaths(a: string, b: string): number { + if (a < b) return -1; + if (a > b) return 1; + return 0; +} + +function basename(path: string): string { + return path.split(/[\\/]/).filter(Boolean).pop() || path; +} diff --git a/frontend/src/services/agentRuntimeService.ts b/frontend/src/services/agentRuntimeService.ts index 142e832c..6f19533f 100644 --- a/frontend/src/services/agentRuntimeService.ts +++ b/frontend/src/services/agentRuntimeService.ts @@ -201,6 +201,13 @@ class AgentRuntimeService { }); } + async saveProjectRootOrder(userId: string, paths: string[]): Promise { + return await this.invokeForUser(userId, "agent_save_project_root_order", { + userId, + paths + }); + } + async createSession( userId: string, request?: AgentCreateSessionRequest From b22853ea4d28e76da5732293a4cd0ddbf671b64d Mon Sep 17 00:00:00 2001 From: marks Date: Thu, 16 Jul 2026 13:30:15 -0500 Subject: [PATCH 2/2] Avoid registering synthetic Agent projects --- frontend/src/components/AgentMode.tsx | 12 ++++++---- .../src/services/agentProjectOrdering.test.ts | 20 ++++++++++------ frontend/src/services/agentProjectOrdering.ts | 24 +++++++++++++++---- 3 files changed, 41 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/AgentMode.tsx b/frontend/src/components/AgentMode.tsx index 2d77aa2c..c67d81ea 100644 --- a/frontend/src/components/AgentMode.tsx +++ b/frontend/src/components/AgentMode.tsx @@ -660,10 +660,14 @@ export function AgentMode({ userId }: { userId: string }) { ): Promise => { return await enqueueProjectRootMutation(async () => { const config = await agentRuntimeService.loadConfig(userId); - const existingOrder = projectOrderForExistingRegistration(orderedVisibleRoots, path); + const existingOrder = projectOrderForExistingRegistration( + orderedVisibleRoots, + projectOrderState.confirmed, + path + ); // A legacy-capped project can already be visible through session history without being in - // recent_roots.json. Persist the whole visible order in place instead of promoting that - // existing project as though it were a genuinely new folder. + // recent_roots.json. Add only that project after the confirmed roots instead of promoting + // it or materializing unrelated session-derived projects. const roots = existingOrder ? await agentRuntimeService.saveProjectRootOrder(userId, existingOrder) : await agentRuntimeService.saveRecentProjectRoot(userId, path); @@ -675,7 +679,7 @@ export function AgentMode({ userId }: { userId: string }) { return roots; }); }, - [enqueueProjectRootMutation, userId] + [enqueueProjectRootMutation, projectOrderState.confirmed, userId] ); const persistProjectRootOrder = useCallback( diff --git a/frontend/src/services/agentProjectOrdering.test.ts b/frontend/src/services/agentProjectOrdering.test.ts index 8814837b..bbb4350b 100644 --- a/frontend/src/services/agentProjectOrdering.test.ts +++ b/frontend/src/services/agentProjectOrdering.test.ts @@ -73,17 +73,23 @@ describe("mergeAgentProjectRoots", () => { }); describe("projectOrderForExistingRegistration", () => { - test("preserves the visible order when a legacy session-derived project is chosen again", () => { - const visible = mergeAgentProjectRoots([root("/saved/a"), root("/saved/b")], "/saved/a", [ - session("legacy", "/session/legacy", 10) - ]); - - expect(projectOrderForExistingRegistration(visible, "/session/legacy")).toEqual([ + const confirmed = [root("/saved/a"), root("/saved/b")]; + const visible = mergeAgentProjectRoots(confirmed, "/saved/a", [ + session("legacy", "/session/legacy", 10), + session("unrelated", "/session/unrelated", 20) + ]); + + test("registers only the selected visible session-derived project", () => { + expect(projectOrderForExistingRegistration(visible, confirmed, "/session/legacy")).toEqual([ "/saved/a", "/saved/b", "/session/legacy" ]); - expect(projectOrderForExistingRegistration(visible, "/genuinely/new")).toBeNull(); + expect(projectOrderForExistingRegistration(visible, confirmed, "/genuinely/new")).toBeNull(); + }); + + test("does not materialize session-derived projects when a saved project is selected", () => { + expect(projectOrderForExistingRegistration(visible, confirmed, "/saved/a")).toBeNull(); }); }); diff --git a/frontend/src/services/agentProjectOrdering.ts b/frontend/src/services/agentProjectOrdering.ts index 10aedb27..41e90642 100644 --- a/frontend/src/services/agentProjectOrdering.ts +++ b/frontend/src/services/agentProjectOrdering.ts @@ -107,15 +107,31 @@ export function groupAgentSessionsByRoot( } export function projectOrderForExistingRegistration( - roots: readonly T[], + visibleRoots: readonly T[], + confirmedRoots: readonly T[], selectedPath: string ): string[] | null { - if (!validPath(selectedPath) || !roots.some((root) => root.path === selectedPath)) return null; + const confirmedPaths = new Set( + confirmedRoots.filter((root) => validPath(root.path)).map((root) => root.path) + ); + if ( + !validPath(selectedPath) || + confirmedPaths.has(selectedPath) || + !visibleRoots.some((root) => root.path === selectedPath) + ) { + return null; + } const paths: string[] = []; const seen = new Set(); - for (const root of roots) { - if (!validPath(root.path) || seen.has(root.path)) continue; + for (const root of visibleRoots) { + if ( + !validPath(root.path) || + seen.has(root.path) || + (root.path !== selectedPath && !confirmedPaths.has(root.path)) + ) { + continue; + } seen.add(root.path); paths.push(root.path); }