Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: shared Hint, ActionRow, Toolbar, EmptyState, ExternalLink and ExpandableCard replace per-page copies

Hint, ActionRow, Toolbar, EmptyState and ExternalLink replace the muted
text, button row, refresh bar, empty line and _blank anchor that each page
restyled under its own class. ExpandableCard (with a loading twin) holds the
builds and recent-runs cards, which had identical head and body CSS. The
create form's raw checkbox becomes CheckboxField. The components page shows
each new piece.
This commit is contained in:
iris 2026-11-06 17:54:24 +01:00 • committed by mara
commit 8a2e5203ad
69 changed files with 689 additions and 427 deletions

View file

@ -221,7 +221,8 @@ commit, whether the run was a try-out or a requested review, how long ago it
started, how long it took, how many classifications ran, and the outcome as a
badge. Select a card to open it: it shows the same result table as the
[try-out](#review-try-out), the overall verdict, the reason when the outcome
has one, and a link to the posted review when there is one.
has one, and a link to the posted review when there is one. While the first
answer loads, the section shows three grey placeholder cards.
An outcome can be more than approve or request changes:
@ -589,7 +590,8 @@ config PR). Each card shows the repo and PR, or for a deploy's build the agent
and `internal/meta`, the first seven characters of the commit, how the build ended (**built**,
**failed** or **error**), when it started and how long it took. Select a card to
read it: for a failed build, what `nix` printed; for a built one, the system
closure; for an error, why no build came back.
closure; for an error, why no build came back. While the first answer loads, the
card shows three grey placeholder cards.
The **Details** link on a `swarm-builder` commit status opens the jobs tab as
`/jobs?run=<id>`, with the builds card open and that build's card in view. Links made before the

View file

@ -8,6 +8,5 @@
margin-top: 1em;
}
.claude-login-ok {
margin-top: 1em;
color: var(--green);
margin-block: 1em;
}

View file

@ -11,6 +11,8 @@ import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Button } from "../../ui/button/Button.js";
import { Dialog } from "../../ui/dialog/Dialog.js";
import { ExternalLink } from "../../ui/external-link/ExternalLink.js";
import { Hint } from "../../ui/hint/Hint.js";
import { Panel } from "../../ui/panel/Panel.js";
import { TextField } from "../../ui/text-field/TextField.js";
import {
@ -98,9 +100,7 @@ export function ClaudeLoginDialog({
<>
<p>
Open{" "}
<a href={step.url} target="_blank" rel="noopener noreferrer">
the claude login page
</a>
<ExternalLink href={step.url}>the claude login page</ExternalLink>
, sign in with this agent's account, and paste the code it shows.
</p>
<form class="claude-login-form" onSubmit={submit}>
@ -122,10 +122,10 @@ export function ClaudeLoginDialog({
</>
)}
{step.step === "stored" && (
<p class="claude-login-ok">
<Hint tone="ok" class="claude-login-ok">
Stored. <strong>{agent}</strong> picks the login up within a minute
and resumes.
</p>
</Hint>
)}
{step.step === "failed" && (
<ApiErrorPanel

View file

@ -17,11 +17,6 @@
flex-wrap: wrap;
gap: 0.4em 1em;
}
.ui-agent-detail-actions {
display: flex;
align-items: center;
gap: 0.75em;
}
/* The folded sections of an opened card sit one under another, each with the
shared disclosure look; the fields keep the grid above. */

View file

@ -11,7 +11,10 @@
// change or a link made here shows wherever else the same row is drawn.
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { Badge } from "@hive/shared/badge.js";
import { ActionRow } from "../../ui/action-row/ActionRow.js";
import { Button } from "../../ui/button/Button.js";
import { ExternalLink } from "../../ui/external-link/ExternalLink.js";
import { Hint } from "../../ui/hint/Hint.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import type { AgentActions } from "../roster/AgentActions.js";
import { AgentInference } from "./AgentInference.js";
@ -118,12 +121,10 @@ function OverviewRows({
<>
<dt>forge</dt>
<dd class="ui-agent-detail-links">
<a href={forgeLinks.profile} target="_blank" rel="noreferrer">
profile
</a>
<a href={forgeLinks.configRepo} target="_blank" rel="noreferrer">
<ExternalLink href={forgeLinks.profile}>profile</ExternalLink>
<ExternalLink href={forgeLinks.configRepo}>
config repo
</a>
</ExternalLink>
</dd>
</>
) : null}
@ -134,13 +135,9 @@ function OverviewRows({
tone="warning"
value={
row.config_pr.html_url ? (
<a
href={row.config_pr.html_url}
target="_blank"
rel="noreferrer"
>
<ExternalLink href={row.config_pr.html_url}>
#{row.config_pr.pr_number}
</a>
</ExternalLink>
) : (
`#${row.config_pr.pr_number}`
)
@ -211,7 +208,7 @@ function GrantRows({ agent }: { agent: string }) {
<dt>permissions</dt>
<dd>
{g.grant === null ? (
<span class="ui-agent-limits-muted">—</span>
<Hint as="span">—</Hint>
) : (
<>
{g.readFailure}
@ -257,13 +254,13 @@ function LinkAccountAction({
actions: AgentActions;
}) {
return (
<div class="ui-agent-detail-actions">
<ActionRow class="ui-agent-detail-actions">
<LinkAccountMenu
agent={row.name}
hasHive={row.hive !== null}
onSelect={(kind) => actions.openLink(row, kind)}
/>
</div>
</ActionRow>
);
}

View file

@ -17,8 +17,7 @@
.ui-agent-inference-source {
margin-left: -0.25em;
}
.ui-agent-inference-source,
.ui-agent-inference-muted {
.ui-agent-inference-source {
color: var(--muted);
}
.ui-agent-inference-form {
@ -28,12 +27,3 @@
gap: 0.75em;
margin-top: 1em;
}
.ui-agent-inference-problem {
margin: 0;
color: var(--red);
}
.ui-agent-inference-actions {
display: flex;
align-items: center;
gap: 0.75em;
}

View file

@ -15,7 +15,9 @@ import { useEffect, useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge } from "@hive/shared/badge.js";
import { ActionRow } from "../../ui/action-row/ActionRow.js";
import { Dialog } from "../../ui/dialog/Dialog.js";
import { Hint } from "../../ui/hint/Hint.js";
import { Panel } from "../../ui/panel/Panel.js";
import { SelectField } from "../../ui/select-field/SelectField.js";
import { TextField } from "../../ui/text-field/TextField.js";
@ -171,7 +173,7 @@ export function InferenceFields({
pattern="[0-9]*"
onInput={(maxSubagents) => onChange({ ...draft, maxSubagents })}
/>
{problem ? <p class="ui-agent-inference-problem">{problem}</p> : null}
{problem ? <Hint tone="error">{problem}</Hint> : null}
</>
);
}
@ -215,7 +217,7 @@ export function AgentInference({ agent }: { agent: string }) {
/>
) : null;
if (view === null) {
return errorPanel ?? <span class="ui-agent-inference-muted">…</span>;
return errorPanel ?? <Hint as="span">…</Hint>;
}
const { configured, effective } = view;
@ -368,7 +370,7 @@ function InferenceForm({
{problem ? (
<ApiErrorPanel context="saving failed" problem={problem} />
) : null}
<div class="ui-agent-inference-actions">
<ActionRow>
<Button
variant="primary"
type="submit"
@ -382,7 +384,7 @@ function InferenceForm({
>
clear the swarm's setting
</Button>
</div>
</ActionRow>
</form>
</Panel>
);

View file

@ -38,9 +38,3 @@
border: 1px solid var(--red);
border-radius: 4px;
}
.ui-agent-instructions-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.75em;
}

View file

@ -16,6 +16,7 @@ import { useEffect, useRef, useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge } from "@hive/shared/badge.js";
import { ActionRow } from "../../ui/action-row/ActionRow.js";
import { Button } from "../../ui/button/Button.js";
import { Dialog } from "../../ui/dialog/Dialog.js";
import { Panel } from "../../ui/panel/Panel.js";
@ -235,9 +236,9 @@ function InstructionsForm({
context="loading AGENTS.md failed"
problem={load.problem}
/>
<div class="ui-agent-instructions-actions">
<ActionRow class="ui-agent-instructions-actions">
<Button onClick={reload}>try again</Button>
</div>
</ActionRow>
</>
) : null}
{load.status === "ready" ? (
@ -263,18 +264,18 @@ function InstructionsForm({
<pre class="ui-agent-instructions-theirs" tabIndex={0}>
{theirs.content === "" ? "(empty)" : theirs.content}
</pre>
<div class="ui-agent-instructions-actions">
<ActionRow class="ui-agent-instructions-actions">
<Button onClick={keepMine}>
keep my text, replace {agent}'s changes
</Button>
<Button onClick={takeTheirs}>use {agent}'s version</Button>
</div>
</ActionRow>
</section>
) : null}
{problem ? (
<ApiErrorPanel context="saving failed" problem={problem} />
) : null}
<div class="ui-agent-instructions-actions">
<ActionRow class="ui-agent-instructions-actions">
<Button
variant="primary"
type="submit"
@ -305,7 +306,7 @@ function InstructionsForm({
>
reload from {agent}
</Button>
</div>
</ActionRow>
</form>
) : null}
</div>

View file

@ -23,10 +23,4 @@
}
.ui-agent-limits-hint {
margin: 0 0 0.5em;
color: var(--muted);
}
.ui-agent-limits-actions {
display: flex;
align-items: center;
gap: 0.75em;
}

View file

@ -16,8 +16,10 @@ import { useEffect, useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge } from "@hive/shared/badge.js";
import { ActionRow } from "../../ui/action-row/ActionRow.js";
import { Button } from "../../ui/button/Button.js";
import { Dialog } from "../../ui/dialog/Dialog.js";
import { Hint } from "../../ui/hint/Hint.js";
import { Panel } from "../../ui/panel/Panel.js";
import { TextField } from "../../ui/text-field/TextField.js";
import { BadgeSkeleton } from "../../ui/skeleton/BadgeSkeleton.js";
@ -71,9 +73,9 @@ export function LimitsFields({
placeholder="e.g. 400%"
onInput={onCpu}
/>
<p class="ui-agent-limits-hint">
<Hint class="ui-agent-limits-hint">
A percentage of one core: 100% is one core, 400% is four.
</p>
</Hint>
<TextField
id={`${idPrefix}-memory-max`}
label="memory max"
@ -81,10 +83,10 @@ export function LimitsFields({
placeholder="e.g. 8G"
onInput={onMemory}
/>
<p class="ui-agent-limits-hint">
<Hint class="ui-agent-limits-hint">
A size such as 512M or 8G, a percentage of the machine's memory such as
50%, or infinity.
</p>
</Hint>
</>
);
}
@ -239,7 +241,7 @@ function LimitsForm({
{problem ? (
<ApiErrorPanel context="the caps were not saved" problem={problem} />
) : null}
<div class="ui-agent-limits-actions">
<ActionRow>
<Button variant="primary" type="submit" disabled={busy || empty}>
{busy ? "saving…" : "save"}
</Button>
@ -249,7 +251,7 @@ function LimitsForm({
>
clear the swarm's caps
</Button>
</div>
</ActionRow>
</form>
</Panel>
);

View file

@ -22,6 +22,7 @@ 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 { Hint } from "../../ui/hint/Hint.js";
import { MultiselectFilterOptions } from "../../ui/multiselect-filter/MultiselectFilter.js";
import {
requestPermissions,
@ -225,9 +226,9 @@ export function AgentPermissions({
/>
</div>
) : (
<span class="ui-agent-limits-muted" role="status">
<Hint as="span" role="status">
{catalogueError ? "could not load the permissions" : "loading…"}
</span>
</Hint>
)}
{saveError ? (
<ApiErrorPanel
@ -287,7 +288,7 @@ export function AgentInternet({ agent, g }: { agent: string; g: GrantState }) {
})
}
/>{" "}
<span class="ui-agent-limits-muted">{meaning}</span>
<Hint as="span">{meaning}</Hint>
</li>
))}
</ul>

View file

@ -24,6 +24,7 @@ import { useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge } from "@hive/shared/badge.js";
import { ActionRow } from "../../ui/action-row/ActionRow.js";
import { BadgePopover } from "../../ui/badge-popover/BadgePopover.js";
import { Button } from "../../ui/button/Button.js";
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
@ -232,7 +233,7 @@ export function AddForm({
{problem ? (
<ApiErrorPanel context="the domain was not added" problem={problem} />
) : null}
<div class="ui-agent-limits-actions">
<ActionRow>
<Button
variant="primary"
type="submit"
@ -240,7 +241,7 @@ export function AddForm({
>
{busy ? "adding…" : "add"}
</Button>
</div>
</ActionRow>
</form>
</Panel>
);

View file

@ -12,7 +12,6 @@
gap: 0.5em;
flex-wrap: wrap;
}
.ui-linked-accounts-host,
.ui-linked-accounts-muted {
.ui-linked-accounts-host {
color: var(--muted);
}

View file

@ -15,6 +15,7 @@ import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge } from "@hive/shared/badge.js";
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
import { Hint } from "../../ui/hint/Hint.js";
import { accountKindLabel } from "./account-kind.js";
import { deleteUrl, listUrl } from "./account-api.js";
import "./LinkedAccounts.css";
@ -145,13 +146,13 @@ export function LinkedAccounts({
<ApiErrorPanel context="listing linked accounts failed" problem={error} />
) : null;
if (accounts === null) {
return errorPanel ?? <span class="ui-linked-accounts-muted">…</span>;
return errorPanel ?? <Hint as="span">…</Hint>;
}
if (accounts.length === 0) {
return (
<>
{errorPanel}
<span class="ui-linked-accounts-muted">none linked</span>
<Hint as="span">none linked</Hint>
</>
);
}

View file

@ -7,6 +7,7 @@ import { useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
import { Button } from "../ui/button/Button.js";
import { ExternalLink } from "../ui/external-link/ExternalLink.js";
import { LazyDisclosure } from "../ui/disclosure/LazyDisclosure.js";
import { RelativeTime } from "../ui/relative-time/RelativeTime.js";
import {
@ -16,6 +17,7 @@ import {
import { fetchJson, usePolled } from "../ui/polled/usePolled.js";
import { Table, type TableColumn } from "../ui/table/Table.js";
import { TableSkeleton } from "../ui/table/TableSkeleton.js";
import { Toolbar } from "../ui/toolbar/Toolbar.js";
import { activeSinceMs, alertKey, listedAlerts, type Alert } from "./alerts.js";
const STATE_TONE: Record<string, BadgeTone> = {
@ -90,10 +92,10 @@ function AlertsBody() {
return (
<>
<div class="ui-alerts-bar">
<a href={GRAFANA_ALERTS_URL} target="_blank" rel="noopener noreferrer">
<Toolbar>
<ExternalLink href={GRAFANA_ALERTS_URL}>
Grafana's alert list
</a>
</ExternalLink>
{showAll || hidden > 0 ? (
<Button onClick={() => setShowAll(!showAll)}>
{showAll ? "only firing" : `show ${hidden} more`}
@ -105,7 +107,7 @@ function AlertsBody() {
onChange={setIntervalMs}
refreshing={refreshing}
/>
</div>
</Toolbar>
{error ? (
<ApiErrorPanel
context="failed to load Grafana's alerts"

View file

@ -34,6 +34,15 @@ import {
import { CheckboxField } from "../ui/checkbox-field/CheckboxField.js";
import { Button, type ButtonVariant } from "../ui/button/Button.js";
import { Dialog } from "../ui/dialog/Dialog.js";
import { ActionRow } from "../ui/action-row/ActionRow.js";
import { EmptyState } from "../ui/empty-state/EmptyState.js";
import {
ExpandableCard,
ExpandableCardSkeleton,
} from "../ui/expandable-card/ExpandableCard.js";
import { ExternalLink } from "../ui/external-link/ExternalLink.js";
import { Hint, type HintTone } from "../ui/hint/Hint.js";
import { Toolbar } from "../ui/toolbar/Toolbar.js";
import { ConfirmDialog } from "../ui/confirm-dialog/ConfirmDialog.js";
import { Popover } from "../ui/popover/Popover.js";
import { Badge, type BadgeTone } from "@hive/shared/badge.js";
@ -41,6 +50,8 @@ import { GearIcon } from "@hive/shared/icons.js";
import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js";
import "./ComponentsPage.css";
const HINT_TONES: HintTone[] = ["muted", "warn", "error", "ok"];
function Section({
title,
children,
@ -590,6 +601,62 @@ export function ComponentsPage() {
</div>
</Section>
<Section title="Hint">
<div class="components-chip-row">
{HINT_TONES.map((tone) => (
<Sample key={tone} label={tone}>
<Hint tone={tone}>A line of supporting text.</Hint>
</Sample>
))}
</div>
</Section>
<Section title="ActionRow, Toolbar, EmptyState, ExternalLink">
<Sample label="ActionRow: the buttons that end a form">
<ActionRow>
<Button variant="primary">save</Button>
<Button>cancel</Button>
</ActionRow>
</Sample>
<Sample label="Toolbar: the bar above a list">
<Toolbar>
<Button>refresh</Button>
</Toolbar>
</Sample>
<Sample label="EmptyState">
<EmptyState>Nothing here yet.</EmptyState>
</Sample>
<Sample label="ExternalLink">
<ExternalLink href="https://example.com">example.com</ExternalLink>
</Sample>
</Section>
<Section title="ExpandableCard">
<Sample label="closed, open, and the loading twin">
<ExpandableCard
id="components-expandable-closed"
open={false}
onToggle={() => {}}
title="build 1"
badge={<Badge value="ok" />}
meta="2 minutes ago"
>
body
</ExpandableCard>
<ExpandableCard
id="components-expandable-open"
open
onToggle={() => {}}
title="build 2"
badge={<Badge value="failed" />}
meta="an hour ago"
>
<Hint>The body appears while the card is open.</Hint>
</ExpandableCard>
<ExpandableCardSkeleton />
</Sample>
</Section>
<Section title="Dialog">
<Sample label="modal, caller-owned content">
<DialogSample />

View file

@ -94,7 +94,6 @@
flex: 1 1 auto;
min-width: 0;
margin-left: 0.25em;
color: var(--muted);
font-size: 0.9em;
}
.create-agent-summary {
@ -118,14 +117,6 @@
margin: 0;
overflow-wrap: anywhere;
}
.create-agent-result-ok {
color: var(--green);
}
/* The controller's refusal of the AGENTS.md text, under its box. */
.create-agent-field-error {
margin: 0;
color: var(--red);
}
/* The picked archetype's description, under its select. */
.create-agent-archetype-description {
margin: 0;

View file

@ -45,7 +45,9 @@ import {
InferenceFields,
type InferenceDraft,
} from "../agent/detail/AgentInference.js";
import { CheckboxField } from "../ui/checkbox-field/CheckboxField.js";
import { FormField } from "../ui/form-field/FormField.js";
import { Hint } from "../ui/hint/Hint.js";
import { MultiselectFilterOptions } from "../ui/multiselect-filter/MultiselectFilter.js";
import {
initialSelection,
@ -325,12 +327,12 @@ export function CreateAgentForm({
/>
)}
{done && (
<div class="create-agent-result create-agent-result-ok">
<Hint tone="ok" as="div" class="create-agent-result">
<p>
Queued as job node #{result.nodeId} —{" "}
<Link href="/jobs">watch it in jobs</Link>.
</p>
</div>
</Hint>
)}
{!done && step === "identity" && (
<div class="create-agent-fields">
@ -409,7 +411,9 @@ export function CreateAgentForm({
onInput={setAgentsMd}
/>
{refusal !== null && (
<p class="create-agent-field-error">{refusal}</p>
<Hint tone="error" class="create-agent-field-error">
{refusal}
</Hint>
)}
</div>
)}
@ -438,14 +442,12 @@ export function CreateAgentForm({
)}
</div>
</FormField>
<label>
<input
type="checkbox"
checked={internet}
onChange={() => setInternet((v) => !v)}
/>{" "}
Unrestricted internet
</label>
<CheckboxField
id="create-agent-internet"
label="Unrestricted internet"
checked={internet}
onChange={setInternet}
/>
</div>
)}
{!done && step === "resources" && (
@ -531,9 +533,9 @@ export function CreateAgentForm({
back
</Button>
{problem !== null && (step !== "identity" || name !== "") && (
<span class="create-agent-hint" role="status">
<Hint as="span" role="status" class="create-agent-hint">
{problem}
</span>
</Hint>
)}
{step === "summary" ? (
<Button

View file

@ -32,6 +32,7 @@ import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge } from "@hive/shared/badge.js";
import { useLocalSetting } from "@hive/shared/settings-storage.js";
import { ExternalLink } from "../ui/external-link/ExternalLink.js";
import { LazyDisclosure } from "../ui/disclosure/LazyDisclosure.js";
import { CheckboxField } from "../ui/checkbox-field/CheckboxField.js";
import { SelectField } from "../ui/select-field/SelectField.js";
@ -268,9 +269,7 @@ function IssueReportBody() {
ariaSort: ariaSortFor("number"),
render: (r) =>
r.html_url ? (
<a href={r.html_url} target="_blank" rel="noreferrer">
#{r.number}
</a>
<ExternalLink href={r.html_url}>#{r.number}</ExternalLink>
) : (
`#${r.number}`
),

Some files were not shown because too many files have changed in this diff Show more