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
|
### Sending a message to an agent
|
||||||
|
|
||||||
Under an agent's terminal, in the preview and in its own tab, is a **message**
|
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
|
box, with the agent's name as its placeholder. **send** hands the text to the agent's
|
||||||
an inbox message from your authelia username. **sent** means the harness was
|
harness, and the agent takes it as an inbox message from your authelia username.
|
||||||
listening and holds the message. If the harness isn't running, or isn't
|
While the request is pending, a spinner replaces the button's label. When it
|
||||||
connected to the swarm queue, the box shows an error and the agent gets nothing.
|
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
|
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
|
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
|
the turn, then shows whether one was running ("turn cancelled" or "nothing was
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,3 @@
|
||||||
.ui-agent-input-form > :first-child {
|
.ui-agent-input-form > :first-child {
|
||||||
flex: 1;
|
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 { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||||
import { TextField } from "../../ui/text-field/TextField.js";
|
import { TextField } from "../../ui/text-field/TextField.js";
|
||||||
import { Button } from "../../ui/button/Button.js";
|
import { Button } from "../../ui/button/Button.js";
|
||||||
import { Hint } from "../../ui/hint/Hint.js";
|
|
||||||
import { useSubmit } from "../../ui/submit/useSubmit.js";
|
import { useSubmit } from "../../ui/submit/useSubmit.js";
|
||||||
import "./AgentInputForm.css";
|
import "./AgentInputForm.css";
|
||||||
|
|
||||||
|
|
@ -34,19 +33,16 @@ export function AgentInputForm({ agentName }: { agentName: string }) {
|
||||||
<TextField
|
<TextField
|
||||||
id={`agent-input-${agentName}`}
|
id={`agent-input-${agentName}`}
|
||||||
label={`message ${agentName}`}
|
label={`message ${agentName}`}
|
||||||
|
hideLabel
|
||||||
|
placeholder={`message ${agentName}`}
|
||||||
value={body}
|
value={body}
|
||||||
required
|
required
|
||||||
onInput={setBody}
|
onInput={setBody}
|
||||||
/>
|
/>
|
||||||
<Button variant="primary" type="submit" disabled={submitting}>
|
<Button variant="primary" type="submit" busy={submitting}>
|
||||||
{submitting ? "sending…" : "send"}
|
send
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
{state.status === "done" && (
|
|
||||||
<Hint tone="ok" class="ui-agent-input-sent">
|
|
||||||
sent
|
|
||||||
</Hint>
|
|
||||||
)}
|
|
||||||
{state.status === "error" && (
|
{state.status === "error" && (
|
||||||
<ApiErrorPanel context="failed to send" problem={state.problem} />
|
<ApiErrorPanel context="failed to send" problem={state.problem} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -611,6 +611,11 @@ export function ComponentsPage() {
|
||||||
<Sample label="disabled">
|
<Sample label="disabled">
|
||||||
<Button disabled>disabled</Button>
|
<Button disabled>disabled</Button>
|
||||||
</Sample>
|
</Sample>
|
||||||
|
<Sample label="busy: a spinner over the label, same width">
|
||||||
|
<Button variant="primary" busy>
|
||||||
|
send
|
||||||
|
</Button>
|
||||||
|
</Sample>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,3 +24,17 @@
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: default;
|
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
|
// so far; a third reading gets added the day something needs it, not
|
||||||
// speculatively.
|
// speculatively.
|
||||||
import type { ComponentChildren } from "preact";
|
import type { ComponentChildren } from "preact";
|
||||||
|
import { Spinner } from "../spinner/Spinner.js";
|
||||||
import "./Button.css";
|
import "./Button.css";
|
||||||
|
|
||||||
export type ButtonVariant = "primary" | "default";
|
export type ButtonVariant = "primary" | "default";
|
||||||
|
|
@ -13,6 +14,7 @@ export function Button({
|
||||||
variant = "default",
|
variant = "default",
|
||||||
type = "button",
|
type = "button",
|
||||||
disabled,
|
disabled,
|
||||||
|
busy = false,
|
||||||
onClick,
|
onClick,
|
||||||
describedBy,
|
describedBy,
|
||||||
children,
|
children,
|
||||||
|
|
@ -20,6 +22,10 @@ export function Button({
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
type?: "button" | "submit";
|
type?: "button" | "submit";
|
||||||
disabled?: boolean;
|
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;
|
onClick?: (e: Event) => void;
|
||||||
/** Id of the element that says why the button is disabled. */
|
/** Id of the element that says why the button is disabled. */
|
||||||
describedBy?: string;
|
describedBy?: string;
|
||||||
|
|
@ -27,13 +33,19 @@ export function Button({
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
class={"ui-button ui-button-" + variant}
|
class={"ui-button ui-button-" + variant + (busy ? " ui-button-busy" : "")}
|
||||||
type={type}
|
type={type}
|
||||||
disabled={disabled}
|
disabled={disabled || busy}
|
||||||
|
aria-busy={busy || undefined}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
aria-describedby={describedBy}
|
aria-describedby={describedBy}
|
||||||
>
|
>
|
||||||
{children}
|
<span class="ui-button-label">{children}</span>
|
||||||
|
{busy ? (
|
||||||
|
<span class="ui-button-spinner">
|
||||||
|
<Spinner />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,3 +45,12 @@
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-height: 2.75em;
|
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,
|
label,
|
||||||
htmlFor,
|
htmlFor,
|
||||||
labelId,
|
labelId,
|
||||||
|
hideLabel = false,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
htmlFor?: string;
|
htmlFor?: string;
|
||||||
labelId?: 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;
|
children: ComponentChildren;
|
||||||
}) {
|
}) {
|
||||||
|
const labelClass =
|
||||||
|
"ui-form-field-label" + (hideLabel ? " ui-form-field-label-hidden" : "");
|
||||||
return (
|
return (
|
||||||
<div class="ui-form-field">
|
<div class="ui-form-field">
|
||||||
{htmlFor !== undefined ? (
|
{htmlFor !== undefined ? (
|
||||||
<label class="ui-form-field-label" for={htmlFor}>
|
<label class={labelClass} for={htmlFor}>
|
||||||
{label}
|
{label}
|
||||||
</label>
|
</label>
|
||||||
) : (
|
) : (
|
||||||
<span class="ui-form-field-label" id={labelId}>
|
<span class={labelClass} id={labelId}>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -43,32 +43,3 @@
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
line-height: 1;
|
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.
|
// hook only decides *when* to call it.
|
||||||
import { useEffect, useRef } from "preact/hooks";
|
import { useEffect, useRef } from "preact/hooks";
|
||||||
import { Listbox } from "../select-field/Listbox.js";
|
import { Listbox } from "../select-field/Listbox.js";
|
||||||
|
import { Spinner } from "../spinner/Spinner.js";
|
||||||
import "./RefreshInterval.css";
|
import "./RefreshInterval.css";
|
||||||
|
|
||||||
// `null` means "off" throughout this module — no separate boolean, so
|
// `null` means "off" throughout this module — no separate boolean, so
|
||||||
|
|
@ -59,7 +60,7 @@ export function RefreshIntervalPicker({
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
title={refreshing ? "refreshing" : undefined}
|
title={refreshing ? "refreshing" : undefined}
|
||||||
>
|
>
|
||||||
{refreshing ? <span class="ui-refresh-picker-spinner" /> : "🕐"}
|
{refreshing ? <Spinner /> : "🕐"}
|
||||||
</span>
|
</span>
|
||||||
<Listbox
|
<Listbox
|
||||||
id={id}
|
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,
|
title,
|
||||||
required,
|
required,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
hideLabel,
|
||||||
suggestions,
|
suggestions,
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -33,13 +34,15 @@ export function TextField({
|
||||||
title?: string;
|
title?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
placeholder?: string;
|
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
|
/** Values to pick from. The field stays free text: a value that is not in
|
||||||
* the list can still be typed. */
|
* the list can still be typed. */
|
||||||
suggestions?: TextFieldSuggestion[];
|
suggestions?: TextFieldSuggestion[];
|
||||||
}) {
|
}) {
|
||||||
const listId = `${id}-suggestions`;
|
const listId = `${id}-suggestions`;
|
||||||
return (
|
return (
|
||||||
<FormField label={label} htmlFor={id}>
|
<FormField label={label} htmlFor={id} hideLabel={hideLabel}>
|
||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
class="ui-form-control"
|
class="ui-form-control"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue