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:
parent
38505a5864
commit
a9f0d61daa
10 changed files with 106 additions and 27 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
30
frontend/packages/swarm-ui/src/ui/skeleton/BadgeSkeleton.tsx
Normal file
30
frontend/packages/swarm-ui/src/ui/skeleton/BadgeSkeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue