Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: link an agent's forge profile and config repo from its overview

The agent card's overview gets a forge row with two links, built from
the forge entry of the swarm's service links.
This commit is contained in:
iris 2026-11-02 22:29:43 +01:00
commit e8e6150137
6 changed files with 129 additions and 2 deletions

View file

@ -73,8 +73,10 @@ and config PR.
An opened card has four sections, each folded except **overview**, and each
reads nothing until you open it:
- **overview**: the agent's turn state, context and cost, its hive, and its open
config PR.
- **overview**: the agent's turn state, context and cost, its hive, links to its
profile and its config repo on the swarm's forge (built from the forge entry
of `GET /api/links`; without one the row is left out), and its
open config PR.
- **settings**: inference, instructions, resource caps, permissions and allowed
domains, each with its own editor (described below).
- **accounts**: the agent's linked accounts, and the menu that links another.

View file

@ -12,6 +12,11 @@
.ui-agent-detail-fields dd {
margin: 0;
}
.ui-agent-detail-links {
display: flex;
flex-wrap: wrap;
gap: 0.4em 1em;
}
.ui-agent-detail-actions {
display: flex;
align-items: center;

View file

@ -22,6 +22,8 @@ import { AllowedDomains } from "./AllowedDomains.js";
import { AgentStateRow } from "./AgentStateRow.js";
import { AgentTermPreview } from "../terminal/AgentTermPreview.js";
import { canTurnIntoCustom, FRESHNESS, type AgentRow } from "../AgentTypes.js";
import { agentForgeLinks } from "./forge-links.js";
import { useForgeBase } from "./useForgeBase.js";
import { LinkAccountMenu } from "./LinkAccountMenu.js";
import { LinkedAccounts } from "./LinkedAccounts.js";
import { LazyDisclosure } from "../../ui/disclosure/LazyDisclosure.js";
@ -98,6 +100,8 @@ function OverviewRows({
row: AgentRow;
actions: AgentActions;
}) {
const forge = useForgeBase();
const forgeLinks = forge ? agentForgeLinks(forge, row.name) : null;
return (
<>
<dt>state</dt>
@ -106,6 +110,19 @@ function OverviewRows({
</dd>
<dt>hive</dt>
<dd>{row.hive ?? "—"}</dd>
{forgeLinks ? (
<>
<dt>forge</dt>
<dd class="ui-agent-detail-links">
<a href={forgeLinks.profile} target="_blank" rel="noreferrer">
profile
</a>
<a href={forgeLinks.configRepo} target="_blank" rel="noreferrer">
config repo
</a>
</dd>
</>
) : null}
<dt>config PR</dt>
<dd>
{row.config_pr ? (

View file

@ -0,0 +1,22 @@
// Where an agent lives on the swarm's forge, without a DOM: its profile and its
// config repo. The agent's forge login is its name, and its config repo is
// `agent-configs/<name>`. The forge's address is the swarm's "Forge" entry in
// `GET /api/links`, the same list the header's links menu shows.
import type { MenuLink } from "@hive/shared/links-menu.js";
export type AgentForgeLinks = { profile: string; configRepo: string };
/** The forge's base URL from the swarm's service links, or null without one. */
export function forgeBase(links: MenuLink[]): string | null {
const forge = links.find((l) => l.label.trim().toLowerCase() === "forge");
return forge ? forge.url.replace(/\/+$/, "") : null;
}
/** The agent's profile and config repo on the forge at `base`. */
export function agentForgeLinks(base: string, agent: string): AgentForgeLinks {
const name = encodeURIComponent(agent);
return {
profile: `${base}/${name}`,
configRepo: `${base}/agent-configs/${name}`,
};
}

View file

@ -0,0 +1,40 @@
// The swarm forge's base URL, from `GET /api/links`. Fetched once and shared by
// every agent card, so opening many cards sends one request. A failed fetch
// is not kept, so the next card retries; the links just stay hidden meanwhile.
import { useEffect, useState } from "preact/hooks";
import type { MenuLink } from "@hive/shared/links-menu.js";
import { forgeBase } from "./forge-links.js";
let pending: Promise<string | null> | null = null;
function loadForgeBase(): Promise<string | null> {
if (!pending) {
pending = fetch("/api/links")
.then((r) =>
r.ok
? (r.json() as Promise<MenuLink[]>)
: Promise.reject(new Error(`http ${r.status}`)),
)
.then(forgeBase)
.catch(() => {
pending = null;
return null;
});
}
return pending;
}
/** The forge's base URL, or null until known (or when the swarm lists none). */
export function useForgeBase(): string | null {
const [base, setBase] = useState<string | null>(null);
useEffect(() => {
let live = true;
void loadForgeBase().then((b) => {
if (live) setBase(b);
});
return () => {
live = false;
};
}, []);
return base;
}

View file

@ -0,0 +1,41 @@
// Where an agent lives on the swarm's forge: its profile and config repo, from
// the forge entry of the swarm's service links. Run by `npm test` with Node's
// type stripping.
import assert from "node:assert/strict";
import { test } from "node:test";
import { agentForgeLinks, forgeBase } from "../src/agent/detail/forge-links.ts";
const link = (label, url) => ({ label, icon: "x", url });
test("the forge base is the forge entry's URL, any case, without a trailing slash", () => {
assert.equal(
forgeBase([
link("Grafana", "https://grafana.example/"),
link("Forge", "https://forge.example/"),
]),
"https://forge.example",
);
assert.equal(
forgeBase([link("forge", "https://forge.example")]),
"https://forge.example",
);
});
test("no forge entry gives no base", () => {
assert.equal(forgeBase([]), null);
assert.equal(forgeBase([link("Grafana", "https://grafana.example/")]), null);
});
test("an agent's profile and config repo sit under the forge base", () => {
assert.deepEqual(agentForgeLinks("https://forge.example", "iris"), {
profile: "https://forge.example/iris",
configRepo: "https://forge.example/agent-configs/iris",
});
});
test("the agent name is encoded", () => {
assert.equal(
agentForgeLinks("https://forge.example", "a b").profile,
"https://forge.example/a%20b",
);
});