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 (