diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md
index 3a223f62..49a8f2d3 100644
--- a/docs/swarm/ui.md
+++ b/docs/swarm/ui.md
@@ -46,7 +46,10 @@ The pages that only display (agents, alerts, builds, review runs and the hives
card) show the answer they last saw at once, then replace it with the fresh one
while the spinner runs, so opening one never starts on an empty page. On the very
first visit, the hives card and the alerts table show their own header with three
-grey placeholder rows until the first answer comes. The browser
+grey placeholder rows until the first answer comes. In an opened agent card, a
+setting still loading (permissions, resource caps, internet, allowed
+domains) shows a grey pill the size of its badge, and the AGENTS.md editor a grey
+block. The browser
keeps the answer for itself only, and forgets it after a day, after a new build
of the UI, at **log out**, and when the login ends. The pages with an editor
(schedules, notification flows, broadcast) start empty as before.
diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentDetailPopover.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentDetailPopover.tsx
index 0e0517ad..932b90f9 100644
--- a/frontend/packages/swarm-ui/src/agent/detail/AgentDetailPopover.tsx
+++ b/frontend/packages/swarm-ui/src/agent/detail/AgentDetailPopover.tsx
@@ -16,6 +16,7 @@ import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Panel } from "../../ui/panel/Panel.js";
import { Popover } from "../../ui/popover/Popover.js";
import { useAgentActions } from "../roster/AgentActions.js";
+import { SkeletonLines } from "../../ui/skeleton/Skeleton.js";
import { AgentDetail } from "./AgentDetail.js";
import type { AgentRow } from "../AgentTypes.js";
@@ -83,7 +84,7 @@ export function AgentDetailPopover({
) : row === null ? (
{agent} is not in the swarm's agent roster
) : (
- loading…
+
)}
diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentInstructions.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentInstructions.tsx
index 6cf9cca1..4ca6cd80 100644
--- a/frontend/packages/swarm-ui/src/agent/detail/AgentInstructions.tsx
+++ b/frontend/packages/swarm-ui/src/agent/detail/AgentInstructions.tsx
@@ -27,6 +27,7 @@ import {
STALE_STATUS,
type Instructions,
} from "./instructions.js";
+import { Skeleton, SkeletonRegion } from "../../ui/skeleton/Skeleton.js";
import "./AgentInstructions.css";
// An edit that was not saved: the text, and the version it was made on.
@@ -218,14 +219,16 @@ function InstructionsForm({
role="status"
aria-live="polite"
>
- {load.status === "loading"
- ? "loading…"
- : busy
- ? "saving…"
- : saved
- ? "saved"
- : ""}
+ {busy ? "saving…" : saved ? "saved" : ""}
+ {load.status === "loading" ? (
+
+
+
+
+
+
+ ) : null}
{load.status === "error" ? (
<>
) : null;
if (caps === null) {
- return (
- errorPanel ?? (
-
- loading the resource caps…
-
- )
- );
+ return errorPanel ?? ;
}
return (
diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentPermissions.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentPermissions.tsx
index cad4c6e7..913b6c4d 100644
--- a/frontend/packages/swarm-ui/src/agent/detail/AgentPermissions.tsx
+++ b/frontend/packages/swarm-ui/src/agent/detail/AgentPermissions.tsx
@@ -21,6 +21,7 @@ import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge } from "@hive/shared/badge.js";
import { BadgePopover } from "../../ui/badge-popover/BadgePopover.js";
import { Button } from "../../ui/button/Button.js";
+import { BadgeSkeleton } from "../../ui/skeleton/BadgeSkeleton.js";
import { MultiselectFilterOptions } from "../../ui/multiselect-filter/MultiselectFilter.js";
import {
requestPermissions,
@@ -130,11 +131,7 @@ export function useGrant(agent: string) {
) : null;
const loading =
grant === null
- ? (readFailure ?? (
-
- loading the permissions…
-
- ))
+ ? (readFailure ?? )
: null;
return { grant, busy, saveError, readFailure, loading, send };
}
diff --git a/frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx b/frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx
index d12c0804..35906aa6 100644
--- a/frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx
+++ b/frontend/packages/swarm-ui/src/agent/detail/AllowedDomains.tsx
@@ -39,6 +39,7 @@ import {
type EntryGroup,
} from "./allowed-domains.js";
import { useAllowedDomains } from "./useAllowedDomains.js";
+import { BadgeSkeleton } from "../../ui/skeleton/BadgeSkeleton.js";
import "../../ui/badge-row/BadgeRow.css";
import "./AgentLimits.css";
@@ -77,11 +78,7 @@ export function AllowedDomains({ agent }: { agent: string }) {
) : null;
if (entries === null) {
return (
- errorPanel ?? (
-
- loading the allowed domains…
-
- )
+ errorPanel ??
);
}
diff --git a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx
index 33a844d8..872c66bd 100644
--- a/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx
+++ b/frontend/packages/swarm-ui/src/pages/ComponentsPage.tsx
@@ -9,7 +9,8 @@ import { useRef, useState } from "preact/hooks";
import type { ComponentChildren } from "preact";
import { AgentListSkeleton } from "../agent/roster/AgentCardSkeleton.js";
import { Card } from "../ui/card/Card.js";
-import { SkeletonRegion } from "../ui/skeleton/Skeleton.js";
+import { SkeletonLines, SkeletonRegion } from "../ui/skeleton/Skeleton.js";
+import { BadgeSkeleton } from "../ui/skeleton/BadgeSkeleton.js";
import { FilterableView } from "../ui/filterable-view/FilterableView.js";
import {
MultiselectFilter,
@@ -492,6 +493,15 @@ export function ComponentsPage() {
+
+
+
+
+
+
+
+
+
diff --git a/frontend/packages/swarm-ui/src/ui/skeleton/BadgeSkeleton.tsx b/frontend/packages/swarm-ui/src/ui/skeleton/BadgeSkeleton.tsx
new file mode 100644
index 00000000..934d6dea
--- /dev/null
+++ b/frontend/packages/swarm-ui/src/ui/skeleton/BadgeSkeleton.tsx
@@ -0,0 +1,30 @@
+// — the twin of a for a value that is still loading:
+// the real Badge frame (so the pill has the height and padding the loaded one
+// will have; its settings values are buttons, which keep a 2.75em touch
+// target, see Skeleton.css) with a placeholder shape for its value, and the label the badge
+// will carry when that is known already. For the settings rows of an agent's
+// details, where each value is one badge.
+import { Badge } from "@hive/shared/badge.js";
+import { Skeleton, SkeletonRegion } from "./Skeleton.js";
+
+export function BadgeSkeleton({
+ label,
+ what,
+ width = "5em",
+}: {
+ /** The badge's dim prefix, e.g. "permissions". */
+ label?: string;
+ /** What is loading, as a screen reader says it: "the permissions". */
+ what: string;
+ width?: string;
+}) {
+ return (
+
+ }
+ class="ui-skeleton-badge"
+ />
+
+ );
+}
diff --git a/frontend/packages/swarm-ui/src/ui/skeleton/Skeleton.css b/frontend/packages/swarm-ui/src/ui/skeleton/Skeleton.css
index 35194b08..5914d87e 100644
--- a/frontend/packages/swarm-ui/src/ui/skeleton/Skeleton.css
+++ b/frontend/packages/swarm-ui/src/ui/skeleton/Skeleton.css
@@ -65,3 +65,24 @@
.ui-skeleton-region {
display: contents;
}
+
+/* Lines of text stacked with a line's own spacing. */
+.ui-skeleton-lines {
+ display: flex;
+ flex-direction: column;
+ gap: 0.6em;
+}
+
+/* A badge placeholder as tall and wide-set as an interactive Badge
+ (`.ui-badge-interactive` in @hive/shared's Badge.css: the settings values
+ are all buttons), so the pill does not change size when its value lands. */
+.ui-skeleton-badge {
+ box-sizing: border-box; /* as the button it stands in for */
+ min-height: 2.75em;
+ padding-inline: 0.8em;
+ border-radius: 999px;
+}
+.ui-skeleton-badge .ui-skeleton {
+ display: inline-block;
+ vertical-align: middle;
+}
diff --git a/frontend/packages/swarm-ui/src/ui/skeleton/Skeleton.tsx b/frontend/packages/swarm-ui/src/ui/skeleton/Skeleton.tsx
index 24137e30..78acb84a 100644
--- a/frontend/packages/swarm-ui/src/ui/skeleton/Skeleton.tsx
+++ b/frontend/packages/swarm-ui/src/ui/skeleton/Skeleton.tsx
@@ -46,3 +46,25 @@ export function SkeletonRegion({
);
}
+
+const LINE_WIDTHS = ["90%", "75%", "85%", "60%"];
+
+/** A few lines of text, for a block of prose or a list still loading. */
+export function SkeletonLines({
+ label,
+ count = 3,
+}: {
+ /** What is loading, as a screen reader says it. */
+ label: string;
+ count?: number;
+}) {
+ return (
+
+
+ {Array.from({ length: count }, (_, i) => (
+
+ ))}
+
+
+ );
+}