diff --git a/panel/src/components/settings/__tests__/ai-routing-card.test.tsx b/panel/src/components/settings/__tests__/ai-routing-card.test.tsx index 22dbc6dd..0f44898a 100644 --- a/panel/src/components/settings/__tests__/ai-routing-card.test.tsx +++ b/panel/src/components/settings/__tests__/ai-routing-card.test.tsx @@ -413,6 +413,27 @@ describe("AIRoutingCard", () => { expect(mixSection.querySelector(".animate-pulse")).toBeInTheDocument(); }); + it("shows an error note (not a silently empty grid) when the roster fetch fails", async () => { + useAgentDefinitions.mockReturnValue({ + data: undefined, + isLoading: false, + isError: true, + }); + render(withQueryClient()); + await screen.findByText("Per-agent override (mix mode)"); + + expect( + screen.getByText(/Couldn.t load the agent roster/i), + ).toBeInTheDocument(); + expect( + screen.queryByRole("heading", { level: 4, name: "Board" }), + ).not.toBeInTheDocument(); + const mixSection = screen + .getByText("Per-agent override (mix mode)") + .closest("section")!; + expect(mixSection.querySelector(".animate-pulse")).not.toBeInTheDocument(); + }); + it("tooltip-wraps the Grok/Ollama key labels and status badges, not the raw Switch", async () => { render(withQueryClient()); await screen.findByText("Grok (xAI) API key"); diff --git a/panel/src/components/settings/ai-routing-card.tsx b/panel/src/components/settings/ai-routing-card.tsx index 58169db9..c62d1fa9 100644 --- a/panel/src/components/settings/ai-routing-card.tsx +++ b/panel/src/components/settings/ai-routing-card.tsx @@ -131,7 +131,11 @@ export function AIRoutingCard() { const { data: keyStatus } = useOllamaKey(); const { data: snapshot } = useRoutingMode(); const { data: selfHostedModels = [] } = useSelfHostedModels(); - const { data: agentDefs, isLoading: agentsLoading } = useAgentDefinitions(); + const { + data: agentDefs, + isLoading: agentsLoading, + isError: agentsError, + } = useAgentDefinitions(); const agentGroups = useMemo( () => @@ -662,7 +666,13 @@ export function AIRoutingCard() { Leave a row blank to inherit from the global mode. Saving overwrites all per-agent overrides with what's picked here.

- {agentsLoading ? ( + {agentsError ? ( +

+ + Couldn't load the agent roster — per-agent overrides are + unavailable until this reloads. +

+ ) : agentsLoading ? (
{Array.from({ length: 4 }).map((_, i) => (
diff --git a/panel/src/components/tasks/__tests__/acceptance-criteria-editor.test.tsx b/panel/src/components/tasks/__tests__/acceptance-criteria-editor.test.tsx new file mode 100644 index 00000000..18b825ec --- /dev/null +++ b/panel/src/components/tasks/__tests__/acceptance-criteria-editor.test.tsx @@ -0,0 +1,59 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { AcceptanceCriteriaEditor } from "../acceptance-criteria-editor"; + +const sevenCriteria = Array.from({ length: 7 }, (_, i) => `Criterion ${i + 1}`); + +describe("AcceptanceCriteriaEditor — max-7 guard", () => { + it("allows adding while under the cap", async () => { + const onChange = vi.fn(); + render( + , + ); + + await userEvent.type( + screen.getByPlaceholderText(/enter acceptance criterion/i), + "Criterion 2", + ); + await userEvent.click(screen.getByRole("button", { name: /add/i })); + + expect(onChange).toHaveBeenCalledWith(["Criterion 1", "Criterion 2"]); + // `criteria` is controlled by the parent — unchanged in this render since + // the test doesn't re-render with the mutation applied. + expect(screen.getByText("1/7 item")).toBeInTheDocument(); + }); + + it("disables the add control and shows the cap hint at 7 criteria", () => { + const onChange = vi.fn(); + render( + , + ); + + expect(screen.getByText("7/7 items")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /add/i })).toBeDisabled(); + expect( + screen.getByPlaceholderText(/maximum of 7 criteria reached/i), + ).toBeDisabled(); + expect( + screen.getAllByText(/maximum of 7 acceptance criteria reached/i).length, + ).toBeGreaterThan(0); + }); + + it("never calls onChange for an 8th criterion even via Enter", async () => { + const onChange = vi.fn(); + render( + , + ); + + const input = screen.getByPlaceholderText(/maximum of 7 criteria reached/i); + expect(input).toBeDisabled(); + // A disabled input can't be typed into or submitted — confirms the guard + // is enforced at the control, not just the handler. + await userEvent.type(input, "Criterion 8"); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/panel/src/components/tasks/acceptance-criteria-editor.tsx b/panel/src/components/tasks/acceptance-criteria-editor.tsx index b5ed01a2..2c39321c 100644 --- a/panel/src/components/tasks/acceptance-criteria-editor.tsx +++ b/panel/src/components/tasks/acceptance-criteria-editor.tsx @@ -18,16 +18,23 @@ interface AcceptanceCriteriaEditorProps { error?: string; } +// Mirrors the backend cap (acceptance_criteria max_length=7 — see +// roboco/api/schemas/tasks.py's TaskUpdate and the agent-facing v1 flow +// schema). Blocking it here means an 8th criterion never round-trips into a +// swallowed 422 on save. +const MAX_CRITERIA = 7; + export function AcceptanceCriteriaEditor({ criteria, onChange, error, }: AcceptanceCriteriaEditorProps) { const [newCriterion, setNewCriterion] = useState(""); + const atMax = criteria.length >= MAX_CRITERIA; const handleAdd = () => { const trimmed = newCriterion.trim(); - if (trimmed && !criteria.includes(trimmed)) { + if (trimmed && !criteria.includes(trimmed) && !atMax) { onChange([...criteria, trimmed]); setNewCriterion(""); } @@ -59,9 +66,10 @@ export function AcceptanceCriteriaEditor({ Acceptance Criteria * - + - {criteria.length} item{criteria.length !== 1 ? "s" : ""} + {criteria.length}/{MAX_CRITERIA} item + {criteria.length !== 1 ? "s" : ""}
@@ -103,25 +111,42 @@ export function AcceptanceCriteriaEditor({ {/* Add new criterion */}
- + setNewCriterion(e.target.value)} onKeyDown={handleKeyDown} - placeholder="Enter acceptance criterion and press Enter..." + placeholder={ + atMax + ? "Maximum of 7 criteria reached" + : "Enter acceptance criterion and press Enter..." + } + disabled={atMax} className="flex-1" /> - +