fix(desktop): keep member runtime status off the UI thread (#6445)

## Summary
- skip managed-agent runtime discovery when the members sidebar has no
local managed bots
- run runtime listing disk, process, and mutex work on Tauri’s blocking
pool
- preserve local managed-bot status and Start/Stop behavior with
positive and negative E2E coverage

Opening Add people in a human-only channel could invoke synchronous
native runtime discovery before the sidebar painted, leaving the macOS
app beachballed. Human invites do not depend on that data.

## Why
I have seen slowness opening this dialog in the UI


https://github.com/user-attachments/assets/1955eb5e-ee47-4edf-8e5c-606d11ffbc25


### Related issue
Related overlap: #4851 is a broader managed-agent lifecycle change that
includes a similar native offload. This draft is intentionally limited
to the sidebar critical path and adds the human-only query gate.

### Testing
I have verified the pause in the video goes away after this change.

- `just ci`
- `just desktop-check`
- `just desktop-test` (5,241 passed)
- `just desktop-tauri-fmt-check`
- `just desktop-tauri-clippy`
- `just desktop-tauri-test` (2,702 passed; 18 ignored)
- focused Playwright: human-only sidebar skips runtime discovery
- focused Playwright: local managed bot retains status and Stop/Start
controls

No visual styling changed, so screenshots are not applicable.

Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
Matt Toohey
2026-08-25 01:02:14 +10:00
committed by GitHub
parent 2d280376ad
commit 17af15effa
3 changed files with 125 additions and 71 deletions

View File

@@ -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<Vec<ManagedAgentRuntimeStatus>, 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::<AppState>();
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::<AppState>();
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<F, Fut>(_command: F)
where
F: Fn(AppHandle) -> Fut,
Fut: std::future::Future<Output = Result<Vec<ManagedAgentRuntimeStatus>, String>>,
{
}
assert_async_command(list_managed_agent_runtimes);
}
fn payload(
relay_url: &str,
lifecycle: ManagedAgentRuntimeLifecycle,

View File

@@ -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<HTMLInputElement>(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) => {

View File

@@ -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}`,