From 9afe21819fcf6696a51fd3e21f55f29c23feb6ef Mon Sep 17 00:00:00 2001 From: iris Date: Wed, 4 Nov 2026 00:23:56 +0100 Subject: [PATCH] swarm-ui: read the agent's grant once for the internet and permissions controls, and save one permission change at a time --- .../swarm-ui/src/agent/detail/AgentDetail.tsx | 29 ++++++- .../src/agent/detail/AgentPermissions.tsx | 76 +++++++++++-------- .../multiselect-filter/MultiselectFilter.tsx | 4 + 3 files changed, 73 insertions(+), 36 deletions(-) diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx index 60e0a6b9..04b809cf 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx @@ -17,7 +17,11 @@ import type { AgentActions } from "../roster/AgentActions.js"; import { AgentInference } from "./AgentInference.js"; import { AgentInstructions } from "./AgentInstructions.js"; import { AgentLimits } from "./AgentLimits.js"; -import { AgentInternet, AgentPermissions } from "./AgentPermissions.js"; +import { + AgentInternet, + AgentPermissions, + useGrant, +} from "./AgentPermissions.js"; import { AllowedDomains } from "./AllowedDomains.js"; import { AgentStateRow } from "./AgentStateRow.js"; import { AgentTermPreview } from "../terminal/AgentTermPreview.js"; @@ -184,9 +188,21 @@ function SettingsRows({ agent }: { agent: string }) {
+ + + ); +} + +// The internet and permissions rows read and write the one stored grant, so +// they share one read of it: a save in one is seen by the other. Keyed by +// agent, so switching agents remounts it instead of showing the last grant. +function GrantRows({ agent }: { agent: string }) { + const g = useGrant(agent); + return ( + <>
internet
- + {g.grant === null ? g.loading : }
allowed domains
@@ -194,7 +210,14 @@ function SettingsRows({ agent }: { agent: string }) {
permissions
- + {g.grant === null ? ( + — + ) : ( + <> + {g.readFailure} + + + )}
); diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentPermissions.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentPermissions.tsx index 62603d2c..6c0519c8 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentPermissions.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentPermissions.tsx @@ -60,38 +60,48 @@ function permissionsUrl(agent: string): string { return `/api/agents/${encodeURIComponent(agent)}/permissions`; } -// The stored grant of one agent, and what changes it. -function useGrant(agent: string) { +// The stored grant of one agent, and what changes it. One instance serves +// every control of the grant, so a save from one is seen by the others. +export function useGrant(agent: string) { const [grant, setGrant] = useState(null); const [error, setError] = useState(null); - // Bumped after a save or a clear, so the grant is fetched again. + // Bumped by a retry, so the grant is fetched again. const [reload, setReload] = useState(0); const [busy, setBusy] = useState(false); // The refusal of the last save, kept apart from a failed read. const [saveError, setSaveError] = useState(null); - useEffect(() => { - let cancelled = false; - (async () => { + // Reads the grant; false when the read failed. + async function load(isCancelled: () => boolean): Promise { + try { const r = await fetch(permissionsUrl(agent)); if (!r.ok) { - if (!cancelled) setError(await readApiError(r)); - return; + if (!isCancelled()) setError(await readApiError(r)); + return false; } const body = (await r.json()) as Grant; - if (cancelled) return; + if (isCancelled()) return false; setGrant(body); setError(null); - })().catch((e: unknown) => { - if (!cancelled) setError({ detail: String(e) }); - }); + return true; + } catch (e: unknown) { + if (!isCancelled()) setError({ detail: String(e) }); + return false; + } + } + + useEffect(() => { + let cancelled = false; + void load(() => cancelled); return () => { cancelled = true; }; }, [agent, reload]); - // Sends one change and reads the grant again; true when the route took it. + // Sends one change and reads the grant again before it lets go of `busy`, + // so the next change builds on what was stored; true when the route took it. async function send(init: RequestInit): Promise { + if (busy) return false; setBusy(true); setSaveError(null); try { @@ -100,7 +110,7 @@ function useGrant(agent: string) { setSaveError(await readApiError(r)); return false; } - setReload((n) => n + 1); + await load(() => false); return true; } catch (e: unknown) { setSaveError({ detail: String(e) }); @@ -129,14 +139,21 @@ function useGrant(agent: string) { return { grant, busy, saveError, readFailure, loading, send }; } -export function AgentPermissions({ agent }: { agent: string }) { - const { grant, busy, saveError, readFailure, loading, send } = - useGrant(agent); +export type GrantState = ReturnType; + +export function AgentPermissions({ + agent, + g, +}: { + agent: string; + g: GrantState; +}) { + const { grant, busy, saveError, loading, send } = g; const [catalogue, setCatalogue] = useState(null); const [catalogueError, setCatalogueError] = useState( null, ); - // The boxes as ticked; they follow the stored grant once it is read again. + // The boxes as ticked while a change is being saved; null shows the stored grant. const [selected, setSelected] = useState(null); useEffect(() => { @@ -157,14 +174,9 @@ export function AgentPermissions({ agent }: { agent: string }) { }; }, []); - // The stored grant changing (a save went through) resets the boxes to it. - const stored = grant?.permissions; - useEffect(() => { - setSelected(null); - }, [stored]); - if (grant === null) return loading; + const stored = grant.permissions; const ticked = selected ?? (catalogue @@ -172,12 +184,12 @@ export function AgentPermissions({ agent }: { agent: string }) { : []); async function toggle(value: string) { - if (!catalogue) return; + if (!catalogue || busy) return; const next = togglePermission(ticked, value); setSelected(next); // The default set sends no list, so the agent follows the default. const list = requestPermissions(next, catalogue.default); - const ok = await send( + await send( list === undefined ? { method: "DELETE" } : { @@ -186,13 +198,12 @@ export function AgentPermissions({ agent }: { agent: string }) { body: JSON.stringify({ permissions: list }), }, ); - // A refused change puts the boxes back as stored. - if (!ok) setSelected(null); + // The grant was read again by now, or the change refused: show what is stored. + setSelected(null); } return (
- {readFailure} void toggle(v)} + disabled={busy} />
) : ( @@ -236,15 +248,13 @@ export function AgentPermissions({ agent }: { agent: string }) { ); } -export function AgentInternet({ agent }: { agent: string }) { - const { grant, busy, saveError, readFailure, loading, send } = - useGrant(agent); +export function AgentInternet({ agent, g }: { agent: string; g: GrantState }) { + const { grant, busy, saveError, loading, send } = g; if (grant === null) return loading; const mode = internetMode(grant.unrestricted_internet, grant.internet_log); return (
- {readFailure} void; + /** Greys the boxes out, e.g. while a change is being saved. */ + disabled?: boolean; }) { return (
@@ -41,6 +44,7 @@ export function MultiselectFilterOptions({