diff --git a/frontend/e2e/live/new-run.spec.ts b/frontend/e2e/live/new-run.spec.ts
index e79cab5b..79f12780 100644
--- a/frontend/e2e/live/new-run.spec.ts
+++ b/frontend/e2e/live/new-run.spec.ts
@@ -170,3 +170,60 @@ test("a free-trial account starts on a budget the free credits cover, and is tol
await page.getByLabel("Budget (USDC)").fill("5");
await expect(page.getByText("Free credits cover up to 2.00 USDC per plan; this plan is 5.00 USDC")).toBeVisible();
});
+
+test("what was typed is still there after visiting another page, until the plan is drafted or the form is cleared", async ({
+ page,
+}) => {
+ await mockBackend(page);
+ const calls = await mockPlanApi(page, { created: draftPlan() });
+ await fakeGitHub(page);
+ await page.goto("/app/runs/new");
+ await page.getByLabel("Goal").fill("The refund test passes again");
+ await page.getByRole("combobox", { name: "Repository" }).fill("someone/else");
+ await page.getByRole("radio", { name: "Docs" }).check();
+ await page.getByLabel("Budget (USDC)").fill("2");
+
+ // Away to another tab of the app and back, without a reload.
+ await page.getByRole("navigation").getByRole("link", { name: "Agents" }).click();
+ await expect(page).toHaveURL(/\/app\/agents$/);
+ // The Agents page loads lazily; until it shows, the form has not left the screen, so wait for it.
+ await expect(page.getByRole("heading", { name: "Agents", level: 1 })).toBeVisible();
+ await page.goBack();
+ await expect(page.getByLabel("Goal")).toHaveValue("The refund test passes again");
+ await expect(page.getByRole("combobox", { name: "Repository" })).toHaveValue("someone/else");
+ await expect(page.getByRole("radio", { name: "Docs" })).toBeChecked();
+ await expect(page.getByLabel("Budget (USDC)")).toHaveValue("2");
+ await expect(page.getByTestId("draft-restored")).toBeVisible();
+
+ // "Clear the form" starts over, and a reload keeps it empty.
+ await page.getByRole("button", { name: "Clear the form" }).click();
+ await expect(page.getByLabel("Goal")).toHaveValue("");
+ await expect(page.getByRole("radio", { name: "Fix failing tests" })).toBeChecked();
+ await page.reload();
+ await expect(page.getByLabel("Goal")).toHaveValue("");
+ await expect(page.getByTestId("draft-restored")).toHaveCount(0);
+
+ // Once the plan is drafted, the form is empty the next time.
+ await page.getByLabel("Goal").fill("The build passes on develop");
+ await page.getByRole("combobox", { name: "Repository" }).fill("someone/else");
+ await expect(page.getByRole("combobox", { name: "Branch" })).toHaveValue("develop");
+ await page.getByRole("button", { name: "Draft the plan" }).click();
+ await expect(page).toHaveURL(new RegExp(`/app/runs/${draftPlan().plan_uuid}$`));
+ expect(calls.filter((c) => c.method === "POST" && c.path === "/v0/plans")).toHaveLength(1);
+ await page.goto("/app/runs/new");
+ await expect(page.getByLabel("Goal")).toHaveValue("");
+});
+
+test("each kind of work says what it means, and the line follows the one picked", async ({ page }) => {
+ await mockBackend(page);
+ await mockPlanApi(page);
+ await fakeGitHub(page);
+ await page.goto("/app/runs/new");
+ const hint = page.getByTestId("kind-hint");
+ await expect(hint).toContainText("Fix failing tests:");
+ await expect(hint).toContainText("which agents are suggested");
+ await page.getByRole("radio", { name: "Security" }).check();
+ await expect(hint).toContainText("Security:");
+ await expect(hint).toContainText("a test that tries it");
+ await expect(page.getByRole("group", { name: "Kind of work" })).toHaveAccessibleDescription(/Security:/);
+});
diff --git a/frontend/e2e/live/overview.spec.ts b/frontend/e2e/live/overview.spec.ts
index 6c66dd98..f98d61ea 100644
--- a/frontend/e2e/live/overview.spec.ts
+++ b/frontend/e2e/live/overview.spec.ts
@@ -55,9 +55,9 @@ test("the overview shows real credits, balances and runs", async ({ page }) => {
test("the agents page lists the deployed agents with their on-chain operator", async ({ page }) => {
await page.goto("/app/agents");
- const rows = page.locator("tbody tr");
- await expect(rows).not.toHaveCount(0);
- await expect(rows.first()).toContainText("0x1111…1111");
+ const cards = page.locator('[data-testid^="agent-"]');
+ await expect(cards).not.toHaveCount(0);
+ await expect(cards.first()).toContainText("0x1111…1111");
});
test("the live header offers no control that is not wired up: no Search, no Notifications bell", async ({ page }) => {
diff --git a/frontend/e2e/live/run.spec.ts b/frontend/e2e/live/run.spec.ts
index fc95087d..898285a1 100644
--- a/frontend/e2e/live/run.spec.ts
+++ b/frontend/e2e/live/run.spec.ts
@@ -439,3 +439,20 @@ test("the agents list shows a validated record only where there is one, and says
await expect(page.getByTestId("record-1")).toHaveText("No validated outcomes yet");
await expect(page.getByText("configuration, not a measurement")).toBeVisible();
});
+
+test("the agents list shows no figures for an agent whose rows are only the prior (0 validated)", async ({ page }) => {
+ await mockBackend(page);
+ const prior = {
+ ...SEASONED,
+ agent_id: "1",
+ name: "coder-a",
+ basis: "prior (no counted outcomes)",
+ counted_outcomes: 0,
+ };
+ await mockPlanApi(page, { reputation: [prior, SEASONED] });
+ await page.goto("/app/agents");
+
+ await expect(page.getByTestId("record-2")).toContainText("7 validated");
+ await expect(page.getByTestId("record-1")).toHaveText("No validated outcomes yet");
+ await expect(page.getByTestId("record-1")).not.toContainText("% pass");
+});
diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx
index 22d88dce..8273e2e3 100644
--- a/frontend/src/components/TopNav.tsx
+++ b/frontend/src/components/TopNav.tsx
@@ -54,6 +54,9 @@ export function TopNav() {
to={tab.to}
className={`tab${active === tab.to ? " on" : ""}`}
aria-current={active === tab.to ? "page" : undefined}
+ // Below 1100px only the icon shows (shell.css hides .tab-label), so the name is given here too.
+ aria-label={tab.label}
+ title={tab.label}
>
{tab.label}
diff --git a/frontend/src/screens/live/LiveAgents.tsx b/frontend/src/screens/live/LiveAgents.tsx
index da945f4d..e64d7e9f 100644
--- a/frontend/src/screens/live/LiveAgents.tsx
+++ b/frontend/src/screens/live/LiveAgents.tsx
@@ -3,12 +3,13 @@ import type { Address } from "viem";
import { useAgents } from "../../api/useAgents";
import { byAgent, percent, usdc } from "../../api/reputation";
import { useReputation } from "../../api/useReputation";
-import type { AgentInfo } from "../../api/types";
+import type { AgentInfo, AgentReputation } from "../../api/types";
import { identityAbi } from "../../chain/abi";
import { publicClient } from "../../chain/client";
import { EnsIdentity } from "../../components/EnsIdentity";
import { PageHeader } from "../../components/PageHeader";
import { LIVE } from "../../config/live";
+import "../../styles/screens/agents.css";
type Onchain = { operator: Address; runtimeKey: Address };
@@ -26,6 +27,20 @@ function AddressLink({ address }: { address: Address }) {
);
}
+/** An address read from the Identity contract: a link once read, "unavailable" if the read failed, "…" before. */
+function ChainAddress({ value, pick }: { value: Onchain | null | undefined; pick: (c: Onchain) => Address }) {
+ if (value === undefined) return … ;
+ if (value === null) return unavailable ;
+ return ;
+}
+
+/**
+ * Only categories with counted outcomes. A row with none holds the prior, which is configuration and not a measurement
+ * (CLAUDE.md: no number may be presented as measured), so it is not shown as a record.
+ */
+const validated = (rows: AgentReputation[] | undefined): AgentReputation[] =>
+ (rows ?? []).filter((r) => r.counted_outcomes > 0);
+
/** Who owns each agent and which key signs its receipts, read from the Identity contract. */
function useOnchain(agents: AgentInfo[] | null) {
const [info, setInfo] = useState>({});
@@ -76,66 +91,70 @@ export function LiveAgents() {
title="Agents"
subtitle={`The agents you can pick for a task. Owner and signing key are read from the Identity contract on ${LIVE.chainName}.`}
/>
-
- {error && {error}
}
- {!error && agents === null && Loading agents…
}
- {agents?.length === 0 && No agents are deployed yet.
}
- {agents && agents.length > 0 && (
-
-
-
- Agent
- ENS name
- Model
- Operator (paid)
- Receipt signing key
- Validated record
-
-
-
- {agents.map((a) => {
- const chain = onchain[a.agent_id];
- return (
-
-
- {a.name} #{a.agent_id}
-
-
-
-
- {a.model}
- {chain ? : chain === null ? "unavailable" : "…"}
- {chain ? : chain === null ? "unavailable" : "…"}
-
- {reputation === null ? (
- "…"
- ) : (seen.get(a.agent_id) ?? []).length === 0 ? (
- No validated outcomes yet
- ) : (
-
- {(seen.get(a.agent_id) ?? []).map((r) => (
-
- {r.category} : {percent(r.success_mean)} pass, about{" "}
- {usdc(r.cost_mean_micros)} USDC a task, {r.value_density_per_usdc.toFixed(2)} passes per
- USDC ({String(r.counted_outcomes)} validated)
-
- ))}
-
- )}
-
-
- );
- })}
-
-
- )}
- {reputation && (
-
- {reputation.note} Until an agent has validated outcomes, no figures are shown for it here: it would only be
- a configured starting point, not a record.
-
- )}
-
+ {error && {error}
}
+ {!error && agents === null && Loading agents…
}
+ {agents?.length === 0 && No agents are deployed yet.
}
+ {agents && agents.length > 0 && (
+
+ {agents.map((a) => {
+ const chain = onchain[a.agent_id];
+ const record = validated(seen.get(a.agent_id));
+ return (
+
+
+
+
{a.name}
+ Agent #{a.agent_id}
+
+
+ {a.model}
+
+
+
+
+
+
+ Owner (paid)
+
+ c.operator} />
+
+ Signs receipts
+
+ c.runtimeKey} />
+
+
+
+
+ {reputation === null ? (
+
…
+ ) : record.length === 0 ? (
+
No validated outcomes yet
+ ) : (
+ record.map((r) => (
+
+
+ {r.category}
+ {String(r.counted_outcomes)} validated
+
+
+ {percent(r.success_mean)} pass · about {usdc(r.cost_mean_micros)} USDC a task ·{" "}
+ {r.value_density_per_usdc.toFixed(2)} passes per USDC
+
+
+ ))
+ )}
+
+
+ );
+ })}
+
+ )}
+ {reputation && (
+
+ {reputation.note} Until an agent has validated outcomes, no figures are shown for it here: they would only be
+ a configured starting point, not a record.
+
+ )}
>
);
}
diff --git a/frontend/src/screens/live/run/LiveNewRun.tsx b/frontend/src/screens/live/run/LiveNewRun.tsx
index a3fbbd9d..baec654e 100644
--- a/frontend/src/screens/live/run/LiveNewRun.tsx
+++ b/frontend/src/screens/live/run/LiveNewRun.tsx
@@ -13,7 +13,8 @@ import { formatUsdc, parseUsdc, usdcText } from "../../../chain/funding";
import { useCredits } from "../../../chain/useCredits";
import { Icon, type IconName } from "../../../components/Icon";
import { Combo, type ComboOption } from "./Combo";
-import { fieldProblems, GOAL_LIMIT, INITIAL, KINDS, STARTERS, toRepoUrl, type Fields } from "./newRunForm";
+import { fieldProblems, GOAL_LIMIT, INITIAL, KIND_EFFECT, KINDS, STARTERS, toRepoUrl, type Fields } from "./newRunForm";
+import { clearDraft, draftKey, loadDraft, saveDraft, type NewRunDraft } from "./newRunDraft";
import { Problems } from "./Problems";
import {
applyPrefill,
@@ -44,7 +45,42 @@ const STEPS: { icon: IconName; label: string }[] = [
export function LiveNewRun() {
const navigate = useNavigate();
const { account, session } = useAuth();
- const [fields, setFields] = useState(INITIAL);
+ // "Start again": an earlier plan named in the URL only fills fields that are still empty. It is read, never
+ // changed, and the form is still submitted, drafted, reviewed, approved and funded like any other.
+ const [searchParams] = useSearchParams();
+ const from = sourcePlanId(searchParams);
+ // What was typed here before going to another page comes back (newRunDraft.ts), except under "Start again", which
+ // fills the form from the earlier plan instead.
+ const key = draftKey(session?.address);
+ const [restored] = useState(() => (from === null ? loadDraft(key) : null));
+ const [fields, setFields] = useState(restored?.fields ?? INITIAL);
+ const [budgetTouched, setBudgetTouched] = useState(restored?.budgetTouched ?? false);
+ // A repository typed rather than picked starts on its own default branch, until a branch is chosen by hand.
+ const [refTouched, setRefTouched] = useState(restored?.refTouched ?? false);
+ const [showRestored, setShowRestored] = useState(restored !== null);
+ // Another account signing in on this tab gets its own draft (or an empty form), never the previous one's.
+ const [draftFor, setDraftFor] = useState(key);
+ if (draftFor !== key) {
+ setDraftFor(key);
+ const next = from === null ? loadDraft(key) : null;
+ setFields(next?.fields ?? INITIAL);
+ setBudgetTouched(next?.budgetTouched ?? false);
+ setRefTouched(next?.refTouched ?? false);
+ setShowRestored(next !== null);
+ }
+
+ useEffect(() => {
+ saveDraft(key, { fields, refTouched, budgetTouched });
+ }, [key, fields, refTouched, budgetTouched]);
+
+ const clearForm = () => {
+ clearDraft(key);
+ setFields(INITIAL);
+ setBudgetTouched(false);
+ setRefTouched(false);
+ setShowRestored(false);
+ document.getElementById("nr-goal")?.focus();
+ };
const { busy, problems, setProblems, submit } = useSubmit();
// One per page load: the same form sent twice (a retry, a second click) is the same plan.
const [nonce] = useState(() => crypto.randomUUID());
@@ -55,7 +91,6 @@ export function LiveNewRun() {
// A free-trial account can only pay from credits, which cover a plan up to a cap: start there, and say so if
// the budget goes over, instead of letting the plan be drafted and then refused at approval.
const trial = isTrialSession(account, session?.address);
- const [budgetTouched, setBudgetTouched] = useState(false);
const startBudget = trial ? creditBudget(credits) : null;
// Until the budget is edited by hand, it is the credit-covered amount once the credits are known.
const budget = !budgetTouched && startBudget !== null ? usdcText(startBudget) : fields.budget;
@@ -77,12 +112,6 @@ export function LiveNewRun() {
const { repos, error: reposError, loading: reposLoading } = useRepoList(owner);
const chosen = parseRepo(fields.repo);
const { branches, defaultBranch, error: branchesError, loading: branchesLoading } = useBranches(chosen);
- // A repository typed rather than picked starts on its own default branch, until a branch is chosen by hand.
- const [refTouched, setRefTouched] = useState(false);
- // "Start again": an earlier plan named in the URL only fills fields that are still empty. It is read, never
- // changed, and the form is still submitted, drafted, reviewed, approved and funded like any other.
- const [searchParams] = useSearchParams();
- const from = sourcePlanId(searchParams);
const [prefillNote, setPrefillNote] = useState<{ from: string; text: string } | null>(null);
const prefillLoading = from !== null && prefillNote?.from !== from;
@@ -204,6 +233,7 @@ export function LiveNewRun() {
category: fields.category,
};
const plan = await createPlan(request, planKey(nonce, request));
+ clearDraft(key);
await navigate(paths.run(plan.plan_uuid));
});
};
@@ -315,19 +345,28 @@ export function LiveNewRun() {
)}
+ {showRestored && (
+
+ Your unsent run from earlier in this tab is filled back in.{" "}
+
+ Clear the form
+
+
+ )}
{from !== null && (
{prefillLoading ? "Loading your earlier plan…" : prefillNote?.text}
)}
-
+
Kind of work
{KINDS.map((k, i) => (
))}
+ {kind && (
+
+ {kind.label}: {kind.hint} {KIND_EFFECT}
+
+ )}
{STARTERS.map((s, i) => {
diff --git a/frontend/src/screens/live/run/newRunDraft.test.ts b/frontend/src/screens/live/run/newRunDraft.test.ts
new file mode 100644
index 00000000..ccef4a0a
--- /dev/null
+++ b/frontend/src/screens/live/run/newRunDraft.test.ts
@@ -0,0 +1,98 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { clearDraft, draftKey, isEmptyDraft, loadDraft, type NewRunDraft, parseDraft, saveDraft } from "./newRunDraft";
+import { GOAL_LIMIT, INITIAL } from "./newRunForm";
+
+const typed: NewRunDraft = {
+ fields: { goal: "The refund test passes", repo: "acme/shop", ref: "dev", budget: "3", category: "bug-fix" },
+ refTouched: true,
+ budgetTouched: true,
+};
+const empty: NewRunDraft = { fields: { ...INITIAL }, refTouched: false, budgetTouched: false };
+
+describe("keeping the new-run form across pages", () => {
+ let store: Map;
+ beforeEach(() => {
+ // The unit tests run in Node, which has no sessionStorage.
+ store = new Map();
+ vi.stubGlobal("sessionStorage", {
+ getItem: (k: string) => store.get(k) ?? null,
+ setItem: (k: string, v: string) => store.set(k, v),
+ removeItem: (k: string) => store.delete(k),
+ });
+ });
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("gives back what was typed", () => {
+ const key = draftKey("0xAbC");
+ saveDraft(key, typed);
+ expect(loadDraft(key)).toEqual(typed);
+ });
+
+ it("is kept per signed-in address, so another account in the same tab does not see it", () => {
+ saveDraft(draftKey("0xabc"), typed);
+ expect(loadDraft(draftKey("0xdef"))).toBeNull();
+ expect(loadDraft(draftKey("0xABC"))).toEqual(typed);
+ });
+
+ it("forgets the draft when the form is back to its starting state", () => {
+ const key = draftKey("0xabc");
+ saveDraft(key, typed);
+ saveDraft(key, empty);
+ expect(store.has(key)).toBe(false);
+ });
+
+ it("is gone once cleared (after the plan is drafted)", () => {
+ const key = draftKey("0xabc");
+ saveDraft(key, typed);
+ clearDraft(key);
+ expect(loadDraft(key)).toBeNull();
+ });
+
+ it("keeps working when storage is unavailable (private mode)", () => {
+ vi.stubGlobal("sessionStorage", {
+ getItem: () => {
+ throw new Error("denied");
+ },
+ setItem: () => {
+ throw new Error("denied");
+ },
+ removeItem: () => {
+ throw new Error("denied");
+ },
+ });
+ const key = draftKey("0xabc");
+ expect(() => {
+ saveDraft(key, typed);
+ clearDraft(key);
+ }).not.toThrow();
+ expect(loadDraft(key)).toBeNull();
+ });
+});
+
+describe("reading a stored draft", () => {
+ it("ignores anything that is not a draft this form wrote", () => {
+ expect(parseDraft(null)).toBeNull();
+ expect(parseDraft("not json")).toBeNull();
+ expect(parseDraft("[]")).toBeNull();
+ expect(parseDraft(JSON.stringify({ fields: typed.fields }))).toBeNull();
+ expect(parseDraft(JSON.stringify({ ...typed, fields: { ...typed.fields, goal: 7 } }))).toBeNull();
+ });
+
+ it("refuses a kind of work the form does not offer", () => {
+ expect(parseDraft(JSON.stringify({ ...typed, fields: { ...typed.fields, category: "mining" } }))).toBeNull();
+ });
+
+ it("refuses a goal longer than the form allows", () => {
+ const long = { ...typed, fields: { ...typed.fields, goal: "x".repeat(GOAL_LIMIT + 1) } };
+ expect(parseDraft(JSON.stringify(long))).toBeNull();
+ });
+
+ it("calls a form with nothing typed or chosen empty, and anything else not", () => {
+ expect(isEmptyDraft(empty)).toBe(true);
+ expect(isEmptyDraft(typed)).toBe(false);
+ expect(isEmptyDraft({ ...empty, fields: { ...INITIAL, category: "docs" } })).toBe(false);
+ expect(isEmptyDraft({ ...empty, budgetTouched: true })).toBe(false);
+ });
+});
diff --git a/frontend/src/screens/live/run/newRunDraft.ts b/frontend/src/screens/live/run/newRunDraft.ts
new file mode 100644
index 00000000..5bbad5af
--- /dev/null
+++ b/frontend/src/screens/live/run/newRunDraft.ts
@@ -0,0 +1,84 @@
+import { type Fields, GOAL_LIMIT, INITIAL, KINDS } from "./newRunForm";
+
+/**
+ * The new-run form, kept while the requester looks at another page: leaving for Agents or Wallet and coming back
+ * finds the goal, repository, branch, budget and kind of work as they were left. It lives in this browser tab only
+ * (sessionStorage), keyed on the signed-in address so another account in the same tab never sees it, and is
+ * dropped once the plan is drafted or the form is cleared. Nothing here is sent anywhere.
+ */
+export type NewRunDraft = {
+ fields: Fields;
+ /** The branch was chosen by hand, so the repository's default branch does not replace it. */
+ refTouched: boolean;
+ /** The budget was edited by hand, so the free-credit amount does not replace it. */
+ budgetTouched: boolean;
+};
+
+const PREFIX = "mergit-new-run-draft:";
+/** Longest stored text for the repository, branch and budget fields; anything longer is not a draft this form wrote. */
+const FIELD_LIMIT = 512;
+
+export const draftKey = (address: string | null | undefined): string =>
+ `${PREFIX}${(address ?? "signed-out").toLowerCase()}`;
+
+/** Nothing typed and nothing chosen: no draft is worth keeping. */
+export const isEmptyDraft = (d: NewRunDraft): boolean =>
+ !d.refTouched &&
+ !d.budgetTouched &&
+ d.fields.goal.trim() === "" &&
+ d.fields.repo.trim() === "" &&
+ d.fields.ref === INITIAL.ref &&
+ d.fields.budget === INITIAL.budget &&
+ d.fields.category === INITIAL.category;
+
+const text = (value: unknown, limit: number): string | null =>
+ typeof value === "string" && value.length <= limit ? value : null;
+
+/** A stored draft read back. Anything malformed (edited by hand, from an older version) is ignored, not repaired. */
+export function parseDraft(raw: string | null): NewRunDraft | null {
+ if (raw === null) return null;
+ let data: unknown;
+ try {
+ data = JSON.parse(raw);
+ } catch {
+ return null;
+ }
+ if (typeof data !== "object" || data === null) return null;
+ const { fields, refTouched, budgetTouched } = data as Record;
+ if (typeof fields !== "object" || fields === null) return null;
+ if (typeof refTouched !== "boolean" || typeof budgetTouched !== "boolean") return null;
+ const f = fields as Record;
+ const goal = text(f.goal, GOAL_LIMIT);
+ const repo = text(f.repo, FIELD_LIMIT);
+ const ref = text(f.ref, FIELD_LIMIT);
+ const budget = text(f.budget, FIELD_LIMIT);
+ const category = typeof f.category === "string" && KINDS.some((k) => k.value === f.category) ? f.category : null;
+ if (goal === null || repo === null || ref === null || budget === null || category === null) return null;
+ return { fields: { goal, repo, ref, budget, category }, refTouched, budgetTouched };
+}
+
+export function loadDraft(key: string): NewRunDraft | null {
+ try {
+ return parseDraft(sessionStorage.getItem(key));
+ } catch {
+ return null;
+ }
+}
+
+/** Keeps the draft, or forgets it once the form is back to its starting state. */
+export function saveDraft(key: string, draft: NewRunDraft): void {
+ try {
+ if (isEmptyDraft(draft)) sessionStorage.removeItem(key);
+ else sessionStorage.setItem(key, JSON.stringify(draft));
+ } catch {
+ // Private mode or storage full: the form still works, it just is not kept across pages.
+ }
+}
+
+export function clearDraft(key: string): void {
+ try {
+ sessionStorage.removeItem(key);
+ } catch {
+ // Nothing was kept.
+ }
+}
diff --git a/frontend/src/screens/live/run/newRunForm.ts b/frontend/src/screens/live/run/newRunForm.ts
index dfe61176..99a54604 100644
--- a/frontend/src/screens/live/run/newRunForm.ts
+++ b/frontend/src/screens/live/run/newRunForm.ts
@@ -7,29 +7,61 @@ import type { IconName } from "../../../components/Icon";
export const GOAL_LIMIT = 8000;
+/** Said under every kind's hint: what picking a kind changes. */
+export const KIND_EFFECT =
+ "The kind shapes the tasks Mergit drafts, which agents are suggested (by their record in this kind of work), and which record the result counts toward.";
+
/** Kinds of work. The planner is told the kind (its prompt names the task category); the API takes any slug. */
-export const KINDS: { value: string; label: string; icon: IconName; placeholder: string }[] = [
+export const KINDS: {
+ value: string;
+ label: string;
+ icon: IconName;
+ placeholder: string;
+ /** What this kind of work means, shown under the chips for the one picked. */
+ hint: string;
+}[] = [
{
value: "test-repair",
label: "Fix failing tests",
icon: "flask",
placeholder: "The refund test in payments/ passes again",
+ hint: "A test that fails today passes again, without the test being weakened or skipped.",
+ },
+ {
+ value: "bug-fix",
+ label: "Fix a bug",
+ icon: "bug",
+ placeholder: "Expired sessions no longer log users out",
+ hint: "Something behaves wrongly. It is fixed, and a new test shows it stays fixed.",
},
- { value: "bug-fix", label: "Fix a bug", icon: "bug", placeholder: "Expired sessions no longer log users out" },
{
value: "security",
label: "Security",
icon: "shield",
placeholder: "The upload endpoint rejects paths outside the uploads folder",
+ hint: "A way to misuse the code is closed: unsafe input, access or paths are refused, with a test that tries it.",
},
{
value: "performance",
label: "Performance",
icon: "bolt",
placeholder: "The search endpoint answers in under 200 ms on the test data",
+ hint: "Something gets faster or lighter. Give a target that can be measured, like a time or a size.",
+ },
+ {
+ value: "refactor",
+ label: "Refactor",
+ icon: "wrench",
+ placeholder: "The billing module has no circular imports",
+ hint: "The code is reorganised without changing what it does: the existing tests still pass.",
+ },
+ {
+ value: "docs",
+ label: "Docs",
+ icon: "book",
+ placeholder: "Every public function in api/ has a docstring",
+ hint: "Documentation is written or corrected, such as docstrings or a README, to match what the code does.",
},
- { value: "refactor", label: "Refactor", icon: "wrench", placeholder: "The billing module has no circular imports" },
- { value: "docs", label: "Docs", icon: "book", placeholder: "Every public function in api/ has a docstring" },
];
/** Starting points: each fills the goal with a sentence to finish, and picks its kind of work. */
diff --git a/frontend/src/styles/screens/agents.css b/frontend/src/styles/screens/agents.css
index 7bc34a19..df9c19b4 100644
--- a/frontend/src/styles/screens/agents.css
+++ b/frontend/src/styles/screens/agents.css
@@ -342,3 +342,51 @@
padding: 24px 20px;
}
}
+
+/* Live agents (screens/live/LiveAgents.tsx): one card per deployed agent, on the registry's grid. */
+.la-card:hover {
+ translate: none;
+}
+.la-head {
+ align-items: flex-start;
+}
+.la-name {
+ overflow-wrap: anywhere;
+}
+.la-model {
+ max-width: 55%;
+ padding: 3px 8px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ color: var(--ink2);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.la-facts {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 8px 16px;
+ align-items: baseline;
+ margin: 0;
+ padding-top: 14px;
+ border-top: 1px solid var(--border);
+}
+.la-facts dd {
+ margin: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.la-record {
+ flex: 1;
+ padding-top: 14px;
+ border-top: 1px solid var(--border);
+}
+.la-record-row + .la-record-row {
+ padding-top: 8px;
+ border-top: 1px dashed var(--border);
+}
+.la-note {
+ margin-top: 16px;
+}
diff --git a/frontend/src/styles/screens/live-new-run.css b/frontend/src/styles/screens/live-new-run.css
index 5262b1c0..c7790b2d 100644
--- a/frontend/src/styles/screens/live-new-run.css
+++ b/frontend/src/styles/screens/live-new-run.css
@@ -465,3 +465,10 @@
justify-content: center;
}
}
+/* What the picked kind of work means, under the chips. */
+.nr-kind-hint {
+ max-width: 640px;
+ margin: -4px auto 0;
+ text-align: center;
+ text-wrap: balance;
+}