diff --git a/docs/2-handbook/swarm/ui.md b/docs/2-handbook/swarm/ui.md
index 066313df..0bdb65f3 100644
--- a/docs/2-handbook/swarm/ui.md
+++ b/docs/2-handbook/swarm/ui.md
@@ -611,6 +611,7 @@ to more than one agent at once, and the messages show up in the list below as th
go. Folding it again keeps what you typed and ticked. Pick the
agents from a grid of their icons. **running** shows the agents that report
themselves running and **all** shows every agent; the browser keeps the choice.
+While the agents load, the grid shows grey placeholder chips of the same size.
**select all** ticks every agent the filter shows, and **clear** unticks them.
The composer skips an agent you ticked and then hid with the filter. Type the
message and press **send to N agents**. The text goes exactly as typed, as an
@@ -739,7 +740,8 @@ swarm. The tick is off by default, so an entry is for this agent alone.
The **egress** card on `/swarm` has two lists of the swarm-wide entries, one per line, with
**remove** beside each and an **add** that opens the same dialog without the tick (the
-entry is always for every agent). **allowed** is the list above. **denied** is the swarm's
+entry is always for every agent). While a list loads, it shows grey placeholder
+rows. **allowed** is the list above. **denied** is the swarm's
deny list: every egress mode refuses a denied name, even when an allowed entry, a hive's
own `allowedDomains` or the unrestricted internet flag would let it through. The card says
which hives haven't applied the current deny list yet; until they do, their proxies may still let
diff --git a/frontend/packages/swarm-ui/src/pages/broadcast/BroadcastComposer.css b/frontend/packages/swarm-ui/src/pages/broadcast/BroadcastComposer.css
index fbd88b4c..e8fa201a 100644
--- a/frontend/packages/swarm-ui/src/pages/broadcast/BroadcastComposer.css
+++ b/frontend/packages/swarm-ui/src/pages/broadcast/BroadcastComposer.css
@@ -157,3 +157,16 @@
.ui-broadcast-fail .ui-broadcast-outcome-name {
color: var(--fg);
}
+/* The loading twin's chip: not a control, so no pointer. */
+.ui-broadcast-agent-loading {
+ cursor: default;
+}
+/* Its name and state lines take the height of a line of text, so the chip is
+ the height of a real one. */
+.ui-broadcast-agent-loading .ui-broadcast-name,
+.ui-broadcast-agent-loading .ui-broadcast-state {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 1.6em;
+}
diff --git a/frontend/packages/swarm-ui/src/pages/broadcast/BroadcastComposer.tsx b/frontend/packages/swarm-ui/src/pages/broadcast/BroadcastComposer.tsx
index ca58ba1a..359d8643 100644
--- a/frontend/packages/swarm-ui/src/pages/broadcast/BroadcastComposer.tsx
+++ b/frontend/packages/swarm-ui/src/pages/broadcast/BroadcastComposer.tsx
@@ -23,6 +23,7 @@ import {
} from "../../ui/refresh-interval/RefreshInterval.js";
import { EmptyState } from "../../ui/empty-state/EmptyState.js";
import { Hint } from "../../ui/hint/Hint.js";
+import { Skeleton, SkeletonRegion } from "../../ui/skeleton/Skeleton.js";
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js";
import {
@@ -59,6 +60,30 @@ function storedFilter(): AgentFilter {
}
}
+// The twin of the agent grid while the roster loads: the same grid and the
+// same chip (border, icon, name, state) with a placeholder in each slot.
+function RosterSkeleton({ count = 6 }: { count?: number }) {
+ return (
+
+
+ {Array.from({ length: count }, (_, i) => (
+ -
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+}
+
export function BroadcastComposer() {
const [intervalMs, setIntervalMs] =
useState(DEFAULT_INTERVAL_MS);
@@ -183,7 +208,9 @@ export function BroadcastComposer() {
/>
- {agents !== null && shown.length === 0 ? (
+ {agents === null ? (
+
+ ) : shown.length === 0 ? (
{filter === "running" && all.length > 0
? "no agent is running right now. Show all to pick a stopped one."
diff --git a/frontend/packages/swarm-ui/src/pages/swarm/EgressCard.tsx b/frontend/packages/swarm-ui/src/pages/swarm/EgressCard.tsx
index 3fb165e6..126b34d7 100644
--- a/frontend/packages/swarm-ui/src/pages/swarm/EgressCard.tsx
+++ b/frontend/packages/swarm-ui/src/pages/swarm/EgressCard.tsx
@@ -26,6 +26,7 @@ import { LazyDisclosure } from "../../ui/disclosure/LazyDisclosure.js";
import { Dialog } from "../../ui/dialog/Dialog.js";
import { Hint } from "../../ui/hint/Hint.js";
import { SectionHeading } from "../../ui/section-heading/SectionHeading.js";
+import { Skeleton, SkeletonRegion } from "../../ui/skeleton/Skeleton.js";
import "../../agent/detail/AgentLimits.css";
import "./SwarmPage.css";
@@ -67,9 +68,18 @@ function DomainSection({
{errorPanel}
{entries === null ? (
errorPanel === null ? (
-
- loading the {kind === "deny" ? "denied" : "allowed"} domains…
-
+
+
+ {[0, 1, 2].map((i) => (
+ -
+
+
+
+ ))}
+
+
) : null
) : (
<>