import { useEffect, useMemo, useRef, useState } from 'react'; import { Check, ChevronDown, RefreshCw, Loader2, Shield, ShieldAlert, Eye, Brain, Bot } from 'lucide-react'; import { api } from '@/api/client'; import type { AgentSessionConfig, ProviderSnapshotEntry, AgentCommand } from '@/api/types'; import { useProviderSnapshot, refreshProviderSnapshot } from '@/hooks/useProviderSnapshot'; import type { AgentStatusEntry } from '@/hooks/useAgentStatus'; import { providerIcon } from '@/components/coder/providerIcons'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { BottomSheet } from '@/components/BottomSheet'; import { useViewport } from '@/hooks/useViewport'; import { formatModelLabel } from '@/lib/model-label'; import { availablePermissionModes, permissionForModeId, nativeModeForPermission, type PermissionMode, } from '@/lib/permission-mode'; import { cn } from '@/lib/utils'; // Permission picker icon — varies with the active mode so the (icon-only) control // is glanceable: Eye = Plan (read-only), Shield = Ask, ShieldAlert = Bypass. function permissionIcon(mode: PermissionMode): React.ReactNode { if (mode === 'plan') return ; if (mode === 'bypass') return ; return ; } 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; /** Grow to fill the row's free space and render the value brighter — used for * the Model picker so the active model is the most visible control. */ flexible?: boolean; } function CompactPicker({ label, value, disabled, options, onPick, icon, iconOnly, flexible }: 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; // #10: normalized status (working|blocked|idle|error) for the active external // agent in this chat, or null for native boocode / before any frame. Renders // a status dot DISTINCT from the WS-liveness `connected` dot. Undefined for // non-coder callers — no dot. agentStatus?: AgentStatusEntry | null; } // #10: normalized external-agent status dot. Mirrors StatusDot's visual // language but on the four normalized buckets (working|blocked|idle|error), // and is DISTINCT from the WS-liveness `connected` dot beside it: // working — emerald spinning ring (subtle motion, like chat streaming) // blocked — amber dot (matches the permission/blocked state colour) // idle — gray dot // error — red dot function AgentStatusDot({ entry, agent }: { entry: AgentStatusEntry; agent: string }) { const title = `${agent}: ${entry.status}` + (entry.reason ? ` — ${entry.reason}` : ''); if (entry.status === 'working') { return ( ); } const bg = entry.status === 'blocked' ? 'bg-amber-500' : entry.status === 'error' ? 'bg-destructive' : 'bg-muted-foreground/40'; return ( ); } export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected, agentStatus }: 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 providerOptions = entries.map((e) => ({ id: e.name, label: e.label })); // Unified permission ladder (Plan / Ask / Bypass) mapped onto this provider's // native modes. `value.modeId` stays the wire field; the active unified mode is // derived from it. const permissionModes = availablePermissionModes(currentEntry?.modes ?? []); const currentPermission = permissionForModeId(value.modeId, currentEntry?.modes ?? []); const modelOptions = (currentEntry?.models ?? []).map((m) => ({ id: m.id, label: formatModelLabel(m.label) })); const thinkingOpts = thinkingOptions.map((t) => ({ id: t.id, label: t.label })); return (
{/* Status indicator lives inside the agent button, to its left: normalized agent status (external agents only) + WS liveness. */} {agentStatus && value.provider !== 'boocode' && ( )} {connected !== undefined && ( )} {currentEntry?.status === 'loading' ? : providerIcon(value.provider)} } /> {/* Permission ladder (Plan / Ask / Bypass) — shown when the provider exposes modes. Picks the unified mode; we resolve it to the provider's native modeId. Icon varies with the active mode (Bypass is amber). */} {permissionModes.length > 0 && ( persist({ ...value, modeId: nativeModeForPermission( perm as PermissionMode, currentEntry?.modes ?? [], currentEntry?.defaultModeId ?? null, ), }) } icon={permissionIcon(currentPermission)} iconOnly /> )} } flexible /> {thinkingOpts.length > 0 && ( persist({ ...value, thinkingOptionId })} icon={} iconOnly /> )} {/* Refresh — pinned right. Status now lives in the agent button (left). */}
); }