11import { useT } from '@open-codesign/i18n' ;
22import { type WireApi , canonicalBaseUrl , detectWireFromBaseUrl } from '@open-codesign/shared' ;
33import { Button } from '@open-codesign/ui' ;
4- import { AlertCircle , CheckCircle , Loader2 , X } from 'lucide-react' ;
5- import { useState } from 'react' ;
4+ import { AlertCircle , Check , CheckCircle , Loader2 , X } from 'lucide-react' ;
5+ import { useRef , useState } from 'react' ;
66
77interface Props {
88 onSave : ( ) => void ;
@@ -48,6 +48,28 @@ type TestState =
4848 | { kind : 'ok' ; modelCount : number }
4949 | { kind : 'error' ; message : string } ;
5050
51+ type DiscoveryState =
52+ | { kind : 'idle' }
53+ | { kind : 'discovering' }
54+ | { kind : 'found' ; models : string [ ] }
55+ | { kind : 'failed' } ;
56+
57+ /** Priority-ordered model selection after a successful discovery. */
58+ function pickBestModel ( models : string [ ] ) : string {
59+ const priorities : RegExp [ ] = [
60+ / ^ c l a u d e - s o n n e t - 4 - 5 / ,
61+ / ^ c l a u d e - o p u s / ,
62+ / ^ c l a u d e - s o n n e t / ,
63+ / ^ g e m i n i - 2 \. 5 - p r o $ | ^ g e m i n i - 3 .* p r o / ,
64+ / ^ g p t - 5 / ,
65+ ] ;
66+ for ( const pattern of priorities ) {
67+ const match = models . find ( ( m ) => pattern . test ( m ) ) ;
68+ if ( match !== undefined ) return match ;
69+ }
70+ return models [ 0 ] ?? '' ;
71+ }
72+
5173/**
5274 * Minimal Custom Provider form — wire-agnostic endpoint onboarding.
5375 * Deliberately barebones (native form + FormData-ish accessors, no schema),
@@ -79,15 +101,74 @@ export function AddCustomProviderModal({
79101 const [ saving , setSaving ] = useState ( false ) ;
80102 const [ error , setError ] = useState < string | null > ( null ) ;
81103
104+ const [ discovery , setDiscovery ] = useState < DiscoveryState > ( { kind : 'idle' } ) ;
105+ // When true, user explicitly chose to type a model name instead of picking from the dropdown.
106+ const [ manualModel , setManualModel ] = useState ( false ) ;
107+ // Track whether user has explicitly typed/picked a model so auto-pick doesn't override it.
108+ const userPickedModel = useRef ( false ) ;
109+
110+ const debounceTimer = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
111+
112+ function scheduleDiscovery ( currentBaseUrl : string , currentApiKey : string , currentWire : WireApi ) {
113+ if ( debounceTimer . current !== null ) clearTimeout ( debounceTimer . current ) ;
114+ if ( ! currentBaseUrl . trim ( ) . match ( / ^ h t t p s ? : \/ \/ / ) ) {
115+ setDiscovery ( { kind : 'idle' } ) ;
116+ return ;
117+ }
118+ debounceTimer . current = setTimeout ( ( ) => {
119+ void runDiscovery ( currentBaseUrl , currentApiKey , currentWire ) ;
120+ } , 500 ) ;
121+ }
122+
123+ async function runDiscovery ( currentBaseUrl : string , currentApiKey : string , currentWire : WireApi ) {
124+ if ( ! window . codesign ?. config ) return ;
125+ setDiscovery ( { kind : 'discovering' } ) ;
126+ try {
127+ const res = await window . codesign . config . testEndpoint ( {
128+ wire : currentWire ,
129+ baseUrl : currentBaseUrl . trim ( ) ,
130+ apiKey : currentApiKey . trim ( ) ,
131+ } ) ;
132+ if ( res . ok && res . models . length > 0 ) {
133+ setDiscovery ( { kind : 'found' , models : res . models } ) ;
134+ if ( ! userPickedModel . current ) {
135+ const best = pickBestModel ( res . models ) ;
136+ setDefaultModel ( best ) ;
137+ }
138+ } else {
139+ setDiscovery ( { kind : 'failed' } ) ;
140+ }
141+ } catch {
142+ setDiscovery ( { kind : 'failed' } ) ;
143+ }
144+ }
145+
82146 function handleBaseUrlChange ( v : string ) {
83147 setBaseUrl ( v ) ;
84148 if ( wireAuto ) setWire ( detectWireFromBaseUrl ( v ) ) ;
85149 setTest ( { kind : 'idle' } ) ;
150+ scheduleDiscovery ( v , apiKey , wireAuto ? detectWireFromBaseUrl ( v ) : wire ) ;
151+ }
152+
153+ function handleApiKeyChange ( v : string ) {
154+ setApiKey ( v ) ;
155+ scheduleDiscovery ( baseUrl , v , wire ) ;
86156 }
87157
88158 function handleWireChange ( v : WireApi ) {
89159 setWire ( v ) ;
90160 setWireAuto ( false ) ;
161+ scheduleDiscovery ( baseUrl , apiKey , v ) ;
162+ }
163+
164+ function handleModelSelect ( v : string ) {
165+ setDefaultModel ( v ) ;
166+ userPickedModel . current = true ;
167+ }
168+
169+ function handleModelTextChange ( v : string ) {
170+ setDefaultModel ( v ) ;
171+ userPickedModel . current = v . length > 0 ;
91172 }
92173
93174 async function handleTest ( ) {
@@ -168,6 +249,10 @@ export function AddCustomProviderModal({
168249 ? t ( 'settings.providers.custom.editTitle' )
169250 : t ( 'settings.providers.custom.title' ) ;
170251
252+ // Show the model dropdown when discovery found models AND user hasn't switched to manual entry.
253+ const showModelDropdown =
254+ ! manualModel && discovery . kind === 'found' && discovery . models . length > 0 ;
255+
171256 return (
172257 < div
173258 role = "dialog"
@@ -243,7 +328,7 @@ export function AddCustomProviderModal({
243328 < Field label = { t ( 'settings.providers.custom.apiKey' ) } >
244329 < TextInput
245330 value = { apiKey }
246- onChange = { setApiKey }
331+ onChange = { handleApiKeyChange }
247332 type = "password"
248333 placeholder = {
249334 isEdit && editTarget ?. keyMask !== undefined && editTarget . keyMask . length > 0
@@ -255,8 +340,68 @@ export function AddCustomProviderModal({
255340 />
256341 </ Field >
257342
258- < Field label = { t ( 'settings.providers.custom.defaultModel' ) } >
259- < TextInput value = { defaultModel } onChange = { setDefaultModel } placeholder = "model-name" />
343+ < Field
344+ label = { t ( 'settings.providers.custom.defaultModel' ) }
345+ inline = {
346+ discovery . kind === 'discovering' ? (
347+ < span className = "inline-flex items-center gap-1 text-[var(--text-xs)] text-[var(--color-text-muted)]" >
348+ < Loader2 className = "w-3 h-3 animate-spin" />
349+ { t ( 'settings.providers.cliProxyApi.discoveringModels' ) }
350+ </ span >
351+ ) : discovery . kind === 'found' ? (
352+ < span className = "inline-flex items-center gap-1 text-[var(--text-xs)] text-[var(--color-success)]" >
353+ < Check className = "w-3 h-3" />
354+ { t ( 'settings.providers.cliProxyApi.discoveredModels' , {
355+ count : discovery . models . length ,
356+ } ) }
357+ </ span >
358+ ) : discovery . kind === 'failed' ? (
359+ < span className = "inline-flex items-center gap-1 text-[var(--text-xs)] text-[var(--color-text-muted)]" >
360+ < AlertCircle className = "w-3 h-3" />
361+ { t ( 'settings.providers.cliProxyApi.discoveryFailed' ) }
362+ </ span >
363+ ) : null
364+ }
365+ >
366+ { showModelDropdown ? (
367+ < div className = "flex items-center gap-2" >
368+ < select
369+ value = { defaultModel }
370+ onChange = { ( e ) => handleModelSelect ( e . target . value ) }
371+ className = "flex-1 h-8 px-3 rounded-[var(--radius-md)] bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--text-sm)] text-[var(--color-text-primary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-focus-ring)]"
372+ >
373+ { discovery . models . map ( ( m ) => (
374+ < option key = { m } value = { m } >
375+ { m }
376+ </ option >
377+ ) ) }
378+ </ select >
379+ < button
380+ type = "button"
381+ onClick = { ( ) => setManualModel ( true ) }
382+ className = "shrink-0 text-[var(--text-xs)] text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] underline"
383+ >
384+ { t ( 'settings.providers.custom.switchToManual' ) }
385+ </ button >
386+ </ div >
387+ ) : (
388+ < div className = "flex items-center gap-2" >
389+ < TextInput
390+ value = { defaultModel }
391+ onChange = { handleModelTextChange }
392+ placeholder = "model-name"
393+ />
394+ { manualModel && discovery . kind === 'found' && discovery . models . length > 0 && (
395+ < button
396+ type = "button"
397+ onClick = { ( ) => setManualModel ( false ) }
398+ className = "shrink-0 text-[var(--text-xs)] text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] underline"
399+ >
400+ { t ( 'settings.providers.custom.switchToDropdown' ) }
401+ </ button >
402+ ) }
403+ </ div >
404+ ) }
260405 </ Field >
261406
262407 < div className = "flex items-center gap-2" >
@@ -304,12 +449,23 @@ export function AddCustomProviderModal({
304449 ) ;
305450}
306451
307- function Field ( { label, children } : { label : string ; children : React . ReactNode } ) {
452+ function Field ( {
453+ label,
454+ inline,
455+ children,
456+ } : {
457+ label : string ;
458+ inline ?: React . ReactNode ;
459+ children : React . ReactNode ;
460+ } ) {
308461 return (
309462 < div >
310- < p className = "block text-[var(--text-xs)] font-medium text-[var(--color-text-secondary)] mb-1.5" >
311- { label }
312- </ p >
463+ < div className = "flex items-center justify-between mb-1.5" >
464+ < p className = "block text-[var(--text-xs)] font-medium text-[var(--color-text-secondary)]" >
465+ { label }
466+ </ p >
467+ { inline !== undefined && < span > { inline } </ span > }
468+ </ div >
313469 { children }
314470 </ div >
315471 ) ;
0 commit comments