what this creates
+
+ what this creates
+
Creating queues everything a new agent needs: a swarm-level
identity, a config repo on the forge with the operator added as
diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentGroupPage.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentGroupPage.tsx
index 045b3af3..876701e8 100644
--- a/frontend/packages/swarm-ui/src/pages/agents/AgentGroupPage.tsx
+++ b/frontend/packages/swarm-ui/src/pages/agents/AgentGroupPage.tsx
@@ -15,10 +15,8 @@ import { Badge } from "@hive/shared/badge.js";
import { CloseIcon, ExpandIcon } from "@hive/shared/icons.js";
import { AgentIcon } from "../../agent/AgentIcon.js";
import { SubagentsMenu } from "../../agent/terminal/SubagentsMenu.js";
-import {
- AgentTermPreview,
- termPaths,
-} from "../../agent/terminal/AgentTermPreview.js";
+import { AgentTermPreview } from "../../agent/terminal/AgentTermPreview.js";
+import { termPaths } from "../../agent/terminal/term-paths.js";
import {
agentsIn,
groupHref,
diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx
index 0b1e22c9..bec3fa40 100644
--- a/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx
+++ b/frontend/packages/swarm-ui/src/pages/agents/AgentTerminalPage.tsx
@@ -15,10 +15,8 @@ import { Badge } from "@hive/shared/badge.js";
import { AgentDetailPopover } from "../../agent/detail/AgentDetailPopover.js";
import { AgentIcon } from "../../agent/AgentIcon.js";
import { SubagentsMenu } from "../../agent/terminal/SubagentsMenu.js";
-import {
- AgentTermPreview,
- termPaths,
-} from "../../agent/terminal/AgentTermPreview.js";
+import { AgentTermPreview } from "../../agent/terminal/AgentTermPreview.js";
+import { termPaths } from "../../agent/terminal/term-paths.js";
import { useAgentFavicon } from "../../agent/terminal/useAgentFavicon.js";
import {
groupHref,
diff --git a/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.css b/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.css
index 50bfd3df..5745fc34 100644
--- a/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.css
+++ b/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.css
@@ -10,12 +10,3 @@
overflow-wrap: anywhere;
}
/* What nix printed: kept as printed, scrolls sideways rather than wrapping. */
-.ui-builds-tail {
- margin: 0;
- padding: 0.6em 0.75em;
- max-height: 24em;
- overflow: auto;
- border-radius: 0.4em;
- background: color-mix(in srgb, var(--crust) 60%, transparent);
- font-size: 0.85em;
-}
diff --git a/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx b/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx
index da5a3b26..a351b265 100644
--- a/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx
+++ b/frontend/packages/swarm-ui/src/pages/builds/BuildsPage.tsx
@@ -15,6 +15,7 @@ import {
ExpandableCardListSkeleton,
} from "../../ui/expandable-card/ExpandableCard.js";
import { Hint } from "../../ui/hint/Hint.js";
+import { Preformatted } from "../../ui/preformatted/Preformatted.js";
import { Toolbar } from "../../ui/toolbar/Toolbar.js";
import { LazyDisclosure } from "../../ui/disclosure/LazyDisclosure.js";
import {
@@ -23,7 +24,7 @@ import {
} from "../../ui/refresh-interval/RefreshInterval.js";
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
-import { durationLabel } from "../review-runs/RunsTypes.js";
+import { durationLabel } from "../../util.js";
import {
OUTCOME_LABEL,
OUTCOME_TONE,
@@ -80,7 +81,7 @@ function BuildCard({
<>
{body.label}
{body.preformatted ? (
-
{body.text}
+ {body.text}
) : (
{body.text}
)}
diff --git a/frontend/packages/swarm-ui/src/pages/review-tryout/TryOutResult.css b/frontend/packages/swarm-ui/src/pages/review-common/TryOutResult.css
similarity index 100%
rename from frontend/packages/swarm-ui/src/pages/review-tryout/TryOutResult.css
rename to frontend/packages/swarm-ui/src/pages/review-common/TryOutResult.css
diff --git a/frontend/packages/swarm-ui/src/pages/review-tryout/TryOutResult.tsx b/frontend/packages/swarm-ui/src/pages/review-common/TryOutResult.tsx
similarity index 97%
rename from frontend/packages/swarm-ui/src/pages/review-tryout/TryOutResult.tsx
rename to frontend/packages/swarm-ui/src/pages/review-common/TryOutResult.tsx
index 15138c4c..0db67463 100644
--- a/frontend/packages/swarm-ui/src/pages/review-tryout/TryOutResult.tsx
+++ b/frontend/packages/swarm-ui/src/pages/review-common/TryOutResult.tsx
@@ -6,7 +6,10 @@
import { Badge } from "@hive/shared/badge.js";
import { Hint } from "../../ui/hint/Hint.js";
import { Table, type TableColumn } from "../../ui/table/Table.js";
-import type { TryOutResult as Result, TryOutRow } from "./TryOutTypes.js";
+import type {
+ TryOutResult as Result,
+ TryOutRow,
+} from "../review-tryout/TryOutTypes.js";
import "./TryOutResult.css";
const VERDICT_LABEL: Record = {
diff --git a/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.tsx b/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.tsx
index d2fa998e..c5fdf32f 100644
--- a/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.tsx
+++ b/frontend/packages/swarm-ui/src/pages/review-runs/ReviewRuns.tsx
@@ -21,13 +21,13 @@ import {
} from "../../ui/refresh-interval/RefreshInterval.js";
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
-import { TryOutResult } from "../review-tryout/TryOutResult.js";
+import { TryOutResult } from "../review-common/TryOutResult.js";
+import { durationLabel } from "../../util.js";
import {
OUTCOME_LABEL,
OUTCOME_TONE,
TRIGGER_LABEL,
classificationCount,
- durationLabel,
type ClassifierRun,
} from "./RunsTypes.js";
import "./ReviewRuns.css";
diff --git a/frontend/packages/swarm-ui/src/pages/review-runs/RunsTypes.ts b/frontend/packages/swarm-ui/src/pages/review-runs/RunsTypes.ts
index c07563e1..a4860115 100644
--- a/frontend/packages/swarm-ui/src/pages/review-runs/RunsTypes.ts
+++ b/frontend/packages/swarm-ui/src/pages/review-runs/RunsTypes.ts
@@ -60,16 +60,6 @@ export const TRIGGER_LABEL: Record = {
review: "requested review",
};
-/** How long a run took, as `45s`, `3m 12s` or `1h 4m`. A clock that ran
- * backwards reads as `0s`. */
-export function durationLabel(startedUnix: number, finishedUnix: number) {
- const total = Math.max(0, Math.round(finishedUnix - startedUnix));
- if (total < 60) return `${total}s`;
- const minutes = Math.floor(total / 60);
- if (minutes < 60) return `${minutes}m ${total % 60}s`;
- return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
-}
-
/** How many classifications ran, in words. A run that never got as far as
* running any has no rows. */
export function classificationCount(run: ClassifierRun): string {
diff --git a/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOut.tsx b/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOut.tsx
index 5948fa8b..e7093a56 100644
--- a/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOut.tsx
+++ b/frontend/packages/swarm-ui/src/pages/review-tryout/ReviewTryOut.tsx
@@ -33,7 +33,7 @@ import { ExternalLink } from "../../ui/external-link/ExternalLink.js";
import { Panel } from "../../ui/panel/Panel.js";
import { TextField } from "../../ui/text-field/TextField.js";
import { ClassificationEditor } from "./ClassificationEditor.js";
-import { TryOutResult } from "./TryOutResult.js";
+import { TryOutResult } from "../review-common/TryOutResult.js";
import {
addProblem,
draftFromRule,
diff --git a/frontend/packages/swarm-ui/src/pages/swarm/EgressCard.tsx b/frontend/packages/swarm-ui/src/pages/swarm/EgressCard.tsx
index 08c01481..3fb165e6 100644
--- a/frontend/packages/swarm-ui/src/pages/swarm/EgressCard.tsx
+++ b/frontend/packages/swarm-ui/src/pages/swarm/EgressCard.tsx
@@ -25,6 +25,7 @@ import { Button } from "../../ui/button/Button.js";
import { LazyDisclosure } from "../../ui/disclosure/LazyDisclosure.js";
import { Dialog } from "../../ui/dialog/Dialog.js";
import { Hint } from "../../ui/hint/Hint.js";
+import { SectionHeading } from "../../ui/section-heading/SectionHeading.js";
import "../../agent/detail/AgentLimits.css";
import "./SwarmPage.css";
@@ -61,7 +62,7 @@ function DomainSection({
const note = kind === "deny" ? behindNote(behind) : null;
return (
- {heading}
+ {heading}
{help}
{errorPanel}
{entries === null ? (
diff --git a/frontend/packages/swarm-ui/src/pages/swarm/SwarmPage.css b/frontend/packages/swarm-ui/src/pages/swarm/SwarmPage.css
index 3666dd77..56ce0370 100644
--- a/frontend/packages/swarm-ui/src/pages/swarm/SwarmPage.css
+++ b/frontend/packages/swarm-ui/src/pages/swarm/SwarmPage.css
@@ -36,8 +36,7 @@
border-top: 1px dashed var(--border);
}
.ui-swarm-egress-heading {
- margin: 0 0 0.35em;
- font-size: 1em;
+ margin-bottom: 0.35em;
}
.ui-swarm-egress-behind {
margin: 0 0 0.75em;
diff --git a/frontend/packages/swarm-ui/src/ui/preformatted/Preformatted.css b/frontend/packages/swarm-ui/src/ui/preformatted/Preformatted.css
new file mode 100644
index 00000000..d61e4e4c
--- /dev/null
+++ b/frontend/packages/swarm-ui/src/ui/preformatted/Preformatted.css
@@ -0,0 +1,13 @@
+.ui-preformatted {
+ margin: 0;
+ padding: 0.6em 0.75em;
+ overflow: auto;
+ border: 1px solid var(--border, currentColor);
+ border-radius: 0.4em;
+ background: color-mix(in srgb, var(--crust) 60%, transparent);
+ font-family: var(--mono, ui-monospace, monospace);
+ font-size: 0.85em;
+}
+.ui-preformatted-wrap {
+ white-space: pre-wrap;
+}
diff --git a/frontend/packages/swarm-ui/src/ui/preformatted/Preformatted.tsx b/frontend/packages/swarm-ui/src/ui/preformatted/Preformatted.tsx
new file mode 100644
index 00000000..63fd1030
--- /dev/null
+++ b/frontend/packages/swarm-ui/src/ui/preformatted/Preformatted.tsx
@@ -0,0 +1,26 @@
+// — a block of text kept as it is: a build's log tail, a file's
+// contents. It scrolls when it is taller than `maxHeight`, and the block takes
+// keyboard focus so a keyboard user can scroll it. A log keeps its long lines
+// and scrolls sideways; text meant to be read (`wrap`) breaks them.
+import "./Preformatted.css";
+
+export function Preformatted({
+ wrap = false,
+ maxHeight = "24em",
+ children,
+}: {
+ wrap?: boolean;
+ /** Any CSS length. */
+ maxHeight?: string;
+ children: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/packages/swarm-ui/src/ui/section-heading/SectionHeading.css b/frontend/packages/swarm-ui/src/ui/section-heading/SectionHeading.css
new file mode 100644
index 00000000..3fac84a9
--- /dev/null
+++ b/frontend/packages/swarm-ui/src/ui/section-heading/SectionHeading.css
@@ -0,0 +1,4 @@
+.ui-section-heading {
+ margin: 0;
+ font-size: 1em;
+}
diff --git a/frontend/packages/swarm-ui/src/ui/section-heading/SectionHeading.tsx b/frontend/packages/swarm-ui/src/ui/section-heading/SectionHeading.tsx
new file mode 100644
index 00000000..496958b6
--- /dev/null
+++ b/frontend/packages/swarm-ui/src/ui/section-heading/SectionHeading.tsx
@@ -0,0 +1,18 @@
+// — the heading of one section inside a page or a card: body
+// text size, bold, no margin of its own. A page that needs space around it
+// passes a `class` for the margin; the size and weight are the same everywhere.
+import type { ComponentChildren } from "preact";
+import "./SectionHeading.css";
+
+export function SectionHeading({
+ as: Tag = "h3",
+ class: extraClass,
+ children,
+}: {
+ as?: "h2" | "h3" | "h4";
+ class?: string;
+ children: ComponentChildren;
+}) {
+ const classes = ["ui-section-heading", extraClass].filter(Boolean).join(" ");
+ return {children};
+}
diff --git a/frontend/packages/swarm-ui/src/util.ts b/frontend/packages/swarm-ui/src/util.ts
index 7b8f9e4e..098574f7 100644
--- a/frontend/packages/swarm-ui/src/util.ts
+++ b/frontend/packages/swarm-ui/src/util.ts
@@ -31,3 +31,13 @@ export function fmtAgo(ageSeconds: number): string {
if (age < 86400) return RTF.format(-Math.floor(age / 3600), "hour");
return RTF.format(-Math.floor(age / 86400), "day");
}
+
+/** How long a run took, as `45s`, `3m 12s` or `1h 4m`. A clock that ran
+ * backwards reads as `0s`. */
+export function durationLabel(startedUnix: number, finishedUnix: number) {
+ const total = Math.max(0, Math.round(finishedUnix - startedUnix));
+ if (total < 60) return `${total}s`;
+ const minutes = Math.floor(total / 60);
+ if (minutes < 60) return `${minutes}m ${total % 60}s`;
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
+}
diff --git a/frontend/packages/swarm-ui/test/tidy-helpers.test.mjs b/frontend/packages/swarm-ui/test/tidy-helpers.test.mjs
new file mode 100644
index 00000000..ee314abd
--- /dev/null
+++ b/frontend/packages/swarm-ui/test/tidy-helpers.test.mjs
@@ -0,0 +1,26 @@
+// Two helpers that used to live in page folders: how long a run took, and the
+// paths one terminal is reached at.
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { durationLabel } from "../src/util.ts";
+import { termPaths } from "../src/agent/terminal/term-paths.ts";
+
+test("durationLabel names seconds, minutes and hours", () => {
+ assert.equal(durationLabel(0, 45), "45s");
+ assert.equal(durationLabel(0, 192), "3m 12s");
+ assert.equal(durationLabel(0, 3840), "1h 4m");
+});
+
+test("durationLabel reads a clock that ran backwards as 0s", () => {
+ assert.equal(durationLabel(100, 90), "0s");
+});
+
+test("an agent's terminal has a turn-state stream, a subagent's has none", () => {
+ const agent = termPaths("iris");
+ assert.equal(agent.page, "/agents/iris/terminal");
+ assert.equal(agent.state, "/api/agents/iris/state/stream");
+ const sub = termPaths("iris", "t 1");
+ assert.equal(sub.label, "iris/t 1");
+ assert.equal(sub.page, "/agents/iris/subagents/t%201/terminal");
+ assert.equal(sub.state, null);
+});