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
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.

View file

@ -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 ? (
<p>{agent} is not in the swarm's agent roster</p>
) : (
<p>loading…</p>
<SkeletonLines label="the agent's details" count={4} />
)}
</Panel>
</Popover>

View file

@ -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" : ""}
</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" ? (
<>
<ApiErrorPanel

View file

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

View file

@ -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 ?? (
<span class="ui-agent-limits-muted" role="status">
loading the allowed domains…
</span>
)
errorPanel ?? <BadgeSkeleton what="the allowed domains" width="7em" />
);
}

View file

@ -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() {
<AgentListSkeleton count={2} />
</SkeletonRegion>
</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">
<TableSkeleton columns={TABLE_COLUMNS} label="the rows" />
</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 {
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>
);
}
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>
);
}