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:
2026-05-29 20:20:18 +00:00
parent e83d9b7d5b
commit 920f8b75a6
5 changed files with 484 additions and 5 deletions

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, RefreshCw, Shield, Brain, Bird, Bot, Dog, Terminal as TermIcon } from 'lucide-react';
import { Check, ChevronDown, RefreshCw, Loader2, Shield, Brain, Bird, Bot, Dog, Terminal as TermIcon } from 'lucide-react';
import { ClaudeIcon, OpenCodeIcon } from '@/components/icons/ProviderIcons';
import { api } from '@/api/client';
import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types';
@@ -176,8 +176,12 @@ interface Props {
export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected }: Props) {
const allEntries = useProviderSnapshot(projectPath);
// 5.5 — the composer picker only offers ENABLED providers that are ready (or
// still loading). Disabled (enabled:false) and unavailable/error providers are
// hidden here and managed in Settings → Providers. Native boocode is always
// enabled+ready, so it always appears.
const entries = useMemo(
() => allEntries?.filter((e) => e.installed && e.status !== 'error') ?? null,
() => allEntries?.filter((e) => e.enabled && (e.status === 'ready' || e.status === 'loading')) ?? null,
[allEntries],
);
const [refreshing, setRefreshing] = useState(false);
@@ -200,6 +204,35 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
onChange(resolveConfig(entry, prefs));
}, [entries, onChange, value.provider]);
// If the active provider is disabled in the settings drawer it drops out of
// `entries` (the 5.5 filter) — fall back to boocode so the composer never
// strands on an unselectable provider with empty model/mode pickers.
useEffect(() => {
if (!entries?.length) return;
if (entries.some((e) => e.name === value.provider)) return;
const fallback = entries.find((e) => e.name === 'boocode') ?? entries[0];
if (!fallback) return;
onChange(resolveConfig(fallback, loadPrefs()));
}, [entries, value.provider, onChange]);
// 5.6 — loading poll: while any entry is loading (Phase 2's sync cache-miss
// return), refetch until terminal. Capped; no provider_snapshot_updated WS
// frame (deferred Tier-2). Dormant today since the snapshot awaits the build.
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(projectPath);
}, 2000);
return () => clearTimeout(t);
}, [allEntries, projectPath]);
const currentEntry = useMemo(
() => entries?.find((e) => e.name === value.provider),
[entries, value.provider],
@@ -283,7 +316,11 @@ export function AgentComposerBar({ projectPath, value, onChange, onProviderComma
value={value.provider}
options={providerOptions}
onPick={pickProvider}
icon={providerIcon(value.provider)}
icon={
currentEntry?.status === 'loading'
? <Loader2 size={13} className="shrink-0 animate-spin" />
: providerIcon(value.provider)
}
/>
<CompactPicker
label="Mode"