Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/agent/detail/AgentInstructions.tsx
iris 5d5df29687 swarm-ui: shared code leaves page folders; SectionHeading and Preformatted replace per-page copies
durationLabel moves to util.ts, termPaths to agent/terminal/term-paths.ts,
StateBadges and tokenTotal to agent/state, and TryOutResult to
pages/review-common, so the builds, runs and agent pages no longer import
across page folders. SectionHeading is the body-size section heading the
create form and the egress card each styled; Preformatted is the scrolling
text block of the builds tail and the AGENTS.md view, which now share one
chrome and are both keyboard-scrollable. The components page shows both.
2026-11-06 19:09:18 +01:00

316 lines
10 KiB
TypeScript

// <AgentInstructions> — an editor for an agent's AGENTS.md, in a dialog opened
// from the agent's opened card.
//
// It loads the copy the agent's harness last published
// (`GET /api/agents/{name}/agents-md`, so a stopped agent shows the text it
// left) into a plain text box. Save sends a unified diff against the revision
// that was loaded (`PUT`), and the agent applies it with git to the file it
// really reads. The agent may have edited the file in the meantime; then the
// swarm refuses with 409 and the editor keeps the operator's text, shows the
// agent's version beside it, and offers the two ways on: save over it, or take
// the agent's version.
//
// Closing the dialog does not lose an unsaved edit: the wrapper keeps it, and
// the next open starts from it (and notices if the agent moved on).
import { useEffect, useRef, useState } from "preact/hooks";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Badge } from "@hive/shared/badge.js";
import { ActionRow } from "../../ui/action-row/ActionRow.js";
import { Button } from "../../ui/button/Button.js";
import { Preformatted } from "../../ui/preformatted/Preformatted.js";
import { Dialog } from "../../ui/dialog/Dialog.js";
import { Panel } from "../../ui/panel/Panel.js";
import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js";
import {
editBody,
instructionsUrl,
parseInstructions,
STALE_STATUS,
type Instructions,
} from "./instructions.js";
import { Skeleton, SkeletonRegion } from "../../ui/skeleton/Skeleton.js";
import "./AgentInstructions.css";
// An edit that was not saved: the text, and the version it was made on.
interface Unsaved {
base: Instructions;
text: string;
}
export function AgentInstructions({ agent }: { agent: string }) {
const [editing, setEditing] = useState(false);
const unsaved = useRef<Unsaved | null>(null);
return (
<>
<Badge
variant="quiet"
value="edit"
title={`read and change ${agent}'s AGENTS.md`}
onClick={() => setEditing(true)}
/>
<Dialog
open={editing}
onClose={() => setEditing(false)}
label={`edit ${agent}'s AGENTS.md`}
plain
>
{/* Remounted per open, so it starts from the kept edit or a fresh read. */}
{editing ? (
<InstructionsForm
agent={agent}
kept={unsaved.current}
onKeep={(u) => {
unsaved.current = u;
}}
onClose={() => setEditing(false)}
/>
) : null}
</Dialog>
</>
);
}
type Load =
| { status: "loading" }
| { status: "error"; problem: ProblemDetails }
| { status: "ready" };
async function fetchInstructions(agent: string): Promise<Instructions> {
const r = await fetch(instructionsUrl(agent));
if (!r.ok) throw await readApiError(r);
const parsed = parseInstructions(await r.json());
if (parsed === null) {
throw { detail: "the swarm's answer was not an AGENTS.md" };
}
return parsed;
}
function asProblem(e: unknown): ProblemDetails {
if (typeof e === "object" && e !== null) return e as ProblemDetails;
return { detail: String(e) };
}
function InstructionsForm({
agent,
kept,
onKeep,
onClose,
}: {
agent: string;
kept: Unsaved | null;
onKeep: (u: Unsaved | null) => void;
onClose: () => void;
}) {
const [load, setLoad] = useState<Load>({ status: "loading" });
// The version the draft is made against, and the draft.
const [base, setBase] = useState<Instructions | null>(null);
const [draft, setDraft] = useState("");
// The agent's version when it has moved past `base`: set when a save was
// refused as stale, or when the kept edit is older than the file.
const [theirs, setTheirs] = useState<Instructions | null>(null);
const [busy, setBusy] = useState(false);
const [problem, setProblem] = useState<ProblemDetails | null>(null);
const [saved, setSaved] = useState(false);
useEffect(() => {
let cancelled = false;
fetchInstructions(agent)
.then((current) => {
if (cancelled) return;
if (kept === null) {
setBase(current);
setDraft(current.content);
} else {
setBase(kept.base);
setDraft(kept.text);
if (current.revision !== kept.base.revision) setTheirs(current);
}
setLoad({ status: "ready" });
})
.catch((e: unknown) => {
if (!cancelled) setLoad({ status: "error", problem: asProblem(e) });
});
return () => {
cancelled = true;
};
// `kept` is only the starting point, read once when the form mounts.
}, [agent]);
const dirty = base !== null && draft !== base.content;
useEffect(() => {
if (base !== null) onKeep(dirty ? { base, text: draft } : null);
}, [base, draft, dirty]);
async function save(e: Event) {
e.preventDefault();
if (base === null) return;
const body = editBody(base, draft);
if (body === null) return;
setBusy(true);
setProblem(null);
setSaved(false);
try {
const r = await fetch(instructionsUrl(agent), {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (r.status === STALE_STATUS) {
// The agent changed the file: keep the draft, fetch what it is now.
setTheirs(await fetchInstructions(agent));
return;
}
if (!r.ok) {
setProblem(await readApiError(r));
return;
}
const answer = (await r.json()) as { revision?: unknown };
if (typeof answer.revision !== "string") {
setProblem({ detail: "the swarm's answer named no revision" });
return;
}
setBase({ content: draft, revision: answer.revision });
setSaved(true);
} catch (err: unknown) {
setProblem(asProblem(err));
} finally {
setBusy(false);
}
}
function keepMine() {
if (theirs === null) return;
setBase(theirs);
setTheirs(null);
}
function takeTheirs() {
if (theirs === null) return;
setBase(theirs);
setDraft(theirs.content);
setTheirs(null);
}
function reload() {
setLoad({ status: "loading" });
fetchInstructions(agent)
.then((current) => {
setBase(current);
setDraft(current.content);
setTheirs(null);
setProblem(null);
setLoad({ status: "ready" });
})
.catch((e: unknown) =>
setLoad({ status: "error", problem: asProblem(e) }),
);
}
return (
<Panel title={`AGENTS.md — ${agent}`} icon="📜" onClose={onClose}>
<p>
The instructions {agent} reads. This is the copy the agent last
published, so a stopped agent still shows. Saving needs the agent
running: it applies your change to its own file, and refuses when it
edited the file since you opened this.
</p>
<div class="ui-agent-instructions-body">
<p
class="ui-agent-instructions-status"
role="status"
aria-live="polite"
>
{busy ? "saving…" : saved ? "saved" : ""}
</p>
{load.status === "loading" ? (
<SkeletonRegion label="AGENTS.md">
<div class="ui-skeleton-lines">
<Skeleton width="6em" />
<Skeleton shape="block" width="100%" height="24em" />
</div>
</SkeletonRegion>
) : null}
{load.status === "error" ? (
<>
<ApiErrorPanel
context="loading AGENTS.md failed"
problem={load.problem}
/>
<ActionRow class="ui-agent-instructions-actions">
<Button onClick={reload}>try again</Button>
</ActionRow>
</>
) : null}
{load.status === "ready" ? (
<form class="ui-agent-instructions-form" onSubmit={save}>
<div class="ui-agent-instructions-text">
<TextAreaField
id={`agents-md-${agent}`}
label="AGENTS.md"
value={draft}
rows={16}
onInput={(text) => {
setDraft(text);
setSaved(false);
}}
/>
</div>
{theirs !== null ? (
<section class="ui-agent-instructions-stale" role="alert">
<p>
{agent} changed AGENTS.md since you started. Your text is kept
above. Here is the file now:
</p>
<Preformatted wrap maxHeight="16em">
{theirs.content === "" ? "(empty)" : theirs.content}
</Preformatted>
<ActionRow class="ui-agent-instructions-actions">
<Button onClick={keepMine}>
keep my text, replace {agent}'s changes
</Button>
<Button onClick={takeTheirs}>use {agent}'s version</Button>
</ActionRow>
</section>
) : null}
{problem ? (
<ApiErrorPanel context="saving failed" problem={problem} />
) : null}
<ActionRow class="ui-agent-instructions-actions">
<Button
variant="primary"
type="submit"
disabled={busy || !dirty || theirs !== null}
>
{busy ? "saving…" : "save"}
</Button>
<Button
disabled={busy || !dirty}
onClick={() => {
if (base !== null) setDraft(base.content);
setSaved(false);
}}
>
undo my changes
</Button>
<Button
disabled={busy}
onClick={() => {
if (
!dirty ||
window.confirm(
"Drop your unsaved changes and load the agent's file?",
)
)
reload();
}}
>
reload from {agent}
</Button>
</ActionRow>
</form>
) : null}
</div>
</Panel>
);
}