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.
316 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|