Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: the agents page shows placeholder cards in the shape of the real ones while the roster loads

A new Skeleton (a shimmering shape, still where motion is off) and a region
that tells assistive tech once that something is loading. The agent card's
header is split into AgentCardLayout, a layout with a slot for each part: the
card fills the slots with the real icon, badges and controls, its new
AgentCardSkeleton twin fills the same slots with placeholder shapes, so the
card that replaces it lands in the same place. The agents page uses the twin in
place of its "loading…" text.
This commit is contained in:
iris 2026-11-05 23:18:33 +01:00 • committed by mara
commit 27c4a593bb
4 changed files with 277 additions and 50 deletions

View file

@ -66,6 +66,10 @@
transform: rotate(-45deg);
transition: transform 0.15s ease;
}
/* The placeholder card keeps the chevron's box without its arrow. */
.ui-agent-card-chevron-blank {
border-color: transparent;
}
.ui-agent-card-row[aria-expanded="true"] .ui-agent-card-chevron {
transform: rotate(45deg);
}

View file

@ -26,6 +26,7 @@ import { termPaths } from "../terminal/AgentTermPreview.js";
import { FRESHNESS, showsLoginNeeded, type AgentRow } from "../AgentTypes.js";
import type { AgentActions } from "./AgentActions.js";
import { WantedMenu } from "./WantedMenu.js";
import { AgentCardLayout } from "./AgentCardLayout.js";
import "./AgentCard.css";
export function AgentCard({
@ -44,24 +45,24 @@ export function AgentCard({
const term = termPaths(row.name);
return (
<Card>
<div
class="ui-agent-card-row"
role="button"
tabIndex={0}
aria-expanded={open}
onClick={toggle}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggle();
}
<AgentCardLayout
rowProps={{
role: "button",
tabIndex: 0,
"aria-expanded": open,
onClick: toggle,
onKeyDown: (e) => {
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggle();
}
},
}}
>
<AgentIcon name={row.name} />
<div class="ui-agent-card-body">
<div class="ui-agent-card-line1">
<span class="ui-agent-card-name">{row.name}</span>
icon={<AgentIcon name={row.name} />}
name={<span class="ui-agent-card-name">{row.name}</span>}
status={
<>
<Badge
tone={tone}
title={
@ -105,39 +106,39 @@ export function AgentCard({
) : null}
</span>
) : null}
<span
class="ui-agent-card-wanted"
onClick={(e) => e.stopPropagation()}
>
</>
}
trailing={
<span
class="ui-agent-card-wanted"
onClick={(e) => e.stopPropagation()}
>
<Badge
variant="quiet"
value={<TerminalIcon />}
title={`open ${row.name}'s terminal in its own tab`}
onClick={() => openTab({ href: term.page, label: term.label })}
/>
<WantedMenu
row={row}
pending={actions.pendingAgents.has(row.name)}
onSelectUp={actions.selectUp}
onSelectOffline={actions.selectOffline}
onSelectPaused={actions.selectPaused}
onDestroy={actions.requestDestroy}
/>
{error ? (
<Badge
variant="quiet"
value={<TerminalIcon />}
title={`open ${row.name}'s terminal in its own tab`}
onClick={() => openTab({ href: term.page, label: term.label })}
tone="negative"
value="failed"
title={error.detail ?? "the declaration failed"}
/>
<WantedMenu
row={row}
pending={actions.pendingAgents.has(row.name)}
onSelectUp={actions.selectUp}
onSelectOffline={actions.selectOffline}
onSelectPaused={actions.selectPaused}
onDestroy={actions.requestDestroy}
/>
{error ? (
<Badge
tone="negative"
value="failed"
title={error.detail ?? "the declaration failed"}
/>
) : null}
</span>
</div>
<div class="ui-agent-card-message">
{row.snapshot?.status_text ?? "—"}
</div>
</div>
<span class="ui-agent-card-chevron" aria-hidden="true" />
</div>
) : null}
</span>
}
message={row.snapshot?.status_text ?? "—"}
chevron={<span class="ui-agent-card-chevron" aria-hidden="true" />}
/>
{open ? (
<div class="ui-agent-card-detail">
<AgentDetail row={row} actions={actions} withTerminal inCard />

View file

@ -0,0 +1,42 @@
// <AgentCardLayout> — the arrangement of an agent card's header, with a slot
// for each part: the icon, the name, the status (freshness and login badges),
// the trailing controls, the message line and the chevron. `AgentCard` fills
// the slots with the real sub components; `AgentCardSkeleton` fills the same
// slots with placeholder shapes, so the card that replaces it lands in the same
// place.
import type { ComponentChildren, JSX } from "preact";
import "./AgentCard.css";
export function AgentCardLayout({
icon,
name,
status,
trailing,
message,
chevron,
rowProps,
}: {
icon: ComponentChildren;
name: ComponentChildren;
status: ComponentChildren;
trailing: ComponentChildren;
message: ComponentChildren;
chevron?: ComponentChildren;
/** Attributes of the header row, for the card that opens on a click. */
rowProps?: JSX.HTMLAttributes<HTMLDivElement>;
}) {
return (
<div class="ui-agent-card-row" {...rowProps}>
{icon}
<div class="ui-agent-card-body">
<div class="ui-agent-card-line1">
{name}
{status}
{trailing}
</div>
<div class="ui-agent-card-message">{message}</div>
</div>
{chevron}
</div>
);
}

View file

@ -0,0 +1,47 @@
// <AgentCardSkeleton> — what an agent card looks like while the roster is
// loading: the same `AgentCardLayout` as `AgentCard`, with a placeholder shape
// in each slot instead of the icon, name, badges, controls and message.
import { Skeleton } from "../../ui/skeleton/Skeleton.js";
import { Card } from "../../ui/card/Card.js";
import { AgentCardLayout } from "./AgentCardLayout.js";
export function AgentCardSkeleton() {
return (
<Card>
<AgentCardLayout
icon={<Skeleton shape="block" width="5em" height="5em" />}
name={<Skeleton width="7em" />}
status={<Skeleton width="9em" height="1.6em" />}
trailing={
// As tall as the real controls (a 2.75em touch target at the badge's
// 0.85em font size), so the message line sits where it does on the
// card.
<span class="ui-agent-card-wanted" style={{ minHeight: "2.34em" }}>
<Skeleton shape="circle" width="1.6em" height="1.6em" />
<Skeleton width="5em" height="1.6em" />
</span>
}
message={<Skeleton width="55%" />}
// The real chevron's box with no arrow, so the trailing controls end at
// the same x.
chevron={
<span
class="ui-agent-card-chevron ui-agent-card-chevron-blank"
aria-hidden="true"
/>
}
/>
</Card>
);
}
/** A few of them, for the page's list while its first answer is awaited. */
export function AgentListSkeleton({ count = 3 }: { count?: number }) {
return (
<>
{Array.from({ length: count }, (_, i) => (
<AgentCardSkeleton key={i} />
))}
</>
);
}