diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index ada2ef6e..98e38239 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -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. diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.css b/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.css index 289b1d5b..0fb4be5d 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.css +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.css @@ -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; diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx index bae373ef..ad8ac91b 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx @@ -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 ( <>
state
@@ -106,6 +110,19 @@ function OverviewRows({
hive
{row.hive ?? "—"}
+ {forgeLinks ? ( + <> +
forge
+ + + ) : null}
config PR
{row.config_pr ? ( diff --git a/frontend/packages/swarm-ui/src/agent/detail/forge-links.ts b/frontend/packages/swarm-ui/src/agent/detail/forge-links.ts new file mode 100644 index 00000000..511c1d4a --- /dev/null +++ b/frontend/packages/swarm-ui/src/agent/detail/forge-links.ts @@ -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/`. 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}`, + }; +} diff --git a/frontend/packages/swarm-ui/src/agent/detail/useForgeBase.ts b/frontend/packages/swarm-ui/src/agent/detail/useForgeBase.ts new file mode 100644 index 00000000..13b3ebb2 --- /dev/null +++ b/frontend/packages/swarm-ui/src/agent/detail/useForgeBase.ts @@ -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 | null = null; + +function loadForgeBase(): Promise { + if (!pending) { + pending = fetch("/api/links") + .then((r) => + r.ok + ? (r.json() as Promise) + : 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(null); + useEffect(() => { + let live = true; + void loadForgeBase().then((b) => { + if (live) setBase(b); + }); + return () => { + live = false; + }; + }, []); + return base; +} diff --git a/frontend/packages/swarm-ui/test/forge-links.test.mjs b/frontend/packages/swarm-ui/test/forge-links.test.mjs new file mode 100644 index 00000000..94389ad6 --- /dev/null +++ b/frontend/packages/swarm-ui/test/forge-links.test.mjs @@ -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", + ); +});