Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: an agent's settings and AGENTS.md show placeholders while they load

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.
This commit is contained in:
iris 2026-11-06 17:27:55 +01:00
commit a9f0d61daa
10 changed files with 106 additions and 27 deletions

View file

@ -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 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 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 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 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 of the UI, at **log out**, and when the login ends. The pages with an editor
(schedules, notification flows, broadcast) start empty as before. (schedules, notification flows, broadcast) start empty as before.

View file

@ -16,6 +16,7 @@ import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Panel } from "../../ui/panel/Panel.js"; import { Panel } from "../../ui/panel/Panel.js";
import { Popover } from "../../ui/popover/Popover.js"; import { Popover } from "../../ui/popover/Popover.js";
import { useAgentActions } from "../roster/AgentActions.js"; import { useAgentActions } from "../roster/AgentActions.js";
import { SkeletonLines } from "../../ui/skeleton/Skeleton.js";
import { AgentDetail } from "./AgentDetail.js"; import { AgentDetail } from "./AgentDetail.js";
import type { AgentRow } from "../AgentTypes.js"; import type { AgentRow } from "../AgentTypes.js";
@ -83,7 +84,7 @@ export function AgentDetailPopover({
) : row === null ? ( ) : row === null ? (
<p>{agent} is not in the swarm's agent roster</p> <p>{agent} is not in the swarm's agent roster</p>
) : ( ) : (
<p>loading…</p> <SkeletonLines label="the agent's details" count={4} />
)} )}
</Panel> </Panel>
</Popover> </Popover>

View file

@ -27,6 +27,7 @@ import {
STALE_STATUS, STALE_STATUS,
type Instructions, type Instructions,
} from "./instructions.js"; } from "./instructions.js";
import { Skeleton, SkeletonRegion } from "../../ui/skeleton/Skeleton.js";
import "./AgentInstructions.css"; import "./AgentInstructions.css";
// An edit that was not saved: the text, and the version it was made on. // An edit that was not saved: the text, and the version it was made on.
@ -218,14 +219,16 @@ function InstructionsForm({
role="status" role="status"
aria-live="polite" aria-live="polite"
> >
{load.status === "loading" {busy ? "saving…" : saved ? "saved" : ""}
? "loading…"
: busy
? "saving…"
: saved
? "saved"
: ""}
</p> </p>
{load.status === "loading" ? (
<SkeletonRegion label="AGENTS.md">
<div class="ui-skeleton-lines">
<Skeleton width="6em" />
<Skeleton shape="block" width="100%" height="24em" />
</div>
</SkeletonRegion>
) : null}
{load.status === "error" ? ( {load.status === "error" ? (
<> <>
<ApiErrorPanel <ApiErrorPanel

View file

@ -20,6 +20,7 @@ import { Button } from "../../ui/button/Button.js";
import { Dialog } from "../../ui/dialog/Dialog.js"; import { Dialog } from "../../ui/dialog/Dialog.js";
import { Panel } from "../../ui/panel/Panel.js"; import { Panel } from "../../ui/panel/Panel.js";
import { TextField } from "../../ui/text-field/TextField.js"; import { TextField } from "../../ui/text-field/TextField.js";
import { BadgeSkeleton } from "../../ui/skeleton/BadgeSkeleton.js";
import "../../ui/badge-row/BadgeRow.css"; import "../../ui/badge-row/BadgeRow.css";
import "./AgentLimits.css"; import "./AgentLimits.css";
@ -127,13 +128,7 @@ export function AgentLimits({ agent }: { agent: string }) {
</> </>
) : null; ) : null;
if (caps === null) { if (caps === null) {
return ( return errorPanel ?? <BadgeSkeleton what="the resource caps" width="9em" />;
errorPanel ?? (
<span class="ui-agent-limits-muted" role="status">
loading the resource caps…
</span>
)
);
} }
return ( return (

View file

@ -21,6 +21,7 @@ import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge } from "@hive/shared/badge.js"; import { Badge } from "@hive/shared/badge.js";
import { BadgePopover } from "../../ui/badge-popover/BadgePopover.js"; import { BadgePopover } from "../../ui/badge-popover/BadgePopover.js";
import { Button } from "../../ui/button/Button.js"; import { Button } from "../../ui/button/Button.js";
import { BadgeSkeleton } from "../../ui/skeleton/BadgeSkeleton.js";
import { MultiselectFilterOptions } from "../../ui/multiselect-filter/MultiselectFilter.js"; import { MultiselectFilterOptions } from "../../ui/multiselect-filter/MultiselectFilter.js";
import { import {
requestPermissions, requestPermissions,
@ -130,11 +131,7 @@ export function useGrant(agent: string) {
) : null; ) : null;
const loading = const loading =
grant === null grant === null
? (readFailure ?? ( ? (readFailure ?? <BadgeSkeleton what="the permissions" />)
<span class="ui-agent-limits-muted" role="status">
loading the permissions…
</span>
))
: null; : null;
return { grant, busy, saveError, readFailure, loading, send }; return { grant, busy, saveError, readFailure, loading, send };
} }

View file

@ -39,6 +39,7 @@ import {
type EntryGroup, type EntryGroup,
} from "./allowed-domains.js"; } from "./allowed-domains.js";
import { useAllowedDomains } from "./useAllowedDomains.js"; import { useAllowedDomains } from "./useAllowedDomains.js";
import { BadgeSkeleton } from "../../ui/skeleton/BadgeSkeleton.js";
import "../../ui/badge-row/BadgeRow.css"; import "../../ui/badge-row/BadgeRow.css";
import "./AgentLimits.css"; import "./AgentLimits.css";
@ -77,11 +78,7 @@ export function AllowedDomains({ agent }: { agent: string }) {
) : null; ) : null;
if (entries === null) { if (entries === null) {
return ( return (
errorPanel ?? ( errorPanel ?? <BadgeSkeleton what="the allowed domains" width="7em" />
<span class="ui-agent-limits-muted" role="status">
loading the allowed domains…
</span>
)
); );
} }

View file

@ -9,7 +9,8 @@ import { useRef, useState } from "preact/hooks";
import type { ComponentChildren } from "preact"; import type { ComponentChildren } from "preact";
import { AgentListSkeleton } from "../agent/roster/AgentCardSkeleton.js"; import { AgentListSkeleton } from "../agent/roster/AgentCardSkeleton.js";
import { Card } from "../ui/card/Card.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 { FilterableView } from "../ui/filterable-view/FilterableView.js";
import { import {
MultiselectFilter, MultiselectFilter,
@ -492,6 +493,15 @@ export function ComponentsPage() {
<AgentListSkeleton count={2} /> <AgentListSkeleton count={2} />
</SkeletonRegion> </SkeletonRegion>
</Sample> </Sample>
<Sample label="badge placeholders">
<div style={{ display: "flex", gap: "0.5em" }}>
<BadgeSkeleton what="the permissions" />
<BadgeSkeleton what="the resource caps" width="9em" />
</div>
</Sample>
<Sample label="text placeholder">
<SkeletonLines label="the details" />
</Sample>
<Sample label="table placeholder"> <Sample label="table placeholder">
<TableSkeleton columns={TABLE_COLUMNS} label="the rows" /> <TableSkeleton columns={TABLE_COLUMNS} label="the rows" />
</Sample> </Sample>

View file

@ -0,0 +1,30 @@
// <BadgeSkeleton> — the twin of a <Badge> 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 (
<SkeletonRegion label={what}>
<Badge
label={label}
value={<Skeleton width={width} />}
class="ui-skeleton-badge"
/>
</SkeletonRegion>
);
}

View file

@ -65,3 +65,24 @@
.ui-skeleton-region { .ui-skeleton-region {
display: contents; 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;
}

View file

@ -46,3 +46,25 @@ export function SkeletonRegion({
</div> </div>
); );
} }
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 (
<SkeletonRegion label={label}>
<div class="ui-skeleton-lines">
{Array.from({ length: count }, (_, i) => (
<Skeleton key={i} width={LINE_WIDTHS[i % LINE_WIDTHS.length]} />
))}
</div>
</SkeletonRegion>
);
}