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 && ( - - - - - - - - - - - - - {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/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.{" "} + +

+ )} {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; +}