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>
219 lines
8.6 KiB
TypeScript
219 lines
8.6 KiB
TypeScript
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>
|
|
);
|
|
}
|