From 66d401461c6f56b7c12ecd52ac69648e212a96a2 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 1 Mar 2026 16:22:22 +0000 Subject: [PATCH] feat(web): fleet settings UI (MS22-P1h) (#617) Co-authored-by: Jason Woltje Co-committed-by: Jason Woltje --- .../settings/agent-config/page.tsx | 356 ++++++++++ .../(authenticated)/settings/auth/page.tsx | 492 ++++++++++++++ .../src/app/(authenticated)/settings/page.tsx | 76 +++ .../settings/providers/page.tsx | 631 ++++++++++++++++++ .../components/settings/FleetSettingsNav.tsx | 51 ++ apps/web/src/lib/api/fleet-settings.test.ts | 162 +++++ apps/web/src/lib/api/fleet-settings.ts | 129 ++++ apps/web/src/lib/api/index.ts | 1 + 8 files changed, 1898 insertions(+) create mode 100644 apps/web/src/app/(authenticated)/settings/agent-config/page.tsx create mode 100644 apps/web/src/app/(authenticated)/settings/auth/page.tsx create mode 100644 apps/web/src/app/(authenticated)/settings/providers/page.tsx create mode 100644 apps/web/src/components/settings/FleetSettingsNav.tsx create mode 100644 apps/web/src/lib/api/fleet-settings.test.ts create mode 100644 apps/web/src/lib/api/fleet-settings.ts diff --git a/apps/web/src/app/(authenticated)/settings/agent-config/page.tsx b/apps/web/src/app/(authenticated)/settings/agent-config/page.tsx new file mode 100644 index 0000000..b19d2f1 --- /dev/null +++ b/apps/web/src/app/(authenticated)/settings/agent-config/page.tsx @@ -0,0 +1,356 @@ +"use client"; + +import { + useCallback, + useEffect, + useMemo, + useState, + type ChangeEvent, + type ReactElement, + type SyntheticEvent, +} from "react"; +import { FleetSettingsNav } from "@/components/settings/FleetSettingsNav"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { + fetchFleetAgentConfig, + fetchFleetProviders, + updateFleetAgentConfig, + type FleetProvider, + type FleetProviderModel, + type UpdateFleetAgentConfigRequest, +} from "@/lib/api/fleet-settings"; + +function getErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + + return fallback; +} + +function normalizeProviderModels(models: unknown): FleetProviderModel[] { + if (!Array.isArray(models)) { + return []; + } + + const parsed: FleetProviderModel[] = []; + + models.forEach((entry) => { + if (typeof entry === "string" && entry.trim().length > 0) { + parsed.push({ id: entry.trim(), name: entry.trim() }); + return; + } + + if (entry && typeof entry === "object") { + const record = entry as Record; + const id = + typeof record.id === "string" + ? record.id.trim() + : typeof record.name === "string" + ? record.name.trim() + : ""; + + if (id.length > 0) { + parsed.push({ id, name: id }); + } + } + }); + + const seen = new Set(); + return parsed.filter((model) => { + if (seen.has(model.id)) { + return false; + } + + seen.add(model.id); + return true; + }); +} + +function parseModelList(value: string): string[] { + const seen = new Set(); + + return value + .split(/\n|,/g) + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0) + .filter((segment) => { + if (seen.has(segment)) { + return false; + } + + seen.add(segment); + return true; + }); +} + +function deriveAvailableModels(providers: FleetProvider[]): string[] { + const seen = new Set(); + const models: string[] = []; + + providers.forEach((provider) => { + normalizeProviderModels(provider.models).forEach((model) => { + if (seen.has(model.id)) { + return; + } + + seen.add(model.id); + models.push(model.id); + }); + }); + + return models.sort((left, right) => left.localeCompare(right)); +} + +export default function AgentConfigSettingsPage(): ReactElement { + const [providers, setProviders] = useState([]); + const [primaryModel, setPrimaryModel] = useState(""); + const [fallbackModelsText, setFallbackModelsText] = useState(""); + const [personality, setPersonality] = useState(""); + + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + const availableModels = useMemo(() => deriveAvailableModels(providers), [providers]); + const fallbackModels = useMemo(() => parseModelList(fallbackModelsText), [fallbackModelsText]); + + const modelSelectOptions = useMemo(() => { + if (primaryModel.length > 0 && !availableModels.includes(primaryModel)) { + return [primaryModel, ...availableModels]; + } + + return availableModels; + }, [availableModels, primaryModel]); + + const loadSettings = useCallback(async (): Promise => { + setIsLoading(true); + + try { + const [providerData, agentConfig] = await Promise.all([ + fetchFleetProviders(), + fetchFleetAgentConfig(), + ]); + setProviders(providerData); + setPrimaryModel(agentConfig.primaryModel ?? ""); + setFallbackModelsText(agentConfig.fallbackModels.join("\n")); + setPersonality(agentConfig.personality ?? ""); + setError(null); + } catch (loadError: unknown) { + setError(getErrorMessage(loadError, "Failed to load agent configuration.")); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + void loadSettings(); + }, [loadSettings]); + + function appendFallbackModel(model: string): void { + const current = parseModelList(fallbackModelsText); + if (current.includes(model)) { + return; + } + + const next = [...current, model]; + setFallbackModelsText(next.join("\n")); + } + + async function handleSave(event: SyntheticEvent): Promise { + event.preventDefault(); + setError(null); + setSuccessMessage(null); + + const updatePayload: UpdateFleetAgentConfigRequest = { + personality: personality.trim(), + }; + + if (primaryModel.trim().length > 0) { + updatePayload.primaryModel = primaryModel.trim(); + } + + const parsedFallbacks = parseModelList(fallbackModelsText).filter( + (model) => model !== primaryModel.trim() + ); + if (parsedFallbacks.length > 0) { + updatePayload.fallbackModels = parsedFallbacks; + } + + try { + setIsSaving(true); + await updateFleetAgentConfig(updatePayload); + setSuccessMessage("Agent configuration saved."); + await loadSettings(); + } catch (saveError: unknown) { + setError(getErrorMessage(saveError, "Failed to save agent configuration.")); + } finally { + setIsSaving(false); + } + } + + return ( +
+
+
+

Agent Configuration

+

+ Assign primary and fallback models for your agent runtime behavior. +

+
+ +
+ + + + Current Assignment + + Snapshot of your currently saved model routing configuration. + + + + {isLoading ? ( +

Loading configuration...

+ ) : ( + <> +
+

Primary Model

+

+ {primaryModel.length > 0 ? primaryModel : "No primary model configured"} +

+
+ +
+

Fallback Models

+ {fallbackModels.length === 0 ? ( +

No fallback models configured

+ ) : ( +
+ {fallbackModels.map((model) => ( + + {model} + + ))} +
+ )} +
+ + )} +
+
+ + + + Update Agent Config + + Select a primary model and define fallback ordering. Models come from your provider + settings. + + + +
void handleSave(event)} className="space-y-5"> +
+ + + {availableModels.length === 0 ? ( +

+ No models available yet. Add provider models first in Providers settings. +

+ ) : null} +
+ +
+ +