From a9f0d61daa79cb7b7415aa463369f02447aab69d Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 6 Nov 2026 17:27:55 +0100 Subject: [PATCH] swarm-ui: an agent's settings and AGENTS.md show placeholders while they load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BadgeSkeleton is the twin of Badge: the real Badge frame, as tall as an interactive one (the settings values are buttons), with a placeholder for its value. Permissions, resource caps, internet and allowed domains use it in place of their "loading…" text, the details popover shows lines of text, and the AGENTS.md editor a block, in place of theirs. SkeletonLines is a few stacked lines for such blocks. --- docs/swarm/ui.md | 5 +++- .../src/agent/detail/AgentDetailPopover.tsx | 3 +- .../src/agent/detail/AgentInstructions.tsx | 17 ++++++----- .../swarm-ui/src/agent/detail/AgentLimits.tsx | 9 ++---- .../src/agent/detail/AgentPermissions.tsx | 7 ++--- .../src/agent/detail/AllowedDomains.tsx | 7 ++--- .../swarm-ui/src/pages/ComponentsPage.tsx | 12 +++++++- .../src/ui/skeleton/BadgeSkeleton.tsx | 30 +++++++++++++++++++ .../swarm-ui/src/ui/skeleton/Skeleton.css | 21 +++++++++++++ .../swarm-ui/src/ui/skeleton/Skeleton.tsx | 22 ++++++++++++++ 10 files changed, 106 insertions(+), 27 deletions(-) create mode 100644 frontend/packages/swarm-ui/src/ui/skeleton/BadgeSkeleton.tsx 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) => ( + + ))} +
+
+ ); +}