11import { PROVIDER_SHORTLIST , type SupportedOnboardingProvider } from '@open-codesign/shared' ;
22import { Button } from '@open-codesign/ui' ;
3- import { useState } from 'react' ;
3+ import { useEffect , useId , useState } from 'react' ;
4+
5+ const OPENROUTER_FREE_MODEL = 'openrouter/free' ;
46
57interface ChooseModelProps {
68 provider : SupportedOnboardingProvider ;
9+ preferFreeTier ?: boolean ;
10+ baseUrl : string | null ;
711 saving : boolean ;
812 errorMessage : string | null ;
913 onConfirm : ( modelPrimary : string , modelFast : string ) => void ;
@@ -12,14 +16,32 @@ interface ChooseModelProps {
1216
1317export function ChooseModel ( {
1418 provider,
19+ preferFreeTier = false ,
20+ baseUrl,
1521 saving,
1622 errorMessage,
1723 onConfirm,
1824 onBack,
1925} : ChooseModelProps ) {
2026 const shortlist = PROVIDER_SHORTLIST [ provider ] ;
21- const [ modelPrimary , setModelPrimary ] = useState ( shortlist . defaultPrimary ) ;
22- const [ modelFast , setModelFast ] = useState ( shortlist . defaultFast ) ;
27+ const useFreeTierDefaults = provider === 'openrouter' && preferFreeTier ;
28+ const primaryOptions = withFreeTierSuggestion ( shortlist . primary , useFreeTierDefaults ) ;
29+ const fastOptions = withFreeTierSuggestion ( shortlist . fast , useFreeTierDefaults ) ;
30+ const [ modelPrimary , setModelPrimary ] = useState (
31+ getDefaultModel ( shortlist . defaultPrimary , useFreeTierDefaults ) ,
32+ ) ;
33+ const [ modelFast , setModelFast ] = useState (
34+ getDefaultModel ( shortlist . defaultFast , useFreeTierDefaults ) ,
35+ ) ;
36+
37+ useEffect ( ( ) => {
38+ setModelPrimary ( getDefaultModel ( shortlist . defaultPrimary , useFreeTierDefaults ) ) ;
39+ setModelFast ( getDefaultModel ( shortlist . defaultFast , useFreeTierDefaults ) ) ;
40+ } , [ shortlist . defaultPrimary , shortlist . defaultFast , useFreeTierDefaults ] ) ;
41+
42+ const trimmedPrimary = modelPrimary . trim ( ) ;
43+ const trimmedFast = modelFast . trim ( ) ;
44+ const canFinish = trimmedPrimary . length > 0 && trimmedFast . length > 0 && ! saving ;
2345
2446 return (
2547 < div className = "flex flex-col gap-5" >
@@ -28,27 +50,44 @@ export function ChooseModel({
2850 Pick default models
2951 </ h2 >
3052 < p className = "text-[14px] text-[var(--color-text-secondary)] leading-[1.55]" >
31- Recommended starters for { shortlist . label } . Switchable per-design later.
53+ Start with a recommendation or enter any provider-specific model ID. You can switch these
54+ per design later.
3255 </ p >
3356 </ div >
3457
3558 < ModelPicker
3659 label = "Primary design model"
37- hint = "Used for full design generation."
60+ hint = {
61+ useFreeTierDefaults
62+ ? 'Free path starts on openrouter/free, but you can enter any OpenRouter model ID.'
63+ : 'Used for full design generation.'
64+ }
3865 value = { modelPrimary }
39- options = { shortlist . primary }
66+ options = { primaryOptions }
4067 onChange = { setModelPrimary }
4168 />
4269 < ModelPicker
4370 label = "Fast completion model"
44- hint = "Used for quick edits and inline tweaks."
71+ hint = {
72+ useFreeTierDefaults
73+ ? 'Keep openrouter/free for lowest cost, or replace it with a faster custom choice.'
74+ : 'Used for quick edits and inline tweaks.'
75+ }
4576 value = { modelFast }
46- options = { shortlist . fast }
77+ options = { fastOptions }
4778 onChange = { setModelFast }
4879 />
4980
81+ { baseUrl !== null ? (
82+ < p className = "text-[12px] text-[var(--color-text-muted)] leading-[1.5]" >
83+ Custom base URL: < span style = { { fontFamily : 'var(--font-mono)' } } > { baseUrl } </ span >
84+ </ p >
85+ ) : null }
86+
5087 < p className = "text-[12px] text-[var(--color-text-muted)] leading-[1.5]" >
51- Estimated cost: ~$0.01–0.05 per design session (varies by provider and prompt length).
88+ { useFreeTierDefaults
89+ ? 'OpenRouter free routing availability can change. If a free route is unavailable, type another model ID here.'
90+ : 'Estimated cost varies by provider, chosen model, and prompt length.' }
5291 </ p >
5392
5493 { errorMessage !== null ? (
@@ -62,10 +101,10 @@ export function ChooseModel({
62101 < Button
63102 type = "button"
64103 variant = "primary"
65- onClick = { ( ) => onConfirm ( modelPrimary , modelFast ) }
66- disabled = { saving }
104+ onClick = { ( ) => onConfirm ( trimmedPrimary , trimmedFast ) }
105+ disabled = { ! canFinish }
67106 >
68- { saving ? 'Saving… ' : 'Finish' }
107+ { saving ? 'Saving... ' : 'Finish' }
69108 </ Button >
70109 </ div >
71110 </ div >
@@ -81,27 +120,68 @@ interface ModelPickerProps {
81120}
82121
83122function ModelPicker ( { label, hint, value, options, onChange } : ModelPickerProps ) {
123+ const inputId = useId ( ) ;
124+ const datalistId = useId ( ) ;
125+
84126 return (
85- < label className = "flex flex-col gap-2" >
86- < span
127+ < div className = "flex flex-col gap-2" >
128+ < label
129+ htmlFor = { inputId }
87130 className = "text-[10px] uppercase tracking-[0.08em] text-[var(--color-text-muted)] font-medium"
88131 style = { { fontFamily : 'var(--font-mono)' } }
89132 >
90133 { label }
91- </ span >
92- < select
134+ </ label >
135+
136+ < input
137+ id = { inputId }
138+ type = "text"
93139 value = { value }
140+ list = { datalistId }
94141 onChange = { ( e ) => onChange ( e . target . value ) }
142+ placeholder = { options [ 0 ] }
143+ spellCheck = { false }
95144 style = { { fontFamily : 'var(--font-mono)' } }
96- className = "w-full h-[40px] px-3 rounded-[var(--radius-md)] bg-[var(--color-surface)] border border-[var(--color-border)] text-[13px] text-[var(--color-text-primary)] focus:outline-none focus:border-[var(--color-accent)] focus:shadow-[0_0_0_3px_var(--color-focus-ring)] transition-[box-shadow,border-color] duration-150 ease-[cubic-bezier(0.16,1,0.3,1)] cursor-pointer"
97- >
145+ className = "w-full h-[40px] px-3 rounded-[var(--radius-md)] bg-[var(--color-surface)] border border-[var(--color-border)] text-[13px] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] focus:outline-none focus:border-[var(--color-accent)] focus:shadow-[0_0_0_3px_var(--color-focus-ring)] transition-[box-shadow,border-color] duration-150 ease-[cubic-bezier(0.16,1,0.3,1)]"
146+ />
147+ < datalist id = { datalistId } >
98148 { options . map ( ( opt ) => (
99- < option key = { opt } value = { opt } >
100- { opt }
101- </ option >
149+ < option key = { opt } value = { opt } />
102150 ) ) }
103- </ select >
151+ </ datalist >
152+
153+ < div className = "flex flex-wrap gap-2" >
154+ { options . map ( ( opt ) => {
155+ const selected = value . trim ( ) === opt ;
156+
157+ return (
158+ < button
159+ key = { opt }
160+ type = "button"
161+ onClick = { ( ) => onChange ( opt ) }
162+ className = { `px-2.5 h-[28px] rounded-full border text-[11px] transition-colors ${
163+ selected
164+ ? 'border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]'
165+ : 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:border-[var(--color-border-strong)] hover:text-[var(--color-text-primary)]'
166+ } `}
167+ style = { { fontFamily : 'var(--font-mono)' } }
168+ >
169+ { opt }
170+ </ button >
171+ ) ;
172+ } ) }
173+ </ div >
174+
104175 < span className = "text-[12px] text-[var(--color-text-muted)] leading-[1.4]" > { hint } </ span >
105- </ label >
176+ </ div >
106177 ) ;
107178}
179+
180+ function getDefaultModel ( defaultModel : string , useFreeTierDefaults : boolean ) : string {
181+ return useFreeTierDefaults ? OPENROUTER_FREE_MODEL : defaultModel ;
182+ }
183+
184+ function withFreeTierSuggestion ( options : string [ ] , useFreeTierDefaults : boolean ) : string [ ] {
185+ if ( ! useFreeTierDefaults ) return options ;
186+ return [ OPENROUTER_FREE_MODEL , ...options . filter ( ( opt ) => opt !== OPENROUTER_FREE_MODEL ) ] ;
187+ }
0 commit comments