Skip to content

fix(settings): give every capability row its own busy guard - #1881

Merged
vastsa merged 3 commits into
vastsa:mainfrom
SeanLiu326:fix/capability-row-busy-guards
Oct 26, 2026
Merged

vastsa merged 3 commits into
vastsa:mainfrom
SeanLiu326:fix/capability-row-busy-guards

Conversation

@SeanLiu326

Copy link
Copy Markdown
Contributor

What this fixes

Fixes #1880 — the Skills, Subagents and MCP pages shared one busyId slot across all row-keyed operations, which fails in two directions:

  • Cross-row double-submit (Skills/Subagents): the guard reads busyId === key but the finally clears with setBusyId(null) no matter whose request settled. Toggle A, then B, then let A finish: B's switch is re-armed while B's own request is still in flight, and a second click double-fires the host write from B's optimistically flipped value. importSkill had no guard at all.
  • Page-wide freeze (MCP toggle): if (busyId) return blocks every row's switch while one request is pending — the opposite of the behavior agent-capability-settings.test.mjs already pins for these pages ("a busy row does not lock the page").

Change

  • src/lib/row-busy-guard.ts — dependency-free createRowBusyGuard() (begin/end/isBusy over a Set), following the plugin-session-push-gate convention so tests drive it without a renderer.
  • src/hooks/use-row-busy.ts — thin React wrapper; a revision counter re-renders consumers on every claim/release without exposing the Set.
  • The three pages migrate every row-keyed op (toggle, toggleBuiltin, openEdit, remove, move, importSkill, the MCP toggle/remove/move) to rowBusy.begin(key) / end(key), which adds double-click guards where there were none and keeps rows independent where they were serialized.

Unrelated single-slot indicators (testingId, authorizingId, the plugins page's dialog-scoped install job) are intentionally untouched — they are one-at-a-time by design, not row toggles.

Verification

  • test/row-busy-guard.test.mjs — new: unit-drives the guard (independent rows, double-claim refused, first release leaves other rows guarded) and pins the wiring contracts on all three pages, including that the MCP toggle no longer page-freezes.
  • test/agent-capability-settings.test.mjs — 26/26 pass unchanged; extensions-page / mcp-market-panel / settings-import-page — 29/29 pass.
  • tsc --noEmit clean for @pi-desktop/desktop.

One commit on current main (4bedbb4).

The Skills, Subagents and MCP pages shared one busyId slot across all row-keyed operations. Whichever request settled first cleared the slot while other rows were still in flight, re-arming their switches for a second click that double-fired from the optimistic state; the MCP toggle guarded with a plain if (busyId), which froze every row's switch while one request was pending. One independent guard key per row keeps each row double-click-safe while the others stay responsive.
@SeanLiu326
SeanLiu326 force-pushed the fix/capability-row-busy-guards branch from 4d53713 to f3e5cc3 Compare October 25, 2026 16:23
The Electron E2E job's provider-client-certificate probe hung and was SIGKILLed after 65s (dbus failures on the headless runner) — the signature this job recovers from on a re-run, unrelated to this branch's settings-page changes.
@vastsa
vastsa merged commit e99e295 into vastsa:main Oct 26, 2026
5 of 6 checks passed
SeanLiu326 added a commit to SeanLiu326/PI-Desktop that referenced this pull request Oct 27, 2026
The models page and the plugins page ran their row-keyed async operations
through single shared busy slots — busyId, testingId and reloadingId —
with unconditional finally clears and, on the models page, no entry guard
at all. The first request to settle re-armed every row still in flight
(a second click double-fires the host write from an optimistically
flipped state), and the models list-wide busy gate froze all rows for
one row's operation.

Both pages now claim one independent key per row through the use-row-busy
guard introduced for the capability pages (vastsa#1881): toggleEnabled,
removeProvider, saveProviderKey and testProvider on the models page;
confirmReview, runInstall and reloadPlugin on the plugins page. The
plugins page model exposes isPluginBusy(id) instead of the busyId slot,
and the models list keeps only its modal-dialog terms in the page-wide
gate.
SeanLiu326 added a commit to SeanLiu326/PI-Desktop that referenced this pull request Oct 27, 2026
The models page and the plugins page ran their row-keyed async operations
through single shared busy slots — busyId, testingId and reloadingId —
with unconditional finally clears and, on the models page, no entry guard
at all. The first request to settle re-armed every row still in flight
(a second click double-fires the host write from an optimistically
flipped state), and the models list-wide busy gate froze all rows for
one row's operation.

Both pages now claim one independent key per row through the use-row-busy
guard introduced for the capability pages: toggleEnabled, removeProvider,
saveProviderKey and testProvider on the models page; confirmReview,
runInstall and reloadPlugin on the plugins page. The plugins page model
exposes isPluginBusy(id) instead of the busyId slot, and the models list
keeps only its modal-dialog terms in the page-wide gate.

Stacked on vastsa#1881; rebase onto main once it lands.
SeanLiu326 added a commit to SeanLiu326/PI-Desktop that referenced this pull request Oct 27, 2026
The models page and the plugins page ran their row-keyed async operations
through single shared busy slots — busyId, testingId and reloadingId —
with unconditional finally clears and, on the models page, no entry guard
at all. The first request to settle re-armed every row still in flight
(a second click double-fires the host write from an optimistically
flipped state), and the models list-wide busy gate froze all rows for
one row's operation.

Both pages now claim one independent key per row through the use-row-busy
guard introduced for the capability pages (vastsa#1881): toggleEnabled,
removeProvider, saveProviderKey and testProvider on the models page;
confirmReview, runInstall and reloadPlugin on the plugins page. The
plugins page model exposes isPluginBusy(id) instead of the busyId slot,
and the models list keeps only its modal-dialog terms in the page-wide
gate.
vastsa added a commit that referenced this pull request Oct 28, 2026
…rds (#1928)

* fix(settings): give model-provider and plugin rows their own busy guards

The models page and the plugins page ran their row-keyed async operations
through single shared busy slots — busyId, testingId and reloadingId —
with unconditional finally clears and, on the models page, no entry guard
at all. The first request to settle re-armed every row still in flight
(a second click double-fires the host write from an optimistically
flipped state), and the models list-wide busy gate froze all rows for
one row's operation.

Both pages now claim one independent key per row through the use-row-busy
guard introduced for the capability pages (#1881): toggleEnabled,
removeProvider, saveProviderKey and testProvider on the models page;
confirmReview, runInstall and reloadPlugin on the plugins page. The
plugins page model exposes isPluginBusy(id) instead of the busyId slot,
and the models list keeps only its modal-dialog terms in the page-wide
gate.

* test(plugins): pin the marketplace install gates to the per-row busy predicate

plugin-runtime-risk asserted the marketplace/detail install buttons
through the removed single busyId slot; the guard job moved to
isPluginBusy in the parent commit, so the pins follow the predicate.

* fix(settings): keep provider test spinners independent

Track active provider tests by row so one settled request cannot clear another row's in-flight indicator. Cover the page-to-list wiring with a regression check.

---------

Co-authored-by: Lan <48862574+vastsa@users.noreply.github.com>
Co-authored-by: vastsa <hhxk666@gmail.com>

This branch was previously deployed

1 inactive deployment
Preview — e1d5a4b0 Deployed Oct 26, 2026 by vercel[bot]
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.

[Bug] Capability pages share one busy slot across all rows: first settle re-arms in-flight switches, MCP toggle freezes the whole page

2 participants