web(coder): provider settings UI — Settings → Providers tab, picker filter, ACP catalog
v2.3 Phase 5. Provider management lives in Settings → Providers: lists every registered provider with a status badge, enable/disable toggle (sends the full override so a custom ACP entry's command survives the wholesale-replace PATCH), per-provider refresh, and a plaintext diagnostic. The composer provider picker now filters to enabled && (status==='ready' || 'loading') — disabled/unavailable providers leave the picker and are managed only in settings; native boocode always shows. Adds a curated ACP catalog + AddProviderModal (PATCH config then subset refresh; the modal caps to the viewport with a single overscroll-contain scroll region). Loading state uses a capped client poll (no WS frame). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
218
apps/web/src/components/coder/ProvidersSettings.tsx
Normal file
218
apps/web/src/components/coder/ProvidersSettings.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Loader2, Plus, RefreshCw, Stethoscope } from 'lucide-react';
|
||||
import { api } from '@/api/client';
|
||||
import type { CoderProvidersFile, ProviderOverride, ProviderSnapshotEntry } from '@/api/types';
|
||||
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AddProviderModal } from './AddProviderModal';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Map a snapshot entry to a status badge (design.md §7.1 labels). */
|
||||
function statusBadge(e: ProviderSnapshotEntry): { label: string; cls: string } {
|
||||
if (e.status === 'loading') return { label: 'Loading', cls: 'bg-muted text-muted-foreground' };
|
||||
if (!e.enabled) return { label: 'Disabled', cls: 'bg-muted text-muted-foreground' };
|
||||
if (e.status === 'ready')
|
||||
return { label: 'Available', cls: 'bg-green-500/15 text-green-600 dark:text-green-400' };
|
||||
if (e.status === 'error')
|
||||
return { label: 'Error', cls: 'bg-red-500/15 text-red-600 dark:text-red-400' };
|
||||
if (!e.installed)
|
||||
return { label: 'Not installed', cls: 'bg-amber-500/15 text-amber-600 dark:text-amber-400' };
|
||||
return { label: 'Unavailable', cls: 'bg-muted text-muted-foreground' };
|
||||
}
|
||||
|
||||
/**
|
||||
* v2.3 — provider management as a Settings tab section (design.md §7.1). Lists
|
||||
* ALL registered providers (including the disabled/unavailable ones the composer
|
||||
* picker hides). Per row: label + model count, status badge, per-id refresh,
|
||||
* diagnostic, and an enable/disable toggle. Native boocode is always-on.
|
||||
*
|
||||
* Uses the home-cwd snapshot (no project arg) — provider management is global,
|
||||
* not per-project (design.md §4.5).
|
||||
*/
|
||||
export function ProvidersSettings() {
|
||||
const allEntries = useProviderSnapshot();
|
||||
const [config, setConfig] = useState<CoderProvidersFile | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [diagId, setDiagId] = useState<string | null>(null);
|
||||
const [diagText, setDiagText] = useState<string | null>(null);
|
||||
|
||||
// The raw config is needed to preserve a provider's FULL override when
|
||||
// toggling: the PATCH replaces an id's override wholesale, so a bare
|
||||
// { enabled } would wipe a custom ACP provider's command/label.
|
||||
useEffect(() => {
|
||||
api.coder
|
||||
.getProvidersConfig()
|
||||
.then(setConfig)
|
||||
.catch(() => setConfig({ providers: {} }));
|
||||
}, []);
|
||||
|
||||
// While any entry is loading, refetch until terminal (capped, no WS frame).
|
||||
const pollsRef = useRef(0);
|
||||
useEffect(() => {
|
||||
const anyLoading = allEntries?.some((e) => e.status === 'loading') ?? false;
|
||||
if (!anyLoading) {
|
||||
pollsRef.current = 0;
|
||||
return;
|
||||
}
|
||||
if (pollsRef.current >= 10) return;
|
||||
const t = setTimeout(() => {
|
||||
pollsRef.current += 1;
|
||||
void refreshProviderSnapshot();
|
||||
}, 2000);
|
||||
return () => clearTimeout(t);
|
||||
}, [allEntries]);
|
||||
|
||||
async function toggle(e: ProviderSnapshotEntry): Promise<void> {
|
||||
setBusyId(e.name);
|
||||
setError(null);
|
||||
try {
|
||||
const existing: ProviderOverride = config?.providers[e.name] ?? {};
|
||||
const resp = await api.coder.patchProvidersConfig({
|
||||
providers: { [e.name]: { ...existing, enabled: !e.enabled } },
|
||||
});
|
||||
setConfig({ providers: resp.providers });
|
||||
await refreshProviderSnapshot();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'failed to update provider');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshOne(id: string): Promise<void> {
|
||||
setBusyId(id);
|
||||
setError(null);
|
||||
try {
|
||||
await api.coder.refreshProviders([id]);
|
||||
await refreshProviderSnapshot();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'failed to refresh');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function openDiagnostic(id: string): Promise<void> {
|
||||
if (diagId === id) {
|
||||
setDiagId(null);
|
||||
setDiagText(null);
|
||||
return;
|
||||
}
|
||||
setDiagId(id);
|
||||
setDiagText('Loading…');
|
||||
try {
|
||||
const { diagnostic } = await api.coder.getProviderDiagnostic(id);
|
||||
setDiagText(diagnostic);
|
||||
} catch (err) {
|
||||
setDiagText(err instanceof Error ? err.message : 'failed to load diagnostic');
|
||||
}
|
||||
}
|
||||
|
||||
const entries = allEntries ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enable, disable, refresh, or add coding agents. Disabled and unavailable providers are
|
||||
hidden from the composer picker but managed here.
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={() => setAddOpen(true)} className="shrink-0">
|
||||
<Plus className="size-3.5" /> Add provider
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border divide-y">
|
||||
{allEntries === null && (
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">Loading…</div>
|
||||
)}
|
||||
{entries.map((e) => {
|
||||
const badge = statusBadge(e);
|
||||
const isNative = e.transport === 'native';
|
||||
const busy = busyId === e.name;
|
||||
return (
|
||||
<div key={e.name} className="px-3 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium truncate">{e.label}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{e.models.length} model{e.models.length === 1 ? '' : 's'}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded px-1.5 py-0.5 text-[11px] font-medium',
|
||||
badge.cls,
|
||||
)}
|
||||
>
|
||||
{e.status === 'loading' && <Loader2 className="size-3 mr-1 animate-spin" />}
|
||||
{badge.label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshOne(e.name)}
|
||||
disabled={busy}
|
||||
className="inline-flex items-center justify-center size-7 rounded text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||
aria-label={`Refresh ${e.label}`}
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openDiagnostic(e.name)}
|
||||
className="inline-flex items-center justify-center size-7 rounded text-muted-foreground hover:text-foreground"
|
||||
aria-label={`Diagnostic for ${e.label}`}
|
||||
title="Diagnostic"
|
||||
>
|
||||
<Stethoscope className="size-3.5" />
|
||||
</button>
|
||||
{isNative ? (
|
||||
<span className="text-[11px] text-muted-foreground w-14 text-center">
|
||||
Always on
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={e.enabled}
|
||||
disabled={busy}
|
||||
onClick={() => void toggle(e)}
|
||||
className={cn(
|
||||
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors disabled:opacity-40',
|
||||
e.enabled ? 'bg-primary' : 'bg-muted-foreground/30',
|
||||
)}
|
||||
aria-label={`${e.enabled ? 'Disable' : 'Enable'} ${e.label}`}
|
||||
title={e.enabled ? 'Enabled — click to disable' : 'Disabled — click to enable'}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-block size-4 rounded-full bg-background transition-transform',
|
||||
e.enabled ? 'translate-x-4' : 'translate-x-0.5',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{diagId === e.name && (
|
||||
<pre className="mt-2 max-h-48 overflow-auto rounded bg-muted/50 p-2 text-[11px] font-mono whitespace-pre-wrap">
|
||||
{diagText}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error && <div className="text-sm text-destructive">{error}</div>}
|
||||
|
||||
<AddProviderModal
|
||||
open={addOpen}
|
||||
onOpenChange={setAddOpen}
|
||||
onAdded={() => void refreshProviderSnapshot()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user