Files
boocode/apps/web/src/components/ProviderPicker.tsx
indifferentketchup d8ffee1950 v2.1.0-provider-picker: BooCoder systemd migration + provider picker
- BooCoder moves from Docker to host systemd service (boocoder.service)
- Agent dispatch (ACP + PTY) switches from SSH to direct spawn/exec
- SSH helpers marked @deprecated (kept for one release cycle)
- Provider registry (5 providers: boocode, opencode, goose, claude, qwen)
- Agent probe with direct which/exec + model discovery (qwen settings, static claude models)
- GET /api/providers route with installed status, models, transport fallback
- ProviderPicker frontend component in CoderPane header
- External provider messages route through tasks row instead of inference enqueue
- Smart scroll: MessageList only auto-scrolls when near bottom (150px threshold)
- DB: available_agents gets models, label, transport columns
- Bug fix: loadContext SELECT includes allowed_read_paths
- Bug fix: cap hit sentinel inserted before buildMessagesPayload
- docker-compose.yml: boocoder service commented out, BOOCODER_URL env var added
- CLAUDE.md: updated docs for systemd, provider registry, JSONB gotcha, loadContext
2026-05-25 19:20:53 +00:00

179 lines
5.6 KiB
TypeScript

import { useEffect, useState } from 'react';
import { Check, ChevronDown, Cpu } from 'lucide-react';
import { api } from '@/api/client';
import type { Provider } from '@/api/types';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { BottomSheet } from '@/components/BottomSheet';
import { useViewport } from '@/hooks/useViewport';
interface Props {
provider: string;
model: string;
onChange: (provider: string, model: string) => void | Promise<void>;
}
function ProviderModelList({
providers,
error,
currentProvider,
currentModel,
onPick,
}: {
providers: Provider[] | null;
error: string | null;
currentProvider: string;
currentModel: string;
onPick: (provider: string, model: string) => void;
}) {
if (error) {
return <div className="px-2 py-1.5 text-xs text-destructive">{error}</div>;
}
if (providers === null) {
return <div className="px-2 py-1.5 text-xs text-muted-foreground">Loading...</div>;
}
const singleProvider = providers.length === 1;
return (
<>
{providers.map((p) => (
<div key={p.name}>
{!singleProvider && (
<div className="px-2 pt-2 pb-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70">
{p.label}
</div>
)}
{p.models.map((m) => (
<button
key={`${p.name}:${m.id}`}
type="button"
onClick={() => onPick(p.name, m.id)}
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={`size-3 shrink-0 ${
p.name === currentProvider && m.id === currentModel
? 'opacity-100'
: 'opacity-0'
}`}
/>
<span className="truncate">{m.label}</span>
</button>
))}
</div>
))}
</>
);
}
export function ProviderPicker({ provider, model, onChange }: Props) {
const { isMobile } = useViewport();
const [providers, setProviders] = useState<Provider[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
useEffect(() => {
if (!open || providers !== null) return;
api.coder
.providers()
.then(setProviders)
.catch((err) =>
setError(err instanceof Error ? err.message : 'failed to load providers'),
);
}, [open, providers]);
function handlePick(prov: string, mod: string) {
setOpen(false);
void onChange(prov, mod);
}
const currentProviderLabel =
providers?.find((p) => p.name === provider)?.label ?? provider;
const triggerText = providers && providers.length > 1
? `${currentProviderLabel} / ${model}`
: model;
if (isMobile) {
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
aria-label={`Provider: ${currentProviderLabel}, Model: ${model}`}
title={`${currentProviderLabel} / ${model}`}
className="inline-flex items-center justify-center min-h-[44px] min-w-[44px] rounded text-muted-foreground hover:text-foreground"
>
<Cpu className="size-4" />
</button>
<BottomSheet open={open} onClose={() => setOpen(false)} title="Provider / Model">
<div className="px-2 py-2 space-y-1">
<ProviderModelList
providers={providers}
error={error}
currentProvider={provider}
currentModel={model}
onPick={handlePick}
/>
</div>
</BottomSheet>
</>
);
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<button
type="button"
className="text-xs font-mono text-muted-foreground hover:text-foreground flex items-center gap-1 px-1.5 py-0.5 rounded hover:bg-muted/60 max-w-[260px]"
>
<span className="truncate">{triggerText}</span>
<ChevronDown className="size-3 opacity-70 shrink-0" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-80 overflow-y-auto min-w-[200px]">
{error && (
<div className="px-2 py-1.5 text-xs text-destructive">{error}</div>
)}
{providers === null && !error && (
<div className="px-2 py-1.5 text-xs text-muted-foreground">Loading...</div>
)}
{providers && providers.map((p) => {
const singleProvider = providers.length === 1;
return (
<div key={p.name}>
{!singleProvider && (
<div className="px-2 pt-2 pb-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/70 select-none">
{p.label}
</div>
)}
{p.models.map((m) => (
<DropdownMenuItem
key={`${p.name}:${m.id}`}
onSelect={() => handlePick(p.name, m.id)}
className="font-mono text-xs"
>
<Check
className={`size-3 shrink-0 ${
p.name === provider && m.id === model
? 'opacity-100'
: 'opacity-0'
}`}
/>
{m.label}
</DropdownMenuItem>
))}
</div>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}