Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: the terminal message box has a placeholder and a spinner instead of a label and a sent line

The label 'message <agent>' is the input's placeholder (the label stays for
screen readers). The send button shows a spinner over its label while the
request is pending and keeps its width, and the 'sent' line is gone: the box
empties when the message is held. Spinner is a component of its own now, used
by the button and the refresh picker.
This commit is contained in:
iris 2026-11-06 19:37:26 +01:00 • committed by mara
commit c37205e8f5
13 changed files with 103 additions and 51 deletions

View file

@ -447,10 +447,12 @@ tab, follow a link in the app, or press the browser's back button. Choose
### Sending a message to an agent
Under an agent's terminal, in the preview and in its own tab, is a **message**
box. **send** hands the text to the agent's harness, and the agent takes it as
an inbox message from your authelia username. **sent** means the harness was
listening and holds the message. If the harness isn't running, or isn't
connected to the swarm queue, the box shows an error and the agent gets nothing.
box, with the agent's name as its placeholder. **send** hands the text to the agent's
harness, and the agent takes it as an inbox message from your authelia username.
While the request is pending, a spinner replaces the button's label. When it
ends, the box empties: the harness was listening and holds the message. If the
harness isn't running, or isn't connected to the swarm queue, the box shows an
error and the agent gets nothing.
While the agent is in a turn, a **cancel turn** badge appears among the state
badges over the terminal, in its own tab. It asks the agent's harness to stop
the turn, then shows whether one was running ("turn cancelled" or "nothing was

View file

@ -11,6 +11,3 @@
.ui-agent-input-form > :first-child {
flex: 1;
}
.ui-agent-input-sent {
margin-block-start: 0.25rem;
}

View file

@ -7,7 +7,6 @@ import { useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { TextField } from "../../ui/text-field/TextField.js";
import { Button } from "../../ui/button/Button.js";
import { Hint } from "../../ui/hint/Hint.js";
import { useSubmit } from "../../ui/submit/useSubmit.js";
import "./AgentInputForm.css";
@ -34,19 +33,16 @@ export function AgentInputForm({ agentName }: { agentName: string }) {
<TextField
id={`agent-input-${agentName}`}
label={`message ${agentName}`}
hideLabel
placeholder={`message ${agentName}`}
value={body}
required
onInput={setBody}
/>
<Button variant="primary" type="submit" disabled={submitting}>
{submitting ? "sending…" : "send"}
<Button variant="primary" type="submit" busy={submitting}>
send
</Button>
</form>
{state.status === "done" && (
<Hint tone="ok" class="ui-agent-input-sent">
sent
</Hint>
)}
{state.status === "error" && (
<ApiErrorPanel context="failed to send" problem={state.problem} />
)}

View file

@ -611,6 +611,11 @@ export function ComponentsPage() {
<Sample label="disabled">
<Button disabled>disabled</Button>
</Sample>
<Sample label="busy: a spinner over the label, same width">
<Button variant="primary" busy>
send
</Button>
</Sample>
</div>
</Section>

View file

@ -24,3 +24,17 @@
opacity: 0.6;
cursor: default;
}
.ui-button {
position: relative;
}
/* Busy: the label keeps its space, hidden, and the spinner sits over it. */
.ui-button-busy .ui-button-label {
visibility: hidden;
}
.ui-button-spinner {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}

View file

@ -5,6 +5,7 @@
// so far; a third reading gets added the day something needs it, not
// speculatively.
import type { ComponentChildren } from "preact";
import { Spinner } from "../spinner/Spinner.js";
import "./Button.css";
export type ButtonVariant = "primary" | "default";
@ -13,6 +14,7 @@ export function Button({
variant = "default",
type = "button",
disabled,
busy = false,
onClick,
describedBy,
children,
@ -20,6 +22,10 @@ export function Button({
variant?: ButtonVariant;
type?: "button" | "submit";
disabled?: boolean;
/** A request from this button is in flight: a spinner covers the label,
* which keeps its space so the button does not change width, and the button
* cannot be pressed again. */
busy?: boolean;
onClick?: (e: Event) => void;
/** Id of the element that says why the button is disabled. */
describedBy?: string;
@ -27,13 +33,19 @@ export function Button({
}) {
return (
<button
class={"ui-button ui-button-" + variant}
class={"ui-button ui-button-" + variant + (busy ? " ui-button-busy" : "")}
type={type}
disabled={disabled}
disabled={disabled || busy}
aria-busy={busy || undefined}
onClick={onClick}
aria-describedby={describedBy}
>
{children}
<span class="ui-button-label">{children}</span>
{busy ? (
<span class="ui-button-spinner">
<Spinner />
</span>
) : null}
</button>
);
}

View file

@ -45,3 +45,12 @@
box-sizing: border-box;
min-height: 2.75em;
}
/* A label that stays for assistive tech and is off the screen. */
.ui-form-field-label-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}

View file

@ -16,21 +16,27 @@ export function FormField({
label,
htmlFor,
labelId,
hideLabel = false,
children,
}: {
label: string;
htmlFor?: string;
labelId?: string;
/** Keep the label for assistive tech and take it off the screen, for a
* control whose placeholder says the same. */
hideLabel?: boolean;
children: ComponentChildren;
}) {
const labelClass =
"ui-form-field-label" + (hideLabel ? " ui-form-field-label-hidden" : "");
return (
<div class="ui-form-field">
{htmlFor !== undefined ? (
<label class="ui-form-field-label" for={htmlFor}>
<label class={labelClass} for={htmlFor}>
{label}
</label>
) : (
<span class="ui-form-field-label" id={labelId}>
<span class={labelClass} id={labelId}>
{label}
</span>
)}

View file

@ -43,32 +43,3 @@
font-size: 0.85em;
line-height: 1;
}
/* The spinner takes the clock's place: the same box, so the picker keeps its
width. Motion follows the page's setting, the same three rules the page
switch uses; where motion is off it is a static arc. */
.ui-refresh-picker-spinner {
box-sizing: border-box;
width: 1em;
height: 1em;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: ui-refresh-spin 0.8s linear infinite;
}
@keyframes ui-refresh-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ui-refresh-picker-spinner {
animation: none;
}
}
:root[data-motion="reduce"] .ui-refresh-picker-spinner {
animation: none;
}
:root[data-motion="allow"] .ui-refresh-picker-spinner {
animation: ui-refresh-spin 0.8s linear infinite;
}

View file

@ -25,6 +25,7 @@
// hook only decides *when* to call it.
import { useEffect, useRef } from "preact/hooks";
import { Listbox } from "../select-field/Listbox.js";
import { Spinner } from "../spinner/Spinner.js";
import "./RefreshInterval.css";
// `null` means "off" throughout this module — no separate boolean, so
@ -59,7 +60,7 @@ export function RefreshIntervalPicker({
aria-hidden="true"
title={refreshing ? "refreshing" : undefined}
>
{refreshing ? <span class="ui-refresh-picker-spinner" /> : "🕐"}
{refreshing ? <Spinner /> : "🕐"}
</span>
<Listbox
id={id}

View file

@ -0,0 +1,26 @@
.ui-spinner {
display: inline-block;
box-sizing: border-box;
width: 1em;
height: 1em;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: ui-spinner-turn 0.8s linear infinite;
}
@keyframes ui-spinner-turn {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.ui-spinner {
animation: none;
}
}
:root[data-motion="reduce"] .ui-spinner {
animation: none;
}
:root[data-motion="allow"] .ui-spinner {
animation: ui-spinner-turn 0.8s linear infinite;
}

View file

@ -0,0 +1,10 @@
// <Spinner> — a small ring that turns while a request is in flight: the
// refresh picker's clock slot, a button waiting on its request. It is one
// character wide and tall (`1em`), takes the colour of the text around it, and
// is hidden from assistive tech; whatever holds it says the state (`aria-busy`,
// a label). Where motion is off it is a static arc.
import "./Spinner.css";
export function Spinner() {
return <span class="ui-spinner" aria-hidden="true" />;
}

View file

@ -22,6 +22,7 @@ export function TextField({
title,
required,
placeholder,
hideLabel,
suggestions,
}: {
id: string;
@ -33,13 +34,15 @@ export function TextField({
title?: string;
required?: boolean;
placeholder?: string;
/** The label is for assistive tech only; the placeholder says it on screen. */
hideLabel?: boolean;
/** Values to pick from. The field stays free text: a value that is not in
* the list can still be typed. */
suggestions?: TextFieldSuggestion[];
}) {
const listId = `${id}-suggestions`;
return (
<FormField label={label} htmlFor={id}>
<FormField label={label} htmlFor={id} hideLabel={hideLabel}>
<input
id={id}
class="ui-form-control"