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>
374 lines
12 KiB
TypeScript
374 lines
12 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from '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';
|
|
import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { BottomSheet } from '@/components/BottomSheet';
|
|
import { useViewport } from '@/hooks/useViewport';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
const PREFS_KEY = 'boocode.coder.agent-prefs';
|
|
|
|
|
|
type ProviderPrefs = Record<string, {
|
|
model: string;
|
|
modeId: string | null;
|
|
thinkingOptionId: string | null;
|
|
}>;
|
|
|
|
function loadPrefs(): ProviderPrefs {
|
|
try {
|
|
const raw = localStorage.getItem(PREFS_KEY);
|
|
return raw ? (JSON.parse(raw) as ProviderPrefs) : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function savePrefs(prefs: ProviderPrefs): void {
|
|
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs));
|
|
}
|
|
|
|
function defaultsForProvider(entry: ProviderSnapshotEntry): AgentSessionConfig {
|
|
const model =
|
|
entry.models.find((m) => m.isDefault)?.id ??
|
|
entry.models[0]?.id ??
|
|
'';
|
|
const selectedModel = entry.models.find((m) => m.id === model);
|
|
const modeId = entry.defaultModeId ?? entry.modes[0]?.id ?? null;
|
|
const thinkingOptionId =
|
|
selectedModel?.defaultThinkingOptionId ??
|
|
selectedModel?.thinkingOptions?.find((t) => t.isDefault)?.id ??
|
|
selectedModel?.thinkingOptions?.[0]?.id ??
|
|
null;
|
|
|
|
return {
|
|
provider: entry.name,
|
|
model,
|
|
modeId,
|
|
thinkingOptionId,
|
|
};
|
|
}
|
|
|
|
function resolveConfig(
|
|
entry: ProviderSnapshotEntry,
|
|
prefs: ProviderPrefs,
|
|
): AgentSessionConfig {
|
|
const saved = prefs[entry.name];
|
|
const base = defaultsForProvider(entry);
|
|
|
|
const model =
|
|
saved?.model && entry.models.some((m) => m.id === saved.model)
|
|
? saved.model
|
|
: base.model;
|
|
|
|
const selectedModel = entry.models.find((m) => m.id === model);
|
|
const modeId =
|
|
saved?.modeId && entry.modes.some((m) => m.id === saved.modeId)
|
|
? saved.modeId
|
|
: base.modeId;
|
|
|
|
const thinkingOptions = selectedModel?.thinkingOptions ?? [];
|
|
const thinkingOptionId =
|
|
saved?.thinkingOptionId &&
|
|
thinkingOptions.some((t) => t.id === saved.thinkingOptionId)
|
|
? saved.thinkingOptionId
|
|
: base.thinkingOptionId;
|
|
|
|
return { provider: entry.name, model, modeId, thinkingOptionId };
|
|
}
|
|
|
|
interface PickerProps {
|
|
label: string;
|
|
value: string;
|
|
disabled?: boolean;
|
|
options: Array<{ id: string; label: string }>;
|
|
onPick: (id: string) => void;
|
|
icon?: React.ReactNode;
|
|
/** Mobile: render icon + chevron only (no value label) to save row width. */
|
|
iconOnly?: boolean;
|
|
}
|
|
|
|
function CompactPicker({ label, value, disabled, options, onPick, icon, iconOnly }: PickerProps) {
|
|
const { isMobile } = useViewport();
|
|
const [open, setOpen] = useState(false);
|
|
const currentLabel = options.find((o) => o.id === value)?.label ?? (value || label);
|
|
|
|
const list = (
|
|
<div className="py-1">
|
|
{options.map((o) => (
|
|
<button
|
|
key={o.id}
|
|
type="button"
|
|
onClick={() => {
|
|
onPick(o.id);
|
|
setOpen(false);
|
|
}}
|
|
className="w-full text-left flex items-center gap-2 font-mono text-xs px-2 py-1.5 hover:bg-accent rounded"
|
|
>
|
|
<Check className={cn('size-3 shrink-0', o.id === value ? 'opacity-100' : 'opacity-0')} />
|
|
<span className="truncate">{o.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
if (isMobile) {
|
|
return (
|
|
<>
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
onClick={() => setOpen(true)}
|
|
aria-label={`${label}: ${currentLabel}`}
|
|
className="inline-flex items-center gap-1 min-h-[44px] px-1.5 rounded text-xs text-muted-foreground hover:text-foreground disabled:opacity-40"
|
|
>
|
|
{icon}
|
|
{!iconOnly && <span className="truncate max-w-[120px]">{currentLabel}</span>}
|
|
<ChevronDown className="size-3 opacity-70 shrink-0" />
|
|
</button>
|
|
<BottomSheet open={open} onClose={() => setOpen(false)} title={label}>
|
|
<div className="px-2">{list}</div>
|
|
</BottomSheet>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted/60 disabled:opacity-40"
|
|
>
|
|
{icon}
|
|
<span className="truncate max-w-[180px]">{currentLabel}</span>
|
|
<ChevronDown className="size-3 opacity-70 shrink-0" />
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start" className="max-h-64 overflow-y-auto min-w-[160px]">
|
|
{options.map((o) => (
|
|
<DropdownMenuItem key={o.id} onSelect={() => onPick(o.id)} className="text-xs">
|
|
<Check className={cn('size-3 shrink-0', o.id === value ? 'opacity-100' : 'opacity-0')} />
|
|
{o.label}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|
|
|
|
interface Props {
|
|
projectPath?: string;
|
|
value: AgentSessionConfig;
|
|
onChange: (next: AgentSessionConfig) => void;
|
|
onProviderCommandsChange?: (commands: AgentCommand[]) => void;
|
|
connected?: boolean;
|
|
}
|
|
|
|
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.enabled && (e.status === 'ready' || e.status === 'loading')) ?? null,
|
|
[allEntries],
|
|
);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
|
|
const hydratedRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
hydratedRef.current = false;
|
|
}, [projectPath]);
|
|
|
|
useEffect(() => {
|
|
if (!entries?.length || hydratedRef.current) return;
|
|
hydratedRef.current = true;
|
|
const prefs = loadPrefs();
|
|
const entry =
|
|
entries.find((e) => e.name === value.provider) ??
|
|
entries.find((e) => e.name === 'boocode') ??
|
|
entries[0];
|
|
if (!entry) return;
|
|
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],
|
|
);
|
|
|
|
const currentModel = useMemo(
|
|
() => currentEntry?.models.find((m) => m.id === value.model),
|
|
[currentEntry, value.model],
|
|
);
|
|
|
|
const thinkingOptions = currentModel?.thinkingOptions ?? [];
|
|
|
|
useEffect(() => {
|
|
onProviderCommandsChange?.(currentEntry?.commands ?? []);
|
|
}, [currentEntry, onProviderCommandsChange]);
|
|
|
|
function persist(next: AgentSessionConfig): void {
|
|
const prefs = loadPrefs();
|
|
prefs[next.provider] = {
|
|
model: next.model,
|
|
modeId: next.modeId,
|
|
thinkingOptionId: next.thinkingOptionId,
|
|
};
|
|
savePrefs(prefs);
|
|
onChange(next);
|
|
}
|
|
|
|
function pickProvider(name: string): void {
|
|
const entry = entries?.find((e) => e.name === name);
|
|
if (!entry) return;
|
|
persist(resolveConfig(entry, loadPrefs()));
|
|
}
|
|
|
|
function pickModel(model: string): void {
|
|
const entry = currentEntry;
|
|
if (!entry) return;
|
|
const selected = entry.models.find((m) => m.id === model);
|
|
const thinkingOptionId =
|
|
selected?.defaultThinkingOptionId ??
|
|
selected?.thinkingOptions?.find((t) => t.isDefault)?.id ??
|
|
selected?.thinkingOptions?.[0]?.id ??
|
|
null;
|
|
persist({ ...value, model, thinkingOptionId });
|
|
}
|
|
|
|
async function handleRefresh(): Promise<void> {
|
|
setRefreshing(true);
|
|
try {
|
|
await api.coder.refreshProviders();
|
|
await refreshProviderSnapshot(projectPath);
|
|
} finally {
|
|
setRefreshing(false);
|
|
}
|
|
}
|
|
|
|
if (!entries) {
|
|
return (
|
|
<div className="text-xs text-muted-foreground px-2 py-1">Loading agents…</div>
|
|
);
|
|
}
|
|
|
|
const providerIcon = (name: string) => {
|
|
switch (name) {
|
|
case 'claude': return <ClaudeIcon size={13} className="shrink-0" />;
|
|
case 'opencode': return <OpenCodeIcon size={13} className="shrink-0" />;
|
|
case 'goose': return <Bird size={13} className="shrink-0" />;
|
|
case 'qwen': return <TermIcon size={13} className="shrink-0" />;
|
|
default: return <Dog size={13} className="shrink-0" />;
|
|
}
|
|
};
|
|
|
|
const providerOptions = entries.map((e) => ({ id: e.name, label: e.label }));
|
|
const modeOptions = (currentEntry?.modes ?? []).map((m) => ({ id: m.id, label: m.label }));
|
|
const modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: m.label }));
|
|
const thinkingOpts = thinkingOptions.map((t) => ({ id: t.id, label: t.label }));
|
|
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-1 px-2 py-1 border-b border-border bg-muted/20 shrink-0">
|
|
<CompactPicker
|
|
label="Provider"
|
|
value={value.provider}
|
|
options={providerOptions}
|
|
onPick={pickProvider}
|
|
icon={
|
|
currentEntry?.status === 'loading'
|
|
? <Loader2 size={13} className="shrink-0 animate-spin" />
|
|
: providerIcon(value.provider)
|
|
}
|
|
/>
|
|
<CompactPicker
|
|
label="Mode"
|
|
value={value.modeId ?? ''}
|
|
disabled={modeOptions.length === 0}
|
|
options={modeOptions}
|
|
onPick={(modeId) => persist({ ...value, modeId })}
|
|
icon={<Shield className="size-3 shrink-0" />}
|
|
iconOnly
|
|
/>
|
|
<CompactPicker
|
|
label="Model"
|
|
value={value.model}
|
|
disabled={modelOptions.length === 0}
|
|
options={modelOptions}
|
|
onPick={pickModel}
|
|
icon={<Bot size={13} className="shrink-0" />}
|
|
/>
|
|
{thinkingOpts.length > 0 && (
|
|
<CompactPicker
|
|
label="Thinking"
|
|
value={value.thinkingOptionId ?? ''}
|
|
options={thinkingOpts}
|
|
onPick={(thinkingOptionId) => persist({ ...value, thinkingOptionId })}
|
|
icon={<Brain className="size-3 shrink-0" />}
|
|
/>
|
|
)}
|
|
{/* Status dot + refresh as one right-aligned unit so the refresh button
|
|
stays on the top line instead of wrapping past the edge-pinned dot. */}
|
|
<div className="ml-auto flex items-center gap-1 shrink-0">
|
|
{connected !== undefined && (
|
|
<span
|
|
className={cn('inline-block w-1.5 h-1.5 rounded-full shrink-0', connected ? 'bg-green-500' : 'bg-red-500')}
|
|
title={connected ? 'Connected' : 'Disconnected'}
|
|
/>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleRefresh()}
|
|
disabled={refreshing}
|
|
className="inline-flex items-center justify-center size-7 max-md:min-h-[44px] max-md:min-w-[44px] rounded text-muted-foreground hover:text-foreground disabled:opacity-40"
|
|
aria-label="Refresh provider list"
|
|
title="Refresh providers"
|
|
>
|
|
<RefreshCw className={cn('size-3.5', refreshing && 'animate-spin')} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|