From 25060371efd2f9a8cd84fe841bc1cfa1e5656c70 Mon Sep 17 00:00:00 2001 From: Divijaa Date: Fri, 6 Nov 2026 22:05:20 +0530 Subject: [PATCH 1/4] fix(frontend): live agents as cards; no prior figures shown as a record The live Agents page was a six-column table: names, hex addresses and model ids wrapped mid-word, and the record column showed the prior ("50% pass ... (0 validated)") while the note under it said no figures are shown until an agent has validated outcomes. Each agent is now a card on the registry's grid (3, 2, then 1 across): name, id and model; ENS name and verdict with the reason when not verified; owner and receipt-signing key; and only categories with counted outcomes. An agent with none says "No validated outcomes yet". A test guards that rows with counted_outcomes 0 never show figures. --- frontend/e2e/live/overview.spec.ts | 6 +- frontend/e2e/live/run.spec.ts | 17 +++ frontend/src/screens/live/LiveAgents.tsx | 141 +++++++++++++---------- frontend/src/styles/screens/agents.css | 48 ++++++++ 4 files changed, 148 insertions(+), 64 deletions(-) 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/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 && ( - - - - - - - - - - - - - {agents.map((a) => { - const chain = onchain[a.agent_id]; - return ( - - - - - - - - - ); - })} - -
AgentENS nameModelOperator (paid)Receipt signing keyValidated record
- {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/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; +} From 4a41abba2d56958e6036e8dde325688a4fc09d7f Mon Sep 17 00:00:00 2001 From: Divijaa Date: Fri, 6 Nov 2026 22:05:20 +0530 Subject: [PATCH 2/4] feat(frontend): keep the new-run form while visiting other pages Typing a goal, going to another tab and coming back emptied the form. The goal, repository, branch, budget and kind of work are now kept in this browser tab (sessionStorage), keyed on the signed-in address so another account never sees them, and are dropped once the plan is drafted or the form is cleared. "Start again" still fills the form from the earlier plan instead. A stored draft that is malformed, too long or names an unknown kind is ignored. --- frontend/e2e/live/new-run.spec.ts | 43 ++++++++ frontend/src/screens/live/run/LiveNewRun.tsx | 54 ++++++++-- .../src/screens/live/run/newRunDraft.test.ts | 98 +++++++++++++++++++ frontend/src/screens/live/run/newRunDraft.ts | 84 ++++++++++++++++ 4 files changed, 271 insertions(+), 8 deletions(-) create mode 100644 frontend/src/screens/live/run/newRunDraft.test.ts create mode 100644 frontend/src/screens/live/run/newRunDraft.ts diff --git a/frontend/e2e/live/new-run.spec.ts b/frontend/e2e/live/new-run.spec.ts index e79cab5b..2debc82b 100644 --- a/frontend/e2e/live/new-run.spec.ts +++ b/frontend/e2e/live/new-run.spec.ts @@ -170,3 +170,46 @@ 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(""); +}); diff --git a/frontend/src/screens/live/run/LiveNewRun.tsx b/frontend/src/screens/live/run/LiveNewRun.tsx index a3fbbd9d..74171fec 100644 --- a/frontend/src/screens/live/run/LiveNewRun.tsx +++ b/frontend/src/screens/live/run/LiveNewRun.tsx @@ -14,6 +14,7 @@ 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 { 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,6 +345,14 @@ export function LiveNewRun() {

)} + {showRestored && ( +

+ Your unsent run from earlier in this tab is filled back in.{" "} + +

+ )} {from !== null && (

{prefillLoading ? "Loading your earlier plan…" : prefillNote?.text} 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. + } +} From d39095e7c7398a5e259258e7dff8c31d9b7c100f Mon Sep 17 00:00:00 2001 From: Divijaa Date: Fri, 6 Nov 2026 22:05:27 +0530 Subject: [PATCH 3/4] feat(frontend): say what each kind of work means on the new-run page The kind-of-work chips (Fix failing tests, Fix a bug, Security, Performance, Refactor, Docs) gave no hint of what they meant or what picking one changes. Each kind now has a one-line meaning, shown under the chips for the one picked (and as the chip's tooltip), followed by what the kind changes: the tasks the planner drafts, which agents are suggested (by their record in this category) and which record the result counts toward. The line is the fieldset's accessible description. --- frontend/e2e/live/new-run.spec.ts | 14 +++++++ frontend/src/screens/live/run/LiveNewRun.tsx | 10 ++++- frontend/src/screens/live/run/newRunForm.ts | 40 ++++++++++++++++++-- frontend/src/styles/screens/live-new-run.css | 7 ++++ 4 files changed, 65 insertions(+), 6 deletions(-) diff --git a/frontend/e2e/live/new-run.spec.ts b/frontend/e2e/live/new-run.spec.ts index 2debc82b..79f12780 100644 --- a/frontend/e2e/live/new-run.spec.ts +++ b/frontend/e2e/live/new-run.spec.ts @@ -213,3 +213,17 @@ test("what was typed is still there after visiting another page, until the plan 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/src/screens/live/run/LiveNewRun.tsx b/frontend/src/screens/live/run/LiveNewRun.tsx index 74171fec..baec654e 100644 --- a/frontend/src/screens/live/run/LiveNewRun.tsx +++ b/frontend/src/screens/live/run/LiveNewRun.tsx @@ -13,7 +13,7 @@ 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 { @@ -359,13 +359,14 @@ export function LiveNewRun() {

)} -
+
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/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/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; +} From 5f14f04be6969e614aa579c9b452912e92c3254a Mon Sep 17 00:00:00 2001 From: Divijaa Date: Fri, 6 Nov 2026 22:05:27 +0530 Subject: [PATCH 4/4] fix(frontend): name the nav tabs when only their icons show Below 1100px shell.css hides .tab-label, which left each primary nav tab as a link with no accessible name. Each tab now carries its label as aria-label and title. --- frontend/src/components/TopNav.tsx | 3 +++ 1 file changed, 3 insertions(+) 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}