import React, { useState, useMemo, useRef, useEffect } from 'react'; import { ChevronRight, ChevronDown, Settings2, Search, Sun, Moon, Square, CheckSquare, UserPlus, ScrollText, SlidersHorizontal, BookOpen, } from 'lucide-react'; /** * Settings menu (gear-icon dropdown in the header). * * Layout, top-to-bottom. Multi-item categories are *collapsible * accordions* that default to closed; single-item categories stay * inline beneath their small uppercase label. * * - Theme (single item: Sun/Moon toggle) * - Model Selection (accordion — merges what used to be the * separate "Models" and "Participants" * categories: Orchestrator model, Summarizer * model, Create Expert Persona, then one * stacked row per active participant) * - Max participants (single item: - / value / + stepper, 3-9) * - Response priority (accordion — Prioritize model choice vs. * conversation speed; under "speed", the * orchestrator races the chosen model * against a fast fallback and aggressively * substitutes failed LLMs) * - Display options (accordion — two toggles) * - View Prompts (accordion — Credential Summary, Prompt * Catalog) * - Advanced (single item: Conversation limits…) * * The Downloads section that previously lived at the bottom of this * panel has been removed; every item it offered is already reachable * from the header DownloadMenu, so duplicating them here just made the * settings menu unnecessarily long. */ export default function DevMenu({ theme, onToggleTheme, allModels, orchestratorModel, onOrchestratorChange, summarizerModel, onSummarizerChange, speedPriority, onSpeedPriorityChange, conversationFormats, conversationStructureId, onConversationStructureChange, decisionMethodId, onDecisionMethodChange, showResponseTime, onShowResponseTimeChange, showChatStats, onShowChatStatsChange, maxParticipants, onMaxParticipantsChange, participants, modelAssignments, onModelAssignmentChange, onOpenExpertModal, onShowCredentials, hasCredentials, onShowPromptCatalog, onShowConversationLimits, conversationLimitsOverridden, }) { const [open, setOpen] = useState(false); const [activeSub, setActiveSub] = useState(null); // null | "orch" | "sum" | const [q, setQ] = useState(''); // Collapsed-by-default accordions. Keys correspond to the section // ids the SectionHeader below toggles. If we ever add a fifth // multi-item category, just add a key here. const [openSections, setOpenSections] = useState({ modelSel: false, conversationFormat: false, responsePriority: false, display: false, transparency: false, }); const wrapRef = useRef(null); const searchRef = useRef(null); useEffect(() => { if (activeSub && searchRef.current) searchRef.current.focus(); }, [activeSub]); useEffect(() => { function handleClickOutside(e) { if (wrapRef.current && !wrapRef.current.contains(e.target)) { setOpen(false); setActiveSub(null); setQ(''); } } document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); const filtered = useMemo(() => { const s = q.trim().toLowerCase(); if (!s) return allModels; return allModels.filter(row => { const hay = `${row.name} ${row.id} ${row.provider || ''}`.toLowerCase(); return hay.includes(s); }); }, [allModels, q]); const nameForModel = (id) => { if (!id) return null; const m = allModels.find(x => x.id === id); return m ? m.name : id; }; const orchName = nameForModel(orchestratorModel) || 'Default (backend)'; const sumName = summarizerModel ? (nameForModel(summarizerModel) || summarizerModel) : 'Same as Orchestrator'; const onPickForSubject = (id, subject) => { if (subject === 'orch') onOrchestratorChange(id); else if (subject === 'sum') onSummarizerChange(id); else if (subject) onModelAssignmentChange(subject, id); }; // Toggle a multi-item accordion. Closing the Model Selection section // while a model sub-panel is open would leave the sub-panel orphaned // (its anchor row is no longer rendered), so we also clear activeSub // in that case. const toggleSection = (id) => { setOpenSections(prev => { const next = { ...prev, [id]: !prev[id] }; if (id === 'modelSel' && !next.modelSel) { setActiveSub(null); setQ(''); } return next; }); }; const modelSelOpen = openSections.modelSel; return (
{open && (
{/* ── Theme (single item) ─────────────────────────────── */}
Theme
{/* ── Model Selection (accordion: merged Models + Participants) ─ */} toggleSection('modelSel')} /> {modelSelOpen && ( <> {(participants || []).map(p => { const assigned = modelAssignments[p.participant_id]; const labelName = assigned ? nameForModel(assigned) : (p.default_model_id ? nameForModel(p.default_model_id) : '(default)'); return ( ); })} )}
{/* ── Max participants (single item) ─────────────────── */}
Max participants ({maxParticipants})
{maxParticipants}
3-9
{/* ── Conversation format (accordion) ───────────────── */} {/* Two mutually-exclusive picker lists. The catalog is served by /api/chat/conversation-formats so adding a new structure or decision-method plugin server-side doesn't need a frontend code change. */} toggleSection('conversationFormat')} /> {openSections.conversationFormat && ( )}
{/* ── Response priority (accordion) ──────────────────── */} {/* Two mutually-exclusive choices. Under "Prioritize conversation speed" the backend also races the chosen model against a fast fallback and aggressively substitutes failed LLMs (see backend/app/services/ resilience.py). Under "Prioritize model choice" the user's selection is preserved and a failed turn just gets noted in chat. */} toggleSection('responsePriority')} /> {openSections.responsePriority && ( <> )}
{/* ── Display options (accordion) ────────────────────── */} toggleSection('display')} /> {openSections.display && ( <> )}
{/* ── View Prompts (accordion) ───────────────────────── */} {/* No right-side chevrons on the rows themselves: these buttons open a modal and don't expand a sub-panel, so a row-level chevron would be misleading. */} toggleSection('transparency')} /> {openSections.transparency && ( <> )}
{/* ── Advanced (single item) ─────────────────────────── */}
Advanced
)} {/* Model picker sub-panel — only meaningful while the Model Selection accordion is open, since that's the only section whose rows set activeSub. */} {open && modelSelOpen && activeSub && (
{activeSub === 'orch' && 'Orchestrator model'} {activeSub === 'sum' && 'Summarizer model'} {activeSub !== 'orch' && activeSub !== 'sum' && ( <>Model for {participants.find(p => p.participant_id === activeSub)?.name || activeSub} )} {activeSub === 'orch' && orchName} {activeSub === 'sum' && sumName} {activeSub !== 'orch' && activeSub !== 'sum' && ( nameForModel(modelAssignments[activeSub]) || '(default)' )}
setQ(e.target.value)} />
    {activeSub === 'sum' && (
  • )} {activeSub === 'orch' && (
  • )} {activeSub !== 'orch' && activeSub !== 'sum' && (
  • )} {filtered.map(m => { const currentId = activeSub === 'orch' ? orchestratorModel : activeSub === 'sum' ? summarizerModel : modelAssignments[activeSub]; return (
  • ); })}
)}
); } /** * Clickable section header for the multi-item categories. Visually * matches the existing uppercase `dev-panel-label` style, but is a * button with a chevron that flips when the section is open. */ function SectionHeader({ label, open, onToggle }) { return ( ); } /** * Two stacked radio-style pickers for the conversation structure and * decision-making method. Driven entirely by the server catalog so * adding a plugin doesn't need a code change here. A null current * selection means "follow the backend's default" — we highlight that * default but the explicit user choice always wins when set. */ function ConversationFormatPicker({ catalog, structureId, onStructureChange, decisionId, onDecisionChange, }) { const structures = Array.isArray(catalog?.structures) ? catalog.structures : []; const decisions = Array.isArray(catalog?.decisions) ? catalog.decisions : []; const defStruct = catalog?.default_structure_id || null; const defDec = catalog?.default_decision_id || null; const effectiveStruct = structureId || defStruct; const effectiveDec = decisionId || defDec; return ( <>
Discussion structure
{structures.length === 0 && (
(catalog unavailable)
)} {structures.map(s => ( ))}
Decision method
{decisions.length === 0 && (
(catalog unavailable)
)} {decisions.map(d => ( ))} ); }