1- import React , { useState , useEffect , useRef } from 'react' ;
1+ import React , { useState , useEffect , useRef , useCallback } from 'react' ;
22import { EditorState } from '@codemirror/state' ;
33import { EditorView , keymap , lineNumbers , highlightActiveLine } from '@codemirror/view' ;
44import { defaultKeymap , history , historyKeymap } from '@codemirror/commands' ;
@@ -909,6 +909,124 @@ function AiTab({
909909 ) ;
910910}
911911
912+ // ─── Model Dropdown Picker (for settings — no routing profiles) ───────────────
913+
914+ function ModelDropdownPicker ( {
915+ settings,
916+ models,
917+ loadModels,
918+ value,
919+ providerId,
920+ onChange,
921+ } : {
922+ settings : AppSettings ;
923+ models : Record < string , string [ ] > ;
924+ loadModels : ( id : string ) => void ;
925+ value : string | undefined ;
926+ providerId : string | undefined ;
927+ onChange : ( pid : string , model : string ) => void ;
928+ } ) {
929+ const [ open , setOpen ] = useState ( false ) ;
930+ const [ search , setSearch ] = useState ( '' ) ;
931+ const ref = useRef < HTMLDivElement > ( null ) ;
932+
933+ useEffect ( ( ) => {
934+ if ( open ) {
935+ settings . providers . forEach ( ( p ) => { if ( ! models [ p . id ] ) loadModels ( p . id ) ; } ) ;
936+ setSearch ( '' ) ;
937+ }
938+ } , [ open , settings . providers , models , loadModels ] ) ;
939+
940+ useEffect ( ( ) => {
941+ if ( ! open ) return ;
942+ const handler = ( e : MouseEvent ) => {
943+ if ( ref . current && ! ref . current . contains ( e . target as Node ) ) setOpen ( false ) ;
944+ } ;
945+ document . addEventListener ( 'mousedown' , handler ) ;
946+ return ( ) => document . removeEventListener ( 'mousedown' , handler ) ;
947+ } , [ open ] ) ;
948+
949+ const providerName = settings . providers . find ( ( p ) => p . id === providerId ) ?. name ;
950+ const label = providerName && value ? `${ providerName } · ${ value } ` : value || 'Select model…' ;
951+ const lowerSearch = search . toLowerCase ( ) ;
952+
953+ const select = useCallback ( ( pid : string , m : string ) => {
954+ onChange ( pid , m ) ;
955+ setOpen ( false ) ;
956+ } , [ onChange ] ) ;
957+
958+ return (
959+ < div ref = { ref } className = "relative" >
960+ < button
961+ type = "button"
962+ onClick = { ( ) => setOpen ( ( o ) => ! o ) }
963+ className = "flex items-center gap-1.5 w-full bg-slate-800 border border-slate-600 text-slate-200 text-xs rounded-lg px-2.5 py-2 outline-none hover:border-blue-500 cursor-pointer transition-colors"
964+ >
965+ < span className = "truncate flex-1 text-left" > { label } </ span >
966+ < svg className = "w-3 h-3 text-slate-500 flex-shrink-0" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
967+ < path strokeLinecap = "round" strokeLinejoin = "round" strokeWidth = { 2 } d = "M19 9l-7 7-7-7" />
968+ </ svg >
969+ </ button >
970+
971+ { open && (
972+ < div className = "absolute left-0 top-full mt-1 w-full min-w-[260px] bg-slate-800 border border-slate-700 rounded-xl shadow-2xl z-50 flex flex-col overflow-hidden" >
973+ < div className = "p-2 border-b border-slate-700" >
974+ < input
975+ autoFocus
976+ value = { search }
977+ onChange = { ( e ) => setSearch ( e . target . value ) }
978+ placeholder = "Search models…"
979+ className = "w-full bg-slate-700 text-slate-200 text-xs rounded-lg px-3 py-1.5 outline-none placeholder-slate-500"
980+ />
981+ </ div >
982+ < div className = "overflow-y-auto max-h-72" >
983+ { value && (
984+ < button
985+ type = "button"
986+ onClick = { ( ) => { onChange ( '' , '' ) ; setOpen ( false ) ; } }
987+ className = "w-full text-left px-3 py-2 text-xs text-slate-400 hover:bg-slate-700 transition-colors"
988+ >
989+ Clear selection
990+ </ button >
991+ ) }
992+ { settings . providers . map ( ( provider ) => {
993+ const all = [
994+ ...( provider . customModels ?? [ ] ) ,
995+ ...( models [ provider . id ] ?? [ ] ) . filter ( ( m ) => ! provider . customModels ?. includes ( m ) ) ,
996+ ] ;
997+ const filtered = all . filter (
998+ ( m ) => ! lowerSearch || m . toLowerCase ( ) . includes ( lowerSearch ) || provider . name . toLowerCase ( ) . includes ( lowerSearch ) ,
999+ ) ;
1000+ if ( filtered . length === 0 ) return null ;
1001+ return (
1002+ < div key = { provider . id } >
1003+ < div className = "px-3 pt-2 pb-1 text-[10px] font-semibold text-slate-500 uppercase tracking-wide" >
1004+ { provider . name }
1005+ </ div >
1006+ { filtered . map ( ( m ) => {
1007+ const isActive = providerId === provider . id && value === m ;
1008+ return (
1009+ < button
1010+ key = { m }
1011+ type = "button"
1012+ onClick = { ( ) => select ( provider . id , m ) }
1013+ className = { `w-full text-left px-3 py-2 text-xs flex items-center gap-2 hover:bg-slate-700 transition-colors ${ isActive ? 'text-blue-400' : 'text-slate-300' } ` }
1014+ >
1015+ < span className = { `w-3 flex-shrink-0 ${ isActive ? 'opacity-100' : 'opacity-0' } ` } > ✓</ span >
1016+ < span className = "truncate" > { m } </ span >
1017+ </ button >
1018+ ) ;
1019+ } ) }
1020+ </ div >
1021+ ) ;
1022+ } ) }
1023+ </ div >
1024+ </ div >
1025+ ) }
1026+ </ div >
1027+ ) ;
1028+ }
1029+
9121030// ─── General Tab ──────────────────────────────────────────────────────────────
9131031
9141032function GeneralTab ( {
@@ -918,24 +1036,25 @@ function GeneralTab({
9181036 settings : AppSettings ;
9191037 onSave : ( p : Partial < AppSettings > ) => Promise < void > ;
9201038} ) {
1039+ const { models, loadModels } = useSettingsStore ( ) ;
1040+
9211041 return (
9221042 < div className = "space-y-6" >
9231043 < SchemaFormRenderer
9241044 contribution = { settingsRegistry . get ( 'openconduit.general' ) }
9251045 settings = { settings }
9261046 onSave = { onSave }
9271047 renderOverrides = { {
928- defaultProviderId : (
929- < select
930- value = { settings . defaultProviderId ?? '' }
931- onChange = { ( e ) => void onSave ( { defaultProviderId : e . target . value || undefined } ) }
932- className = "select-field"
933- >
934- < option value = "" > None</ option >
935- { settings . providers . map ( ( p ) => (
936- < option key = { p . id } value = { p . id } > { p . name } </ option >
937- ) ) }
938- </ select >
1048+ defaultProviderId : false ,
1049+ defaultModel : (
1050+ < ModelDropdownPicker
1051+ settings = { settings }
1052+ models = { models }
1053+ loadModels = { loadModels }
1054+ value = { settings . defaultModel }
1055+ providerId = { settings . defaultProviderId }
1056+ onChange = { ( pid , model ) => void onSave ( { defaultModel : model || undefined , defaultProviderId : pid || undefined } ) }
1057+ />
9391058 ) ,
9401059 } }
9411060 />
@@ -1483,6 +1602,19 @@ function CopilotAuthSection({
14831602 ) ;
14841603}
14851604
1605+ // ─── Simple Icons helpers ─────────────────────────────────────────────────────
1606+
1607+ const SIMPLE_ICONS_CDN = ( slug : string ) => `https://cdn.simpleicons.org/${ slug } ` ;
1608+
1609+ const PROVIDER_ICON_SLUGS : Partial < Record < string , string > > = {
1610+ openai : 'openai' ,
1611+ anthropic : 'anthropic' ,
1612+ gemini : 'googlegemini' ,
1613+ ollama : 'ollama' ,
1614+ bedrock : 'amazonaws' ,
1615+ copilot : 'githubcopilot' ,
1616+ } ;
1617+
14861618function ProviderForm ( {
14871619 provider,
14881620 onSave,
@@ -1493,6 +1625,22 @@ function ProviderForm({
14931625 onCancel : ( ) => void ;
14941626} ) {
14951627 const [ draft , setDraft ] = useState < ProviderConfig > ( { ...provider } ) ;
1628+ const { models, loadModels } = useSettingsStore ( ) ;
1629+ const providerModels = provider . id ? ( models [ provider . id ] ?? [ ] ) : [ ] ;
1630+
1631+ useEffect ( ( ) => {
1632+ if ( provider . id ) loadModels ( provider . id ) ;
1633+ // eslint-disable-next-line react-hooks/exhaustive-deps
1634+ } , [ provider . id ] ) ;
1635+
1636+ // Auto-fill logoUrl from Simple Icons when type is selected and no custom URL is set
1637+ useEffect ( ( ) => {
1638+ const slug = PROVIDER_ICON_SLUGS [ draft . type ] ;
1639+ if ( slug && ! draft . logoUrl ) {
1640+ setDraft ( ( d ) => ( { ...d , logoUrl : SIMPLE_ICONS_CDN ( slug ) } ) ) ;
1641+ }
1642+ // eslint-disable-next-line react-hooks/exhaustive-deps
1643+ } , [ draft . type ] ) ;
14961644
14971645 const set = ( key : keyof ProviderConfig , value : string ) =>
14981646 setDraft ( ( d ) => ( { ...d , [ key ] : value } ) ) ;
@@ -1526,6 +1674,42 @@ function ProviderForm({
15261674 />
15271675 </ Field >
15281676
1677+ < Field label = "Logo" >
1678+ < div className = "space-y-2" >
1679+ < div className = "flex items-center gap-2" >
1680+ { draft . logoUrl && (
1681+ < img src = { draft . logoUrl } alt = "" className = "w-5 h-5 rounded-sm object-contain flex-shrink-0 invert opacity-80" />
1682+ ) }
1683+ < input
1684+ type = "url"
1685+ value = { draft . logoUrl ?? '' }
1686+ onChange = { ( e ) => set ( 'logoUrl' , e . target . value ) }
1687+ placeholder = "https://cdn.simpleicons.org/openai"
1688+ className = "input-field"
1689+ />
1690+ </ div >
1691+ { PROVIDER_ICON_SLUGS [ draft . type ] && (
1692+ < div className = "flex items-center gap-2 flex-wrap" >
1693+ < span className = "text-[11px] text-slate-500" > Simple Icons:</ span >
1694+ { Object . entries ( PROVIDER_ICON_SLUGS )
1695+ . filter ( ( [ , slug ] ) => slug )
1696+ . slice ( 0 , 8 )
1697+ . map ( ( [ , slug ] ) => (
1698+ < button
1699+ key = { slug }
1700+ type = "button"
1701+ title = { slug }
1702+ onClick = { ( ) => set ( 'logoUrl' , SIMPLE_ICONS_CDN ( slug ! ) ) }
1703+ className = { `p-1 rounded border transition-colors ${ draft . logoUrl === SIMPLE_ICONS_CDN ( slug ! ) ? 'border-blue-500 bg-blue-500/10' : 'border-slate-700 hover:border-slate-500' } ` }
1704+ >
1705+ < img src = { SIMPLE_ICONS_CDN ( slug ! ) } alt = { slug } className = "w-4 h-4 object-contain invert opacity-70" />
1706+ </ button >
1707+ ) ) }
1708+ </ div >
1709+ ) }
1710+ </ div >
1711+ </ Field >
1712+
15291713 < Field label = "Type" >
15301714 < select
15311715 value = { draft . type }
@@ -1638,23 +1822,19 @@ function ProviderForm({
16381822 < p className = "text-[11px] font-semibold uppercase tracking-wider text-slate-500" > Models</ p >
16391823
16401824 < Field label = "Default Model" >
1641- < input
1642- type = "text"
1825+ < select
16431826 value = { draft . defaultModel ?? '' }
16441827 onChange = { ( e ) => set ( 'defaultModel' , e . target . value ) }
1645- placeholder = {
1646- draft . type === 'anthropic'
1647- ? 'claude-sonnet-4-5'
1648- : draft . type === 'lmstudio'
1649- ? 'local-model'
1650- : draft . type === 'ollama'
1651- ? 'llama3.2'
1652- : draft . type === 'gemini'
1653- ? 'gemini-2.0-flash'
1654- : 'gpt-4o'
1655- }
1656- className = "input-field"
1657- />
1828+ className = "select-field"
1829+ >
1830+ < option value = "" > None</ option >
1831+ { [
1832+ ...( draft . customModels ?? [ ] ) ,
1833+ ...providerModels . filter ( ( m ) => ! draft . customModels ?. includes ( m ) ) ,
1834+ ] . map ( ( m ) => (
1835+ < option key = { m } value = { m } > { m } </ option >
1836+ ) ) }
1837+ </ select >
16581838 </ Field >
16591839
16601840 < ModelsField
@@ -3249,6 +3429,7 @@ function SchemaFormRenderer({
32493429 < Section key = { section . title } title = { section . title } description = { section . description } >
32503430 { [ ...section . properties ]
32513431 . sort ( ( a , b ) => ( a . order ?? 0 ) - ( b . order ?? 0 ) )
3432+ . filter ( ( property ) => renderOverrides [ property . key ] !== false )
32523433 . map ( ( property ) => (
32533434 < PropertyField
32543435 key = { property . key }
0 commit comments