Watch
0
0
Fork
You've already forked hyperhive
0

swarm ui: the create form picks an archetype from tiles with their icons

An archetype's meta gets an optional icon, the SVG text (at most 16 KiB), which
the controller checks, stores and serves on GET /api/archetypes. The built-in
archetypes read theirs from nix/archetypes/icons. The create form shows each
archetype as a tile with its icon and name, laid out like the broadcast screen's
agent tiles, in place of the dropdown, and draws the SVG through an img data URL.
This commit is contained in:
iris 2026-11-11 09:08:03 +01:00 • committed by mara
commit 53224d877e
5 changed files with 240 additions and 16 deletions

View file

@ -0,0 +1,41 @@
/* The archetype tiles, laid out and styled like the broadcast screen's agent
tiles (`.ui-broadcast-grid`, `.ui-broadcast-agent`). */
.ui-archetype-picker {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(8em, 1fr));
gap: 0.6em;
}
.ui-archetype-tile {
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-archetype-tile:hover {
border-color: var(--muted);
}
.ui-archetype-tile-on {
border-color: var(--purple);
background: var(--bg-elev);
}
.ui-archetype-tile:focus-within {
outline: 2px solid var(--purple);
outline-offset: 2px;
}
.ui-archetype-radio {
position: absolute;
top: 0.5em;
left: 0.5em;
margin: 0;
}
.ui-archetype-name {
max-width: 100%;
overflow-wrap: anywhere;
text-align: center;
}

View file

@ -0,0 +1,76 @@
// <ArchetypePicker> — the create form's archetype choice, drawn like the
// broadcast screen's agent picker: one tile per choice with its icon and name,
// where the broadcast screen has one per agent. One choice at a time, so each
// tile is a radio button. The first tile is "none", an agent with no archetype.
import {
archetypeKey,
iconSrc,
type Archetype,
} from "./CreateAgentArchetype.js";
import { FormField } from "../ui/form-field/FormField.js";
import "./ArchetypePicker.css";
function Icon({ svg }: { svg?: string }) {
const src = iconSrc(svg);
// The dimmed hyperhive mark, as an agent with no icon shows, when there is
// none (or none yet: a custom agent).
return (
<div class={"ui-agent-icon" + (src ? "" : " ui-agent-icon-none")}>
<img alt="" src={src ?? "/favicon.svg"} />
</div>
);
}
export function ArchetypePicker({
id,
archetypes,
value,
onChange,
disabled,
}: {
id: string;
archetypes: Archetype[];
/** The picked tile's `archetypeKey`, `""` for none. */
value: string;
onChange: (key: string) => void;
disabled?: boolean;
}) {
const tiles: { key: string; name: string; svg?: string }[] = [
{ key: "", name: "none" },
...archetypes.map((a) => ({
key: archetypeKey(a),
name: a.name,
svg: a.icon,
})),
];
return (
<FormField label="archetype" labelId={`${id}-label`}>
<div
class="ui-archetype-picker"
role="radiogroup"
aria-labelledby={`${id}-label`}
>
{tiles.map((t) => {
const on = t.key === value;
return (
<label
key={t.key}
class={"ui-archetype-tile" + (on ? " ui-archetype-tile-on" : "")}
>
<input
type="radio"
class="ui-archetype-radio"
name={id}
checked={on}
disabled={disabled}
onChange={() => onChange(t.key)}
/>
<Icon svg={t.svg} />
<span class="ui-archetype-name">{t.name}</span>
</label>
);
})}
</div>
</FormField>
);
}

View file

@ -16,6 +16,8 @@ export interface Archetype {
max_subagents?: number;
};
prompt?: string;
/** The archetype's icon as SVG text. */
icon?: string;
}
// Mirrors swarm-controller's `Archetypes`.
@ -72,3 +74,12 @@ export function requestArchetype(
): { source: string; name: string } | undefined {
return archetype && { source: archetype.source, name: archetype.name };
}
/**
* An `<img>` source for an archetype's SVG text, or null when it has none. A
* data URL, so the browser draws it as an image and a script in it never runs.
*/
export function iconSrc(svg: string | undefined): string | null {
if (svg === undefined || svg.trim() === "") return null;
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}

View file

@ -63,6 +63,7 @@ import {
requestArchetype,
type ArchetypeList,
} from "./CreateAgentArchetype.js";
import { ArchetypePicker } from "./ArchetypePicker.js";
import {
agentsMdAfterPick,
agentsMdBody,
@ -377,19 +378,12 @@ export function CreateAgentForm({
/>
{archetypes && archetypes.archetypes.length > 0 && (
<>
<SelectField
<ArchetypePicker
id="agent-archetype"
label="archetype"
archetypes={archetypes.archetypes}
value={archetype}
onChange={pickArchetype}
disabled={!catalogue}
options={[
{ value: "", label: "none" },
...archetypes.archetypes.map((a) => ({
value: archetypeKey(a),
label: a.name,
})),
]}
/>
{picked && (
<p class="create-agent-archetype-description">

View file

@ -4,6 +4,7 @@ import assert from "node:assert/strict";
import { test } from "node:test";
import {
archetypeKey,
iconSrc,
prefill,
requestArchetype,
} from "../src/pages/CreateAgentArchetype.ts";
@ -83,3 +84,11 @@ test("a picked archetype is sent by source and name, none is left out", () => {
});
assert.equal(requestArchetype(undefined), undefined);
});
test("an icon is a data URL of the SVG text, and none without text", () => {
assert.equal(iconSrc(undefined), null);
assert.equal(iconSrc(" \n"), null);
const src = iconSrc('<svg viewBox="0 0 1 1"><script>x()</script></svg>');
assert.ok(src.startsWith("data:image/svg+xml;charset=utf-8,%3Csvg"), src);
assert.ok(!src.includes("<"), "the markup is percent-encoded");
});