Skip to content

perf(desktop): stop beachballs on agents menu and thread open#1402

Merged
wesbillman merged 3 commits into
mainfrom
brain/async-agent-commands
Jun 30, 2026
Merged

perf(desktop): stop beachballs on agents menu and thread open#1402
wesbillman merged 3 commits into
mainfrom
brain/async-agent-commands

Conversation

@wesbillman

Copy link
Copy Markdown
Collaborator

Why

Users still hit macOS beachballs after the #1338 scroll/perf work. #1338 fixed the cold-mount cost of building hundreds of message rows (a synchronous markdown-parse longtask). But the remaining reports — a 2-message thread ("size doesn't matter") and the agents menu / start-stop / edit — are a different, count-independent class of main-thread block. This PR fixes both.

Investigation writeup: RESEARCH/BEACHBALL_SECOND_PASS_2026_06_30.md (in the Buzz workspace).

Two root causes, two fixes

A) Agents menu / start / stop / edit — blocking Tauri commands on the main thread

In Tauri v2, a synchronous #[tauri::command] pub fn runs on the main UI thread; only async fn commands get a worker thread. The agent/persona/team commands were sync and did blocking disk I/O + per-process liveness syscalls there. list_managed_agents (agents-menu mount, and refetched after every start/stop/edit) reads agent + persona records from disk, syncs every child process, and builds a per-agent summary — a multi-hundred-ms freeze.

Fix: convert 14 of them to async fn + tokio::task::spawn_blocking, matching the existing pattern (deploy_to_provider, probe_backend_provider). State is re-derived from the owned AppHandle inside the closure (State<'_,_> is borrowed and std::sync::MutexGuard is !Send). The managed_agents_store_lock still serializes the on-disk store, so consistency is unchanged. IPC-transparent: the frontend already awaits these; only the Tauri-injected state param was dropped (never sent from JS).

Converted: list_managed_agents, stop_managed_agent, delete_managed_agent; list_personas, create_persona, update_persona, delete_persona, set_persona_active; list_teams, create_team, update_team, delete_team, install_team_from_directory, sync_team_directory.

Left sync on purpose: reconcile_inbound_persona_event + put_agent_session_config (background per-relay-event paths — async-per-event adds overhead/ordering risk for no UX win), parse_team_file/parse_persona_files (pure CPU, rare import), and the trivial log/settings reads.

B) Opening a thread forced the whole channel reflow into the click task

Opening a thread doesn't unmount the channel timeline; the timeline (up to 2000 rows) and the thread panel are siblings in one flex-row, so opening the panel shrinks the timeline's width and reflows it — a cost that scales with the channel behind the panel, not the 2-message thread. handleOpenThread wrapped the optimistic open in flushSync, which forced that whole reflow to run synchronously inside the discrete click task, blocking paint.

Fix: remove the flushSync from the thread open/close path and move the panel-state batch behind a yielded setTimeout(0) + React.startTransition, so the expensive mount + width reflow no longer blocks the click. (desktop/src/features/channels/useChannelPaneHandlers.ts)

Validation

  • A (Rust): just desktop-tauri-check ✅, rustfmt ✅, 850 tauri unit tests pass ✅. (Local desktop-tauri-clippy shows 6 errors that all pre-exist on clean main in untouched files; this diff adds none. Tauri clippy is not a CI gate.)
  • B (frontend): pnpm typecheck ✅, pnpm build ✅, pnpm check ✅, plus smoke e2e: messaging.spec.ts "opens a single-level thread panel" ✅ and navigation.spec.ts "thread panels" ✅.

Authorship

Two commits, both DCO-clean (authored Wes, signed-off Wes): cause A co-authored Brain, cause B co-authored Pinky.

wesbillman and others added 3 commits June 30, 2026 12:10
…thread

These Tauri commands were synchronous (`pub fn`), so in Tauri v2 they ran on
the main UI thread. Their blocking work — disk reads/writes of agent, persona,
and team records plus per-process liveness syscalls — froze the UI (beachball)
on the agents-menu mount and after every start/stop/edit, which invalidates and
refetches `list_managed_agents`.

Convert them to `async fn` and move each body into `tokio::task::spawn_blocking`
so the blocking work runs on Tauri's worker pool, matching the pattern already
used by `deploy_to_provider` and `probe_backend_provider`. State is re-derived
from the owned `AppHandle` inside the closure because `State<'_, _>` is borrowed
and `std::sync::MutexGuard` is not `Send`; the existing `managed_agents_store_lock`
still serializes the on-disk store, so consistency is unchanged.

Converted: list_managed_agents, stop_managed_agent, delete_managed_agent;
list_personas, create_persona, update_persona, delete_persona, set_persona_active;
list_teams, create_team, update_team, delete_team, install_team_from_directory,
sync_team_directory.

Left synchronous on purpose: reconcile_inbound_persona_event and
put_agent_session_config (background per-relay-event paths), parse_team_file /
parse_persona_files (pure CPU, rare import), and the trivial log/settings reads.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Move thread panel open/close state changes out of the discrete click task and into a transitioned timer. This removes the explicit flushSync path that forced the channel timeline width reflow to happen synchronously while opening a thread.

Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
The async conversions pushed agents.rs to 1464 lines, over its file-size
guard ceiling (1437). Rather than bump the limit, extract the two
self-contained backend-provider commands (`discover_backend_providers`,
`probe_backend_provider`) into `commands/agent_providers.rs`, matching the
existing `agent_discovery`/`agent_models`/`agent_config` split pattern. This
brings agents.rs to 1420 lines (under the ceiling) with no logic change —
the commands are glob-re-exported through `commands::*`, so the Tauri
invoke-handler registration is unchanged.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
@wesbillman wesbillman merged commit 34d61ac into main Jun 30, 2026
25 checks passed
@wesbillman wesbillman deleted the brain/async-agent-commands branch June 30, 2026 19:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant