diff --git a/desktop/src-tauri/src/managed_agents/runtime_commands.rs b/desktop/src-tauri/src/managed_agents/runtime_commands.rs index c0e55184b19..135224d01db 100644 --- a/desktop/src-tauri/src/managed_agents/runtime_commands.rs +++ b/desktop/src-tauri/src/managed_agents/runtime_commands.rs @@ -137,86 +137,91 @@ pub fn put_managed_agent_runtime_lifecycle( Ok(status) } +// Keep disk, process, and mutex work off the main thread so opening members cannot stall the UI. #[tauri::command] -pub fn list_managed_agent_runtimes( +pub async fn list_managed_agent_runtimes( app: AppHandle, ) -> Result, String> { - // This command is polled whenever the members sidebar opens and refetched - // on every status event — load the per-row status inputs once, outside - // the locks, instead of hitting disk per row while holding them. - let personas = load_personas(&app).unwrap_or_default(); - let global = load_global_agent_config(&app).unwrap_or_default(); - let state = app.state::(); - let _transition = state - .managed_agent_runtime_transition - .lock() - .map_err(|e| e.to_string())?; - let _store = state - .managed_agents_store_lock - .lock() - .map_err(|e| e.to_string())?; - let mut records = load_managed_agents(&app)?; - let mut runtimes = state - .managed_agent_processes - .lock() - .map_err(|e| e.to_string())?; - let exited_keys: Vec<_> = runtimes - .iter_mut() - .filter_map(|(key, runtime)| match runtime.child.try_wait() { - Ok(Some(_)) | Err(_) => Some(key.clone()), - Ok(None) => None, - }) - .collect(); - let records_changed = !exited_keys.is_empty(); - let mut statuses = Vec::new(); - for key in exited_keys { - runtimes.remove(&key); - super::remove_agent_runtime_receipt(&app, &key); - state.clear_agent_session_cache(&key); - if let Some(record) = records + tokio::task::spawn_blocking(move || { + // This command is polled whenever the members sidebar opens and refetched + // on every status event — load the per-row status inputs once, outside + // the locks, instead of hitting disk per row while holding them. + let personas = load_personas(&app).unwrap_or_default(); + let global = load_global_agent_config(&app).unwrap_or_default(); + let state = app.state::(); + let _transition = state + .managed_agent_runtime_transition + .lock() + .map_err(|e| e.to_string())?; + let _store = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let mut records = load_managed_agents(&app)?; + let mut runtimes = state + .managed_agent_processes + .lock() + .map_err(|e| e.to_string())?; + let exited_keys: Vec<_> = runtimes .iter_mut() - .find(|record| record.pubkey.eq_ignore_ascii_case(&key.pubkey)) - { - record.updated_at = crate::util::now_iso(); - record.last_stopped_at = Some(record.updated_at.clone()); - let status = status_for_with( + .filter_map(|(key, runtime)| match runtime.child.try_wait() { + Ok(Some(_)) | Err(_) => Some(key.clone()), + Ok(None) => None, + }) + .collect(); + let records_changed = !exited_keys.is_empty(); + let mut statuses = Vec::new(); + for key in exited_keys { + runtimes.remove(&key); + super::remove_agent_runtime_receipt(&app, &key); + state.clear_agent_session_cache(&key); + if let Some(record) = records + .iter_mut() + .find(|record| record.pubkey.eq_ignore_ascii_case(&key.pubkey)) + { + record.updated_at = crate::util::now_iso(); + record.last_stopped_at = Some(record.updated_at.clone()); + let status = status_for_with( + &app, + record, + &key, + None, + None, + StatusInputs { + personas: &personas, + global: &global, + }, + ); + emit_status(&app, &status); + statuses.push(status); + } + } + statuses.extend(runtimes.iter().filter_map(|(key, runtime)| { + let record = records + .iter() + .find(|record| record.pubkey.eq_ignore_ascii_case(&key.pubkey))?; + Some(status_for_with( &app, record, - &key, - None, + key, + Some(runtime), None, StatusInputs { personas: &personas, global: &global, }, - ); - emit_status(&app, &status); - statuses.push(status); + )) + })); + drop(runtimes); + // Records are only mutated above when a runtime exited — skip the store + // rewrite on the common nothing-changed poll. + if records_changed { + save_managed_agents(&app, &records)?; } - } - statuses.extend(runtimes.iter().filter_map(|(key, runtime)| { - let record = records - .iter() - .find(|record| record.pubkey.eq_ignore_ascii_case(&key.pubkey))?; - Some(status_for_with( - &app, - record, - key, - Some(runtime), - None, - StatusInputs { - personas: &personas, - global: &global, - }, - )) - })); - drop(runtimes); - // Records are only mutated above when a runtime exited — skip the store - // rewrite on the common nothing-changed poll. - if records_changed { - save_managed_agents(&app, &records)?; - } - Ok(statuses) + Ok(statuses) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } pub(crate) fn start_managed_agent_runtime_pair_lazy( @@ -572,6 +577,18 @@ pub async fn reconcile_managed_agent_runtimes( mod tests { use super::*; + #[test] + fn list_managed_agent_runtimes_returns_a_future() { + fn assert_async_command(_command: F) + where + F: Fn(AppHandle) -> Fut, + Fut: std::future::Future, String>>, + { + } + + assert_async_command(list_managed_agent_runtimes); + } + fn payload( relay_url: &str, lifecycle: ManagedAgentRuntimeLifecycle, diff --git a/desktop/src/features/channels/ui/MembersSidebar.tsx b/desktop/src/features/channels/ui/MembersSidebar.tsx index 70c7bf10a8a..59d68e4057f 100644 --- a/desktop/src/features/channels/ui/MembersSidebar.tsx +++ b/desktop/src/features/channels/ui/MembersSidebar.tsx @@ -141,9 +141,6 @@ export function MembersSidebar({ relayUrl, }: MembersSidebarProps) { const channelId = channel?.id ?? null; - const managedAgentRuntimesQuery = useManagedAgentRuntimesQuery({ - enabled: open, - }); const queryClient = useQueryClient(); const searchInputRef = React.useRef(null); const [searchQuery, setSearchQuery] = React.useState(""); @@ -470,6 +467,18 @@ export function MembersSidebar({ ), [managedAgentsQuery.data], ); + const hasLocalManagedMember = React.useMemo( + () => + [...bots, ...archived].some( + (member) => + managedAgentByPubkey.get(normalizePubkey(member.pubkey))?.backend + .type === "local", + ), + [archived, bots, managedAgentByPubkey], + ); + const managedAgentRuntimesQuery = useManagedAgentRuntimesQuery({ + enabled: open && Boolean(relayUrl) && hasLocalManagedMember, + }); const controllableManagedBots = React.useMemo( () => bots.flatMap((member) => { diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 52d91dea3ed..6c6a53f91b0 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -4017,6 +4017,25 @@ test("members sidebar virtualizes large channel rosters", async ({ page }) => { ).toBeVisible(); }); +test("opening a human-only members sidebar skips managed runtime discovery", async ({ + page, +}) => { + await page.goto("/"); + const baselineCommands = await readCommandLog(page); + const baselineRuntimeListCount = commandCount( + baselineCommands, + "list_managed_agent_runtimes", + ); + + await openMembersSidebar(page, "random"); + await expect(page.getByTestId("members-sidebar-people")).toBeVisible(); + + const commands = await readCommandLog(page); + expect(commandCount(commands, "list_managed_agent_runtimes")).toBe( + baselineRuntimeListCount, + ); +}); + test("members sidebar can invite relay-authorized agents", async ({ page }) => { await installMockBridge(page, { relayAgents: [ @@ -4528,8 +4547,17 @@ test("members sidebar can stop and start a managed bot in this community", async baselineCommands, "stop_managed_agent", ); + const baselineRuntimeListCount = commandCount( + baselineCommands, + "list_managed_agent_runtimes", + ); await openMembersSidebar(page, "general"); + await expect + .poll(async () => + commandCount(await readCommandLog(page), "list_managed_agent_runtimes"), + ) + .toBe(baselineRuntimeListCount + 1); const agentStatus = page.getByTestId( `sidebar-managed-agent-status-${agentPubkey}`,