Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: the schedule form picks agents from the same card grid as the broadcast

The grid becomes a shared AgentPicker; the broadcast composer uses it too.
This commit is contained in:
iris 2026-11-11 23:43:06 +01:00
commit 79b1fc35c5
6 changed files with 128 additions and 102 deletions

View file

@ -0,0 +1,51 @@
/* <AgentPicker>: each agent a labelled checkbox card, icon over name; the grid
wraps to however many cards fit. */
.ui-agent-picker {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8em, 1fr));
gap: 0.6em;
list-style: none;
margin: 0;
padding: 0;
}
.ui-agent-picker-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35em;
padding: 0.7em 0.5em 0.6em;
border: 1px solid var(--border);
border-radius: 0.5em;
background: var(--bg);
cursor: pointer;
}
.ui-agent-picker-card:hover {
border-color: var(--muted);
}
.ui-agent-picker-card-on {
border-color: var(--purple);
background: var(--bg-elev);
}
.ui-agent-picker-card:focus-within {
outline: 2px solid var(--purple);
outline-offset: 2px;
}
.ui-agent-picker-check {
position: absolute;
top: 0.5em;
left: 0.5em;
margin: 0;
}
.ui-agent-picker-name {
max-width: 100%;
overflow-wrap: anywhere;
text-align: center;
}
.ui-agent-picker-state {
color: var(--muted);
font-size: 0.8em;
}
.ui-agent-picker-state-up {
color: var(--green);
}

View file

@ -0,0 +1,63 @@
// <AgentPicker> — pick agents from a grid of cards, each a labelled checkbox
// with the agent's icon over its name and, when the caller knows it, a state
// line under that. The broadcast composer and the schedule form share it, so
// choosing agents looks the same wherever the operator does it. It holds no
// state: the caller owns the selection and what a toggle does to it.
import { AgentIcon } from "../AgentIcon.js";
import "./AgentPicker.css";
export interface AgentPickerOption {
name: string;
/** A line under the name, such as "running". Omitted: no line. */
state?: string;
/** Colours the state line as up. */
up?: boolean;
}
export function AgentPicker({
options,
selected,
onToggle,
label = "agents",
}: {
options: AgentPickerOption[];
selected: string[];
onToggle: (name: string) => void;
label?: string;
}) {
return (
<ul class="ui-agent-picker" aria-label={label}>
{options.map((agent) => {
const on = selected.includes(agent.name);
return (
<li key={agent.name}>
<label
class={
"ui-agent-picker-card" + (on ? " ui-agent-picker-card-on" : "")
}
>
<input
type="checkbox"
class="ui-agent-picker-check"
checked={on}
onChange={() => onToggle(agent.name)}
/>
<AgentIcon name={agent.name} />
<span class="ui-agent-picker-name">{agent.name}</span>
{agent.state !== undefined ? (
<span
class={
"ui-agent-picker-state" +
(agent.up ? " ui-agent-picker-state-up" : "")
}
>
{agent.state}
</span>
) : null}
</label>
</li>
);
})}
</ul>
);
}

View file

@ -57,57 +57,6 @@
font-size: 0.9em;
}
/* The picker: each agent a labelled checkbox card, icon over name. */
.ui-broadcast-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8em, 1fr));
gap: 0.6em;
list-style: none;
margin: 0;
padding: 0;
}
.ui-broadcast-agent {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35em;
padding: 0.7em 0.5em 0.6em;
border: 1px solid var(--border);
border-radius: 0.5em;
background: var(--bg);
cursor: pointer;
}
.ui-broadcast-agent:hover {
border-color: var(--muted);
}
.ui-broadcast-agent-on {
border-color: var(--purple);
background: var(--bg-elev);
}
.ui-broadcast-agent:focus-within {
outline: 2px solid var(--purple);
outline-offset: 2px;
}
.ui-broadcast-check {
position: absolute;
top: 0.5em;
left: 0.5em;
margin: 0;
}
.ui-broadcast-name {
max-width: 100%;
overflow-wrap: anywhere;
text-align: center;
}
.ui-broadcast-state {
color: var(--muted);
font-size: 0.8em;
}
.ui-broadcast-state-up {
color: var(--green);
}
.ui-broadcast-send {
display: flex;
align-items: center;

View file

@ -16,6 +16,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 { AgentIcon } from "../../agent/AgentIcon.js";
import { AgentPicker } from "../../agent/picker/AgentPicker.js";
import { Button } from "../../ui/button/Button.js";
import {
RefreshIntervalPicker,
@ -217,40 +218,15 @@ export function BroadcastComposer() {
: "no agents."}
</EmptyState>
) : (
<ul class="ui-broadcast-grid" aria-label="agents">
{shown.map((agent) => {
const on = targets.includes(agent.name);
return (
<li key={agent.name}>
<label
class={
"ui-broadcast-agent" +
(on ? " ui-broadcast-agent-on" : "")
}
>
<input
type="checkbox"
class="ui-broadcast-check"
checked={on}
onChange={() =>
setTicked((prev) => toggled(prev, agent.name))
}
/>
<AgentIcon name={agent.name} />
<span class="ui-broadcast-name">{agent.name}</span>
<span
class={
"ui-broadcast-state" +
(isRunning(agent) ? " ui-broadcast-state-up" : "")
}
>
{isRunning(agent) ? "running" : "not running"}
</span>
</label>
</li>
);
})}
</ul>
<AgentPicker
options={shown.map((agent) => ({
name: agent.name,
state: isRunning(agent) ? "running" : "not running",
up: isRunning(agent),
}))}
selected={targets}
onToggle={(name) => setTicked((prev) => toggled(prev, name))}
/>
)}
<TextAreaField

View file

@ -11,15 +11,6 @@
flex-direction: column;
gap: 0.3em;
}
/* The picker's own list is a scrolling column sized for a filter popover; in
a form the agents read better as a wrapping row, all visible at once. */
.ui-schedule-form-agents .ui-multiselect-filter-options {
flex-direction: row;
flex-wrap: wrap;
gap: 0.3em 1.2em;
max-height: none;
overflow-y: visible;
}
.ui-schedule-form-hint {
font-size: 0.85em;
}

View file

@ -11,15 +11,12 @@
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 { AgentPicker } from "../../agent/picker/AgentPicker.js";
import { ActionRow } from "../../ui/action-row/ActionRow.js";
import { Button } from "../../ui/button/Button.js";
import { FieldGrid } from "../../ui/field-grid/FieldGrid.js";
import { Card } from "../../ui/card/Card.js";
import { Hint } from "../../ui/hint/Hint.js";
import {
MultiselectFilterOptions,
type MultiselectFilterState,
} from "../../ui/multiselect-filter/MultiselectFilter.js";
import { SelectField } from "../../ui/select-field/SelectField.js";
import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js";
import { TextField } from "../../ui/text-field/TextField.js";
@ -101,7 +98,6 @@ export function ScheduleForm({
const cadenceValid =
startUnix !== null && (!repeat || intervalSeconds !== null);
const picked: MultiselectFilterState = { values: agents, negate: false };
const toggleAgent = (a: string) =>
setAgents((cur) =>
cur.includes(a) ? cur.filter((x) => x !== a) : [...cur, a],
@ -199,9 +195,9 @@ export function ScheduleForm({
: "no agents to pick from"}
</Hint>
) : (
<MultiselectFilterOptions
options={agentOptions}
filter={picked}
<AgentPicker
options={agentOptions.map((name) => ({ name }))}
selected={agents}
onToggle={toggleAgent}
/>
)}