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();
});
});
+308 -230
View File
@@ -51,35 +51,63 @@ import { Checkbox } from "@/components/ui/checkbox";
// 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
// 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
// (Renzo), not an LLM-backed agent. Routing it anywhere would be a
// no-op in spawn_agent but confusing in the UI.
const AGENTS: { slug: string; label: string }[] = [
{ slug: "product-owner", label: "Product Owner" },
{ slug: "head-marketing", label: "Head of Marketing" },
{ slug: "auditor", label: "Auditor" },
{ slug: "main-pm", label: "Main PM" },
{ slug: "be-pm", label: "Backend PM" },
{ slug: "be-dev-1", label: "Backend Dev 1" },
{ slug: "be-dev-2", label: "Backend Dev 2" },
{ slug: "be-qa", label: "Backend QA" },
{ slug: "be-doc", label: "Backend Documenter" },
{ slug: "fe-pm", label: "Frontend PM" },
{ slug: "fe-dev-1", label: "Frontend Dev 1" },
{ slug: "fe-dev-2", label: "Frontend Dev 2" },
{ slug: "fe-qa", label: "Frontend QA" },
{ slug: "fe-doc", label: "Frontend Documenter" },
{ slug: "ux-pm", label: "UX/UI PM" },
{ slug: "ux-dev-1", label: "UX/UI Dev" },
{ slug: "ux-qa", label: "UX/UI QA" },
{ 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.
{ slug: "intake-1", label: "Intake (Prompter)" },
{ slug: "secretary-1", label: "Secretary" },
const AGENT_GROUPS: { title: string; agents: { slug: string; label: string }[] }[] = [
{
title: "Board",
agents: [
{ slug: "product-owner", label: "Product Owner" },
{ slug: "head-marketing", label: "Head of Marketing" },
{ slug: "auditor", label: "Auditor" },
],
},
{
title: "Main PM",
agents: [{ slug: "main-pm", label: "Main PM" }],
},
{
title: "Backend Cell",
agents: [
{ slug: "be-pm", label: "Backend PM" },
{ slug: "be-dev-1", label: "Backend Dev 1" },
{ slug: "be-dev-2", label: "Backend Dev 2" },
{ slug: "be-qa", label: "Backend QA" },
{ slug: "be-doc", label: "Backend Documenter" },
],
},
{
title: "Frontend Cell",
agents: [
{ slug: "fe-pm", label: "Frontend PM" },
{ slug: "fe-dev-1", label: "Frontend Dev 1" },
{ slug: "fe-dev-2", label: "Frontend Dev 2" },
{ slug: "fe-qa", label: "Frontend QA" },
{ slug: "fe-doc", label: "Frontend Documenter" },
],
},
{
title: "UX/UI Cell",
agents: [
{ slug: "ux-pm", label: "UX/UI PM" },
{ slug: "ux-dev-1", label: "UX/UI Dev" },
{ slug: "ux-qa", label: "UX/UI QA" },
{ slug: "ux-doc", label: "UX/UI Documenter" },
],
},
{
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: "secretary-1", label: "Secretary" },
],
},
];
export function AIRoutingCard() {
@@ -319,113 +347,121 @@ export function AIRoutingCard() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* -------- Grok (xAI) key -------- */}
<section className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Grok (xAI) API key</Label>
{hasGrokKey ? (
<Badge className="bg-emerald-500/10 text-emerald-600 border-0">
<KeyRound className="h-3 w-3" /> key set
</Badge>
) : (
<Badge className="bg-amber-500/10 text-amber-600 border-0">
<Key className="h-3 w-3" /> not set
</Badge>
)}
</div>
<div className="flex gap-2">
<Input
type="password"
value={grokKey}
onChange={(e) => setGrokKey(e.target.value)}
placeholder={
hasGrokKey ? "•••••••••••• (leave blank to keep)" : "xai-…"
}
disabled={clearGrokKey}
/>
<Button onClick={saveGrokKey} disabled={setGrokKeyMut.isPending}>
{setGrokKeyMut.isPending ? "Saving…" : "Save"}
</Button>
</div>
{hasGrokKey ? (
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<Checkbox
checked={clearGrokKey}
onCheckedChange={(checked: boolean) => {
const next = checked === true;
setClearGrokKey(next);
if (next) setGrokKey("");
}}
/>
Clear the stored key
</label>
) : (
<p className="text-xs text-muted-foreground">
Used for grok-build-0.1 at api.x.ai/v1. Stored Fernet-encrypted
server-side; never returned by the API.
</p>
)}
</section>
{/* -------- 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 -------- */}
<section className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
Grok (xAI) API key
</Label>
{hasGrokKey ? (
<Badge className="bg-emerald-500/10 text-emerald-600 border-0">
<KeyRound className="h-3 w-3" /> key set
</Badge>
) : (
<Badge className="bg-amber-500/10 text-amber-600 border-0">
<Key className="h-3 w-3" /> not set
</Badge>
)}
</div>
<div className="flex gap-2">
<Input
type="password"
value={grokKey}
onChange={(e) => setGrokKey(e.target.value)}
placeholder={
hasGrokKey ? "•••••••••••• (leave blank to keep)" : "xai-…"
}
disabled={clearGrokKey}
/>
<Button onClick={saveGrokKey} disabled={setGrokKeyMut.isPending}>
{setGrokKeyMut.isPending ? "Saving…" : "Save"}
</Button>
</div>
{hasGrokKey ? (
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<Checkbox
checked={clearGrokKey}
onCheckedChange={(checked: boolean) => {
const next = checked === true;
setClearGrokKey(next);
if (next) setGrokKey("");
}}
/>
Clear the stored key
</label>
) : (
<p className="text-xs text-muted-foreground">
Used for grok-build-0.1 at api.x.ai/v1. Stored
Fernet-encrypted server-side; never returned by the API.
</p>
)}
</section>
<Separator />
<Separator />
{/* -------- Ollama key -------- */}
<section className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Ollama Cloud API key</Label>
{hasOllamaKey ? (
<Badge className="bg-emerald-500/10 text-emerald-600 border-0">
<KeyRound className="h-3 w-3" /> key set
</Badge>
) : (
<Badge className="bg-amber-500/10 text-amber-600 border-0">
<Key className="h-3 w-3" /> not set
</Badge>
)}
{/* -------- Ollama key -------- */}
<section className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">
Ollama Cloud API key
</Label>
{hasOllamaKey ? (
<Badge className="bg-emerald-500/10 text-emerald-600 border-0">
<KeyRound className="h-3 w-3" /> key set
</Badge>
) : (
<Badge className="bg-amber-500/10 text-amber-600 border-0">
<Key className="h-3 w-3" /> not set
</Badge>
)}
</div>
<div className="flex gap-2">
<Input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={
hasOllamaKey
? "•••••••••••• (leave blank to keep)"
: "ollama_xxx…"
}
disabled={clearKey}
/>
<Button onClick={saveKey} disabled={setKey.isPending}>
{setKey.isPending ? "Saving…" : "Save"}
</Button>
</div>
{hasOllamaKey ? (
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<Checkbox
checked={clearKey}
onCheckedChange={(checked: boolean) => {
const next = checked === true;
setClearKey(next);
if (next) setApiKey("");
}}
/>
Clear the stored key
</label>
) : (
<p className="text-xs text-muted-foreground">
Stored Fernet-encrypted server-side; never returned by the
API.
</p>
)}
</section>
</div>
<div className="flex gap-2">
<Input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={
hasOllamaKey
? "•••••••••••• (leave blank to keep)"
: "ollama_xxx…"
}
disabled={clearKey}
/>
<Button onClick={saveKey} disabled={setKey.isPending}>
{setKey.isPending ? "Saving…" : "Save"}
</Button>
</div>
{hasOllamaKey ? (
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<Checkbox
checked={clearKey}
onCheckedChange={(checked: boolean) => {
const next = checked === true;
setClearKey(next);
if (next) setApiKey("");
}}
/>
Clear the stored key
</label>
) : (
<p className="text-xs text-muted-foreground">
Stored Fernet-encrypted server-side; never returned by the API.
</p>
)}
</section>
<Separator />
{/* -------- Self-Hosted LLM -------- */}
<SelfHostedSection
testResult={selfHostedTestResult}
onTestResult={handleSelfHostedTestResult}
onTestSuccess={() => undefined}
/>
{/* -------- Self-Hosted LLM -------- */}
<SelfHostedSection
testResult={selfHostedTestResult}
onTestResult={handleSelfHostedTestResult}
onTestSuccess={() => undefined}
/>
</div>
<Separator />
@@ -560,109 +596,151 @@ export function AIRoutingCard() {
all per-agent overrides with what&apos;s picked here.
</p>
<div className="divide-y rounded-md border">
{AGENTS.map((a) => (
<div
key={a.slug}
className="grid grid-cols-[1fr_280px] items-center gap-2 px-3 py-2"
>
<div>
<div className="font-mono text-sm">{a.slug}</div>
<div className="text-xs text-muted-foreground">{a.label}</div>
{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
key={a.slug}
className="grid grid-cols-[1fr_170px] items-center gap-2 rounded-md border px-2 py-1.5"
>
<div className="min-w-0">
<div className="truncate font-mono text-xs">
{a.slug}
</div>
<div className="truncate text-[11px] text-muted-foreground">
{a.label}
</div>
</div>
<Select
value={mixMap[a.slug] ?? ""}
onValueChange={(v: string) =>
setMixMap((prev) => ({
...prev,
[a.slug]: v === "__clear__" ? "" : v,
}))
}
>
<SelectTrigger size="sm" className="w-full text-xs">
<SelectValue placeholder="(inherit)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__clear__">
(inherit global)
</SelectItem>
{/* Anthropic models */}
{catalogAnthropicOnly.length > 0 && (
<SelectGroup>
<SelectLabel>
<ProviderBadge variant="anthropic" />
Anthropic
</SelectLabel>
{catalogAnthropicOnly.map(
(c: {
model_name: string;
display_name: string;
}) => (
<SelectItem
key={c.model_name}
value={c.model_name}
>
{c.display_name}
</SelectItem>
),
)}
</SelectGroup>
)}
{/* Grok (xAI) models */}
{catalogGrokOnly.length > 0 && (
<SelectGroup>
<SelectLabel>
<ProviderBadge variant="grok" />
Grok (xAI)
</SelectLabel>
{catalogGrokOnly.map(
(c: {
model_name: string;
display_name: string;
}) => (
<SelectItem
key={c.model_name}
value={c.model_name}
>
{c.display_name}
</SelectItem>
),
)}
</SelectGroup>
)}
{/* Ollama Cloud models */}
{catalogOllamaOnly.length > 0 && (
<SelectGroup>
<SelectLabel>
<ProviderBadge variant="ollama" />
Ollama Cloud
</SelectLabel>
{catalogOllamaOnly.map(
(c: {
model_name: string;
display_name: string;
}) => (
<SelectItem
key={c.model_name}
value={c.model_name}
>
{c.display_name}
</SelectItem>
),
)}
</SelectGroup>
)}
{/* Self-Hosted models */}
{selfHostedModels.length > 0 && (
<SelectGroup>
<SelectLabel>
<ProviderBadge variant="self-hosted" />
Self-Hosted
</SelectLabel>
{selfHostedModels.map((m: SelfHostedModel) => (
<SelectItem
key={m.model_name}
value={m.model_name}
>
{m.display_name}
</SelectItem>
))}
</SelectGroup>
)}
{/* Fallback: un-grouped catalog when no grouping is possible */}
{catalogAnthropicOnly.length === 0 &&
catalogOllamaOnly.length === 0 &&
selfHostedModels.length === 0 &&
catalogForMix.map(
(c: {
model_name: string;
display_name: string;
}) => (
<SelectItem
key={c.model_name}
value={c.model_name}
>
{c.display_name} {c.model_name}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
))}
</div>
<Select
value={mixMap[a.slug] ?? ""}
onValueChange={(v: string) =>
setMixMap((prev) => ({
...prev,
[a.slug]: v === "__clear__" ? "" : v,
}))
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="(inherit)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__clear__">(inherit global)</SelectItem>
{/* Anthropic models */}
{catalogAnthropicOnly.length > 0 && (
<SelectGroup>
<SelectLabel>
<ProviderBadge variant="anthropic" />
Anthropic
</SelectLabel>
{catalogAnthropicOnly.map(
(c: { model_name: string; display_name: string }) => (
<SelectItem key={c.model_name} value={c.model_name}>
{c.display_name}
</SelectItem>
),
)}
</SelectGroup>
)}
{/* Grok (xAI) models */}
{catalogGrokOnly.length > 0 && (
<SelectGroup>
<SelectLabel>
<ProviderBadge variant="grok" />
Grok (xAI)
</SelectLabel>
{catalogGrokOnly.map(
(c: { model_name: string; display_name: string }) => (
<SelectItem key={c.model_name} value={c.model_name}>
{c.display_name}
</SelectItem>
),
)}
</SelectGroup>
)}
{/* Ollama Cloud models */}
{catalogOllamaOnly.length > 0 && (
<SelectGroup>
<SelectLabel>
<ProviderBadge variant="ollama" />
Ollama Cloud
</SelectLabel>
{catalogOllamaOnly.map(
(c: { model_name: string; display_name: string }) => (
<SelectItem key={c.model_name} value={c.model_name}>
{c.display_name}
</SelectItem>
),
)}
</SelectGroup>
)}
{/* Self-Hosted models */}
{selfHostedModels.length > 0 && (
<SelectGroup>
<SelectLabel>
<ProviderBadge variant="self-hosted" />
Self-Hosted
</SelectLabel>
{selfHostedModels.map((m: SelfHostedModel) => (
<SelectItem key={m.model_name} value={m.model_name}>
{m.display_name}
</SelectItem>
))}
</SelectGroup>
)}
{/* Fallback: un-grouped catalog when no grouping is possible */}
{catalogAnthropicOnly.length === 0 &&
catalogOllamaOnly.length === 0 &&
selfHostedModels.length === 0 &&
catalogForMix.map(
(c: { model_name: string; display_name: string }) => (
<SelectItem key={c.model_name} value={c.model_name}>
{c.display_name} {c.model_name}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
))}
</div>