fix(panel): AI providers layout — org-grouped two-column override list; key cards beside self-hosted

Per-agent mix rows group under Board / Main PM / cell / intake headers
in a two-column grid with compact selects; Grok + Ollama key cards
stack in the left column with the self-hosted section to their right.
Save-mix and inherit semantics untouched.
This commit is contained in:
Renn F
2026-07-15 16:01:06 +02:00
parent 6dee7e66a0
commit 614c22ed70
2 changed files with 530 additions and 230 deletions
@@ -0,0 +1,222 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
const {
catalog,
getOllamaKey,
setOllamaKey,
getGrokKey,
setGrokKey,
getMode,
applyMode,
getSelfHostedConfig,
saveSelfHostedConfig,
testSelfHosted,
getSelfHostedModels,
} = vi.hoisted(() => ({
catalog: vi.fn(async () => [
{
model_name: "claude-opus-4-6",
provider_type: "anthropic",
display_name: "Claude Opus 4.6",
},
{
model_name: "grok-build-0.1",
provider_type: "grok",
display_name: "Grok Build 0.1",
},
]),
getOllamaKey: vi.fn(async () => ({ has_key: false, enabled: true })),
setOllamaKey: vi.fn(async () => ({ has_key: true, enabled: true })),
getGrokKey: vi.fn(async () => ({ has_key: false, enabled: true })),
setGrokKey: vi.fn(async () => ({ has_key: true, enabled: true })),
getMode: vi.fn(async () => ({ mode: "anthropic", assignments: [] })),
applyMode: vi.fn(async (payload: { mode: string }) => ({
mode: payload.mode,
assignments: [],
})),
getSelfHostedConfig: vi.fn(async () => ({
base_url: null,
has_token: false,
enabled: true,
})),
saveSelfHostedConfig: vi.fn(async () => ({
base_url: "http://localhost:11434",
has_token: false,
enabled: true,
})),
testSelfHosted: vi.fn(async () => ({
ok: false,
model_count: null,
error: null,
})),
getSelfHostedModels: vi.fn(async () => []),
}));
vi.mock("@/lib/api/providers", () => ({
providersApi: {
catalog,
getOllamaKey,
setOllamaKey,
getGrokKey,
setGrokKey,
getMode,
applyMode,
getSelfHostedConfig,
saveSelfHostedConfig,
testSelfHosted,
getSelfHostedModels,
},
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
import { toast } from "sonner";
import { AIRoutingCard } from "../ai-routing-card";
function withQueryClient(ui: ReactNode) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
}
describe("AIRoutingCard", () => {
beforeEach(() => {
catalog.mockClear();
getOllamaKey.mockClear();
setOllamaKey.mockClear();
getGrokKey.mockClear();
setGrokKey.mockClear();
getMode.mockClear();
applyMode.mockClear();
getSelfHostedConfig.mockClear();
saveSelfHostedConfig.mockClear();
testSelfHosted.mockClear();
getSelfHostedModels.mockClear();
});
afterEach(() => {
vi.clearAllMocks();
});
it("renders both key cards and the self-hosted section as one two-column band, Grok+Ollama stacked left", async () => {
render(withQueryClient(<AIRoutingCard />));
await screen.findByText("Grok (xAI) API key");
const grokSection = screen.getByText("Grok (xAI) API key").closest("section");
const ollamaSection = screen
.getByText("Ollama Cloud API key")
.closest("section");
expect(grokSection).toBeInTheDocument();
expect(ollamaSection).toBeInTheDocument();
// Grok + Ollama share the same left-column container.
const leftColumn = grokSection?.parentElement;
expect(leftColumn).toContainElement(ollamaSection as HTMLElement);
// That left column and the Self-Hosted section are the two children of
// one responsive two-column grid band.
const selfHostedHeading = screen.getByText("Self-Hosted LLM");
const band = leftColumn?.parentElement;
expect(band).toContainElement(selfHostedHeading);
expect(band?.className).toContain("grid");
expect(band?.className).toContain("lg:grid-cols-2");
});
it("shows 'not set' badges by default and saves+clears the Grok key", async () => {
render(withQueryClient(<AIRoutingCard />));
await screen.findByText("Grok (xAI) API key");
expect(screen.getAllByText("not set")).toHaveLength(2); // Grok + Ollama
const grokInput = screen.getByPlaceholderText("xai-…");
fireEvent.change(grokInput, { target: { value: "xai-secret" } });
const grokSection = screen
.getByText("Grok (xAI) API key")
.closest("section")!;
const saveButton = Array.from(grokSection.querySelectorAll("button")).find(
(b) => b.textContent === "Save",
)!;
fireEvent.click(saveButton);
await waitFor(() => expect(setGrokKey).toHaveBeenCalledWith("xai-secret"));
});
it("saving the Ollama key clears the input on success", async () => {
render(withQueryClient(<AIRoutingCard />));
await screen.findByText("Ollama Cloud API key");
const ollamaInput = screen.getByPlaceholderText(
"ollama_xxx…",
) as HTMLInputElement;
fireEvent.change(ollamaInput, { target: { value: "ollama_secret" } });
const ollamaSection = screen
.getByText("Ollama Cloud API key")
.closest("section")!;
const saveButton = Array.from(
ollamaSection.querySelectorAll("button"),
).find((b) => b.textContent === "Save")!;
fireEvent.click(saveButton);
await waitFor(() =>
expect(setOllamaKey).toHaveBeenCalledWith("ollama_secret"),
);
await waitFor(() => expect(ollamaInput.value).toBe(""));
});
it("groups the per-agent override list by org structure with two-column rows inside each group", async () => {
render(withQueryClient(<AIRoutingCard />));
await screen.findByText("Per-agent override (mix mode)");
for (const title of [
"Board",
"Main PM",
"Backend Cell",
"Frontend Cell",
"UX/UI Cell",
"Intake / Secretary",
]) {
expect(
screen.getByRole("heading", { level: 4, name: title }),
).toBeInTheDocument();
}
// Spot-check a row from each end of the org chart.
expect(screen.getByText("product-owner")).toBeInTheDocument();
expect(screen.getByText("be-dev-1")).toBeInTheDocument();
expect(screen.getByText("secretary-1")).toBeInTheDocument();
// The Backend Cell group renders its 5 rows inside a 2-column grid.
const backendHeader = screen.getByRole("heading", {
level: 4,
name: "Backend Cell",
});
const rowGrid = backendHeader.nextElementSibling as HTMLElement;
expect(rowGrid.className).toContain("sm:grid-cols-2");
expect(rowGrid.querySelectorAll(":scope > div")).toHaveLength(5);
// All 20 agents still render exactly one override select each.
const mixSection = screen
.getByText("Per-agent override (mix mode)")
.closest("section")!;
expect(
mixSection.querySelectorAll('[role="combobox"]'),
).toHaveLength(20);
});
it("saving the mix with no picks shows an error and never calls applyMode", async () => {
render(withQueryClient(<AIRoutingCard />));
await screen.findByText("Per-agent override (mix mode)");
fireEvent.click(screen.getByRole("button", { name: "Save mix" }));
await waitFor(() =>
expect(toast.error).toHaveBeenCalledWith(
"Pick a model for at least one agent",
),
);
expect(applyMode).not.toHaveBeenCalled();
});
});
+107 -29
View File
@@ -51,35 +51,63 @@ import { Checkbox } from "@/components/ui/checkbox";
// Matches the roboco agents_config AGENT_ROLE_MAP / AGENT_TEAM_MAP. // Matches the roboco agents_config AGENT_ROLE_MAP / AGENT_TEAM_MAP.
// Hard-coded so Mix mode shows a stable 18-row picker without an extra // Hard-coded so Mix mode shows a stable 18-row picker without an extra
// server round-trip. Order mirrors the org chart in CLAUDE.md. // server round-trip. Grouped + ordered to mirror the org chart in CLAUDE.md.
// //
// NOTE: CEO is explicitly excluded — it's the human-in-the-loop seat // NOTE: CEO is explicitly excluded — it's the human-in-the-loop seat
// (Renzo), not an LLM-backed agent. Routing it anywhere would be a // (Renzo), not an LLM-backed agent. Routing it anywhere would be a
// no-op in spawn_agent but confusing in the UI. // no-op in spawn_agent but confusing in the UI.
const AGENTS: { slug: string; label: string }[] = [ const AGENT_GROUPS: { title: string; agents: { slug: string; label: string }[] }[] = [
{
title: "Board",
agents: [
{ slug: "product-owner", label: "Product Owner" }, { slug: "product-owner", label: "Product Owner" },
{ slug: "head-marketing", label: "Head of Marketing" }, { slug: "head-marketing", label: "Head of Marketing" },
{ slug: "auditor", label: "Auditor" }, { slug: "auditor", label: "Auditor" },
{ slug: "main-pm", label: "Main PM" }, ],
},
{
title: "Main PM",
agents: [{ slug: "main-pm", label: "Main PM" }],
},
{
title: "Backend Cell",
agents: [
{ slug: "be-pm", label: "Backend PM" }, { slug: "be-pm", label: "Backend PM" },
{ slug: "be-dev-1", label: "Backend Dev 1" }, { slug: "be-dev-1", label: "Backend Dev 1" },
{ slug: "be-dev-2", label: "Backend Dev 2" }, { slug: "be-dev-2", label: "Backend Dev 2" },
{ slug: "be-qa", label: "Backend QA" }, { slug: "be-qa", label: "Backend QA" },
{ slug: "be-doc", label: "Backend Documenter" }, { slug: "be-doc", label: "Backend Documenter" },
],
},
{
title: "Frontend Cell",
agents: [
{ slug: "fe-pm", label: "Frontend PM" }, { slug: "fe-pm", label: "Frontend PM" },
{ slug: "fe-dev-1", label: "Frontend Dev 1" }, { slug: "fe-dev-1", label: "Frontend Dev 1" },
{ slug: "fe-dev-2", label: "Frontend Dev 2" }, { slug: "fe-dev-2", label: "Frontend Dev 2" },
{ slug: "fe-qa", label: "Frontend QA" }, { slug: "fe-qa", label: "Frontend QA" },
{ slug: "fe-doc", label: "Frontend Documenter" }, { slug: "fe-doc", label: "Frontend Documenter" },
],
},
{
title: "UX/UI Cell",
agents: [
{ slug: "ux-pm", label: "UX/UI PM" }, { slug: "ux-pm", label: "UX/UI PM" },
{ slug: "ux-dev-1", label: "UX/UI Dev" }, { slug: "ux-dev-1", label: "UX/UI Dev" },
{ slug: "ux-qa", label: "UX/UI QA" }, { slug: "ux-qa", label: "UX/UI QA" },
{ slug: "ux-doc", label: "UX/UI Documenter" }, { slug: "ux-doc", label: "UX/UI Documenter" },
// Interactive (held-open chat) roles. Claude (SDK driver) and Grok (grok CLI) ],
// are the supported runtimes; assigning a Grok model routes them to },
// the grok-prompter / grok-secretary image. {
title: "Intake / Secretary",
agents: [
// Interactive (held-open chat) roles. Claude (SDK driver) and Grok
// (grok CLI) are the supported runtimes; assigning a Grok model routes
// them to the grok-prompter / grok-secretary image.
{ slug: "intake-1", label: "Intake (Prompter)" }, { slug: "intake-1", label: "Intake (Prompter)" },
{ slug: "secretary-1", label: "Secretary" }, { slug: "secretary-1", label: "Secretary" },
],
},
]; ];
export function AIRoutingCard() { export function AIRoutingCard() {
@@ -319,10 +347,15 @@ export function AIRoutingCard() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{/* -------- Key cards band: Grok+Ollama (left) / Self-Hosted (right) -------- */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="space-y-6">
{/* -------- Grok (xAI) key -------- */} {/* -------- Grok (xAI) key -------- */}
<section className="space-y-2"> <section className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-sm font-medium">Grok (xAI) API key</Label> <Label className="text-sm font-medium">
Grok (xAI) API key
</Label>
{hasGrokKey ? ( {hasGrokKey ? (
<Badge className="bg-emerald-500/10 text-emerald-600 border-0"> <Badge className="bg-emerald-500/10 text-emerald-600 border-0">
<KeyRound className="h-3 w-3" /> key set <KeyRound className="h-3 w-3" /> key set
@@ -361,8 +394,8 @@ export function AIRoutingCard() {
</label> </label>
) : ( ) : (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Used for grok-build-0.1 at api.x.ai/v1. Stored Fernet-encrypted Used for grok-build-0.1 at api.x.ai/v1. Stored
server-side; never returned by the API. Fernet-encrypted server-side; never returned by the API.
</p> </p>
)} )}
</section> </section>
@@ -372,7 +405,9 @@ export function AIRoutingCard() {
{/* -------- Ollama key -------- */} {/* -------- Ollama key -------- */}
<section className="space-y-2"> <section className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-sm font-medium">Ollama Cloud API key</Label> <Label className="text-sm font-medium">
Ollama Cloud API key
</Label>
{hasOllamaKey ? ( {hasOllamaKey ? (
<Badge className="bg-emerald-500/10 text-emerald-600 border-0"> <Badge className="bg-emerald-500/10 text-emerald-600 border-0">
<KeyRound className="h-3 w-3" /> key set <KeyRound className="h-3 w-3" /> key set
@@ -413,12 +448,12 @@ export function AIRoutingCard() {
</label> </label>
) : ( ) : (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Stored Fernet-encrypted server-side; never returned by the API. Stored Fernet-encrypted server-side; never returned by the
API.
</p> </p>
)} )}
</section> </section>
</div>
<Separator />
{/* -------- Self-Hosted LLM -------- */} {/* -------- Self-Hosted LLM -------- */}
<SelfHostedSection <SelfHostedSection
@@ -426,6 +461,7 @@ export function AIRoutingCard() {
onTestResult={handleSelfHostedTestResult} onTestResult={handleSelfHostedTestResult}
onTestSuccess={() => undefined} onTestSuccess={() => undefined}
/> />
</div>
<Separator /> <Separator />
@@ -560,14 +596,24 @@ export function AIRoutingCard() {
all per-agent overrides with what&apos;s picked here. all per-agent overrides with what&apos;s picked here.
</p> </p>
<div className="divide-y rounded-md border"> <div className="divide-y rounded-md border">
{AGENTS.map((a) => ( {AGENT_GROUPS.map((group) => (
<div key={group.title} className="p-3">
<h4 className="mb-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{group.title}
</h4>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{group.agents.map((a) => (
<div <div
key={a.slug} key={a.slug}
className="grid grid-cols-[1fr_280px] items-center gap-2 px-3 py-2" className="grid grid-cols-[1fr_170px] items-center gap-2 rounded-md border px-2 py-1.5"
> >
<div> <div className="min-w-0">
<div className="font-mono text-sm">{a.slug}</div> <div className="truncate font-mono text-xs">
<div className="text-xs text-muted-foreground">{a.label}</div> {a.slug}
</div>
<div className="truncate text-[11px] text-muted-foreground">
{a.label}
</div>
</div> </div>
<Select <Select
value={mixMap[a.slug] ?? ""} value={mixMap[a.slug] ?? ""}
@@ -578,11 +624,13 @@ export function AIRoutingCard() {
})) }))
} }
> >
<SelectTrigger className="w-full"> <SelectTrigger size="sm" className="w-full text-xs">
<SelectValue placeholder="(inherit)" /> <SelectValue placeholder="(inherit)" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="__clear__">(inherit global)</SelectItem> <SelectItem value="__clear__">
(inherit global)
</SelectItem>
{/* Anthropic models */} {/* Anthropic models */}
{catalogAnthropicOnly.length > 0 && ( {catalogAnthropicOnly.length > 0 && (
@@ -592,8 +640,14 @@ export function AIRoutingCard() {
Anthropic Anthropic
</SelectLabel> </SelectLabel>
{catalogAnthropicOnly.map( {catalogAnthropicOnly.map(
(c: { model_name: string; display_name: string }) => ( (c: {
<SelectItem key={c.model_name} value={c.model_name}> model_name: string;
display_name: string;
}) => (
<SelectItem
key={c.model_name}
value={c.model_name}
>
{c.display_name} {c.display_name}
</SelectItem> </SelectItem>
), ),
@@ -609,8 +663,14 @@ export function AIRoutingCard() {
Grok (xAI) Grok (xAI)
</SelectLabel> </SelectLabel>
{catalogGrokOnly.map( {catalogGrokOnly.map(
(c: { model_name: string; display_name: string }) => ( (c: {
<SelectItem key={c.model_name} value={c.model_name}> model_name: string;
display_name: string;
}) => (
<SelectItem
key={c.model_name}
value={c.model_name}
>
{c.display_name} {c.display_name}
</SelectItem> </SelectItem>
), ),
@@ -626,8 +686,14 @@ export function AIRoutingCard() {
Ollama Cloud Ollama Cloud
</SelectLabel> </SelectLabel>
{catalogOllamaOnly.map( {catalogOllamaOnly.map(
(c: { model_name: string; display_name: string }) => ( (c: {
<SelectItem key={c.model_name} value={c.model_name}> model_name: string;
display_name: string;
}) => (
<SelectItem
key={c.model_name}
value={c.model_name}
>
{c.display_name} {c.display_name}
</SelectItem> </SelectItem>
), ),
@@ -643,7 +709,10 @@ export function AIRoutingCard() {
Self-Hosted Self-Hosted
</SelectLabel> </SelectLabel>
{selfHostedModels.map((m: SelfHostedModel) => ( {selfHostedModels.map((m: SelfHostedModel) => (
<SelectItem key={m.model_name} value={m.model_name}> <SelectItem
key={m.model_name}
value={m.model_name}
>
{m.display_name} {m.display_name}
</SelectItem> </SelectItem>
))} ))}
@@ -655,8 +724,14 @@ export function AIRoutingCard() {
catalogOllamaOnly.length === 0 && catalogOllamaOnly.length === 0 &&
selfHostedModels.length === 0 && selfHostedModels.length === 0 &&
catalogForMix.map( catalogForMix.map(
(c: { model_name: string; display_name: string }) => ( (c: {
<SelectItem key={c.model_name} value={c.model_name}> model_name: string;
display_name: string;
}) => (
<SelectItem
key={c.model_name}
value={c.model_name}
>
{c.display_name} {c.model_name} {c.display_name} {c.model_name}
</SelectItem> </SelectItem>
), ),
@@ -666,6 +741,9 @@ export function AIRoutingCard() {
</div> </div>
))} ))}
</div> </div>
</div>
))}
</div>
{catalogOllamaOnly.length === 0 ? ( {catalogOllamaOnly.length === 0 ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Ollama catalog empty check /api/providers/catalog. Ollama catalog empty check /api/providers/catalog.