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; 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 = (
{options.map((o) => ( ))}
); if (isMobile) { return ( <> setOpen(false)} title={label}>
{list}
); } return ( {options.map((o) => ( onPick(o.id)} className="text-xs"> {o.label} ))} ); } 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 { setRefreshing(true); try { await api.coder.refreshProviders(); await refreshProviderSnapshot(projectPath); } finally { setRefreshing(false); } } if (!entries) { return (
Loading agents…
); } const providerIcon = (name: string) => { switch (name) { case 'claude': return ; case 'opencode': return ; case 'goose': return ; case 'qwen': return ; default: return ; } }; 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 (
: providerIcon(value.provider) } /> persist({ ...value, modeId })} icon={} iconOnly /> } /> {thinkingOpts.length > 0 && ( persist({ ...value, thinkingOptionId })} icon={} /> )} {/* 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. */}
{connected !== undefined && ( )}
); }