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:
parent
5eef1f4a3d
commit
c37205e8f5
13 changed files with 103 additions and 51 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -11,6 +11,3 @@
|
|||
.ui-agent-input-form > :first-child {
|
||||
flex: 1;
|
||||
}
|
||||
.ui-agent-input-sent {
|
||||
margin-block-start: 0.25rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
26
frontend/packages/swarm-ui/src/ui/spinner/Spinner.css
Normal file
26
frontend/packages/swarm-ui/src/ui/spinner/Spinner.css
Normal 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;
|
||||
}
|
||||
10
frontend/packages/swarm-ui/src/ui/spinner/Spinner.tsx
Normal file
10
frontend/packages/swarm-ui/src/ui/spinner/Spinner.tsx
Normal 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" />;
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue