import { useEffect, useMemo, useRef, useState } from 'react'; import { Check, ChevronDown, RefreshCw, Loader2, Shield, 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 { providerIcon } from '@/components/coder/providerIcons'; import { useAgentSessions } from '@/hooks/useAgentSessions'; 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; // v2.6 Phase 1-UX §9b: chat id for the resumed/new-session chip. Optional so // BooChat and any other AgentComposerBar caller renders no chip and is // otherwise unaffected. When present + connected + the chat has ≥1 prior // turn, a chip right of the Provider picker reports whether switching to the // current provider resumes an agent session, replays history (boocode), or // starts fresh. sessionId?: string; // True once the chat has at least one prior turn — gates the chip so it stays // hidden on a brand-new chat. Defaults to false (no chip). hasPriorTurn?: boolean; } // Condensed token count: 950 → "950", 12_400 → "12.4K", 3_200_000 → "3.2M". // Sub-1000 stays exact; thousands/millions get one decimal, trailing .0 trimmed. function abbrevTokens(n: number): string { if (!Number.isFinite(n) || n < 1000) return String(Math.max(0, Math.round(n))); if (n < 1_000_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}K`; return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; } // Relative-time formatter for the resumed-chip title (e.g. "3m ago"). function relativeTime(iso: string | null): string { if (!iso) return 'unknown'; const then = new Date(iso).getTime(); if (Number.isNaN(then)) return 'unknown'; const diffMs = Date.now() - then; if (diffMs < 0) return 'just now'; const sec = Math.floor(diffMs / 1000); if (sec < 60) return 'just now'; const min = Math.floor(sec / 60); if (min < 60) return `${min}m ago`; const hr = Math.floor(min / 60); if (hr < 24) return `${hr}h ago`; const day = Math.floor(hr / 24); return `${day}d ago`; } export function AgentComposerBar({ projectPath, value, onChange, onProviderCommandsChange, connected, sessionId, hasPriorTurn }: 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); // v2.6 Phase 1-UX §9b: chat-scoped agent-session rows for the resumed/new // chip. Hook is unconditional (hooks rule); it self-no-ops when sessionId is // undefined or the chat has no prior turn, so BooChat callers cost nothing. const { sessions: agentSessions } = useAgentSessions( sessionId && hasPriorTurn ? sessionId : undefined, ); 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 })); 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 })); // v2.6 Phase 1-UX §9b: resumed / history / new-session chip. Only meaningful // when this is a real chat (sessionId), the WS is connected, and the chat has // ≥1 prior turn — otherwise render nothing so fresh chats and non-coder // callers stay clean. const sessionRow = agentSessions.find((s) => s.agent === value.provider); const sessionChip: { label: string; title: string } | null = sessionId && hasPriorTurn && connected ? value.provider === 'boocode' ? // Native boocode never holds an agent_sessions row — it reconstructs // the conversation from the chat transcript each turn. { label: 'history', title: 'BooCode replays the chat transcript each turn' } : sessionRow?.has_session ? { label: 'resumed', title: `Resuming ${value.provider} · last active ${relativeTime(sessionRow.last_active_at)}`, } : { label: 'new session', title: `${value.provider} starts a fresh session this turn` } : null; // sampling-streamjson-tokens #8: condensed per-(chat,agent) token/cost readout // beside the session chip. Coerce — input/output are BIGINT (string over wire). // Hidden when no session row or all totals are zero (e.g. native boocode, which // holds no agent_sessions row, or a provider that hasn't run yet). const usageReadout = (() => { if (!sessionChip || !sessionRow) return null; const inTok = Number(sessionRow.input_tokens) || 0; const outTok = Number(sessionRow.output_tokens) || 0; const cost = Number(sessionRow.cost) || 0; if (inTok <= 0 && outTok <= 0 && cost <= 0) return null; const parts = [`${abbrevTokens(inTok)} in`, `${abbrevTokens(outTok)} out`]; if (cost > 0) parts.push(`$${cost.toFixed(2)}`); return parts.join(' · '); })(); return (
: providerIcon(value.provider) } /> {sessionChip && ( {sessionChip.label} )} {usageReadout && ( {usageReadout} )} 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 && ( )}
); }