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:
parent
175019607c
commit
79b1fc35c5
6 changed files with 128 additions and 102 deletions
51
frontend/packages/swarm-ui/src/agent/picker/AgentPicker.css
Normal file
51
frontend/packages/swarm-ui/src/agent/picker/AgentPicker.css
Normal 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);
|
||||||
|
}
|
||||||
63
frontend/packages/swarm-ui/src/agent/picker/AgentPicker.tsx
Normal file
63
frontend/packages/swarm-ui/src/agent/picker/AgentPicker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -57,57 +57,6 @@
|
||||||
font-size: 0.9em;
|
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 {
|
.ui-broadcast-send {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { useState } from "preact/hooks";
|
||||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||||
import { AgentIcon } from "../../agent/AgentIcon.js";
|
import { AgentIcon } from "../../agent/AgentIcon.js";
|
||||||
|
import { AgentPicker } from "../../agent/picker/AgentPicker.js";
|
||||||
import { Button } from "../../ui/button/Button.js";
|
import { Button } from "../../ui/button/Button.js";
|
||||||
import {
|
import {
|
||||||
RefreshIntervalPicker,
|
RefreshIntervalPicker,
|
||||||
|
|
@ -217,40 +218,15 @@ export function BroadcastComposer() {
|
||||||
: "no agents."}
|
: "no agents."}
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
) : (
|
) : (
|
||||||
<ul class="ui-broadcast-grid" aria-label="agents">
|
<AgentPicker
|
||||||
{shown.map((agent) => {
|
options={shown.map((agent) => ({
|
||||||
const on = targets.includes(agent.name);
|
name: agent.name,
|
||||||
return (
|
state: isRunning(agent) ? "running" : "not running",
|
||||||
<li key={agent.name}>
|
up: isRunning(agent),
|
||||||
<label
|
}))}
|
||||||
class={
|
selected={targets}
|
||||||
"ui-broadcast-agent" +
|
onToggle={(name) => setTicked((prev) => toggled(prev, name))}
|
||||||
(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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TextAreaField
|
<TextAreaField
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,6 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.3em;
|
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 {
|
.ui-schedule-form-hint {
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,12 @@
|
||||||
import { useState } from "preact/hooks";
|
import { useState } from "preact/hooks";
|
||||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.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 { ActionRow } from "../../ui/action-row/ActionRow.js";
|
||||||
import { Button } from "../../ui/button/Button.js";
|
import { Button } from "../../ui/button/Button.js";
|
||||||
import { FieldGrid } from "../../ui/field-grid/FieldGrid.js";
|
import { FieldGrid } from "../../ui/field-grid/FieldGrid.js";
|
||||||
import { Card } from "../../ui/card/Card.js";
|
import { Card } from "../../ui/card/Card.js";
|
||||||
import { Hint } from "../../ui/hint/Hint.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 { SelectField } from "../../ui/select-field/SelectField.js";
|
||||||
import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js";
|
import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js";
|
||||||
import { TextField } from "../../ui/text-field/TextField.js";
|
import { TextField } from "../../ui/text-field/TextField.js";
|
||||||
|
|
@ -101,7 +98,6 @@ export function ScheduleForm({
|
||||||
const cadenceValid =
|
const cadenceValid =
|
||||||
startUnix !== null && (!repeat || intervalSeconds !== null);
|
startUnix !== null && (!repeat || intervalSeconds !== null);
|
||||||
|
|
||||||
const picked: MultiselectFilterState = { values: agents, negate: false };
|
|
||||||
const toggleAgent = (a: string) =>
|
const toggleAgent = (a: string) =>
|
||||||
setAgents((cur) =>
|
setAgents((cur) =>
|
||||||
cur.includes(a) ? cur.filter((x) => x !== a) : [...cur, a],
|
cur.includes(a) ? cur.filter((x) => x !== a) : [...cur, a],
|
||||||
|
|
@ -199,9 +195,9 @@ export function ScheduleForm({
|
||||||
: "no agents to pick from"}
|
: "no agents to pick from"}
|
||||||
</Hint>
|
</Hint>
|
||||||
) : (
|
) : (
|
||||||
<MultiselectFilterOptions
|
<AgentPicker
|
||||||
options={agentOptions}
|
options={agentOptions.map((name) => ({ name }))}
|
||||||
filter={picked}
|
selected={agents}
|
||||||
onToggle={toggleAgent}
|
onToggle={toggleAgent}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue