@@ -5,9 +5,21 @@ import { providerLogo } from './providers.js';
55import uiModule from './ui.js' ;
66import settingsModule from './settings.js' ;
77import { sortModelObjects } from './modelSort.js' ;
8+ import { registerMenuDismiss } from './escMenuStack.js' ;
89
910const API_BASE = window . location . origin ;
1011
12+ // Real close path for the picker (uses .hidden/.closing, not .open).
13+ // Stashed so closeAllPopups / Escape / overflow can dismiss without poking classes.
14+ let _closePickerFn = null ;
15+ let _unregisterPickerEsc = ( ) => { } ;
16+
17+ /** Close the model picker if open. Safe to call when closed or before init. */
18+ export function closeModelPicker ( ) {
19+ if ( typeof _closePickerFn === 'function' ) _closePickerFn ( ) ;
20+ }
21+ if ( typeof window !== 'undefined' ) window . closeModelPicker = closeModelPicker ;
22+
1123// ── Recent + Favorites persistence ──
1224// Recent is auto-tracked (last 5 picks, most-recent-first) and lives in its
1325// own key. Favorites is the SAME key the sidebar Models section uses, so a
@@ -181,28 +193,102 @@ function _initModelPickerDropdown() {
181193 const searchRow = menu ? menu . querySelector ( '.model-picker-search-row' ) : null ;
182194 const refreshBtn = document . getElementById ( 'model-picker-refresh-btn' ) ;
183195 if ( ! wrap || ! btn || ! menu || ! search || ! listEl ) return ;
196+ // Guard against double-wiring when sessions.js is loaded twice under
197+ // different module URLs (bare vs ?v=) — second handler would open then close.
198+ if ( btn . dataset . modelPickerWired === '1' ) return ;
199+ btn . dataset . modelPickerWired = '1' ;
200+
201+ let _closeTimer = null ;
202+ let _ignoreCloseUntil = 0 ;
203+ let _docClickHandler = null ;
204+ let _onCloseAnimEnd = null ;
205+
206+ function _detachDocClick ( ) {
207+ if ( ! _docClickHandler ) return ;
208+ document . removeEventListener ( 'click' , _docClickHandler , true ) ;
209+ _docClickHandler = null ;
210+ }
211+
212+ function _cancelCloseAnim ( ) {
213+ if ( _onCloseAnimEnd ) {
214+ menu . removeEventListener ( 'animationend' , _onCloseAnimEnd ) ;
215+ _onCloseAnimEnd = null ;
216+ }
217+ if ( _closeTimer ) {
218+ clearTimeout ( _closeTimer ) ;
219+ _closeTimer = null ;
220+ }
221+ }
184222
185223 function _close ( ) {
186224 if ( menu . classList . contains ( 'hidden' ) ) return ;
225+ _unregisterPickerEsc ( ) ;
226+ _unregisterPickerEsc = ( ) => { } ;
227+ _detachDocClick ( ) ;
187228 // Restore scroll button
188229 const _scrollBtn = document . getElementById ( 'scroll-bottom-btn' ) ;
189230 if ( _scrollBtn ) _scrollBtn . style . display = '' ;
231+ _cancelCloseAnim ( ) ;
190232 menu . classList . add ( 'closing' ) ;
191- menu . addEventListener ( 'animationend' , function _onDone ( ) {
233+ _onCloseAnimEnd = function _onDone ( ) {
192234 menu . removeEventListener ( 'animationend' , _onDone ) ;
235+ _onCloseAnimEnd = null ;
193236 menu . classList . remove ( 'closing' ) ;
194237 menu . classList . add ( 'hidden' ) ;
195238 search . value = '' ;
196- } , { once : true } ) ;
197- // Fallback if animationend doesn't fire
198- setTimeout ( ( ) => {
239+ } ;
240+ menu . addEventListener ( 'animationend' , _onCloseAnimEnd ) ;
241+ // Fallback if animationend doesn't fire — keep id so _open() can cancel
242+ _closeTimer = setTimeout ( ( ) => {
243+ _closeTimer = null ;
244+ if ( _onCloseAnimEnd ) {
245+ menu . removeEventListener ( 'animationend' , _onCloseAnimEnd ) ;
246+ _onCloseAnimEnd = null ;
247+ }
199248 if ( ! menu . classList . contains ( 'hidden' ) ) {
200249 menu . classList . remove ( 'closing' ) ;
201250 menu . classList . add ( 'hidden' ) ;
202251 search . value = '' ;
203252 }
204253 } , 200 ) ;
205254 }
255+ _closePickerFn = _close ;
256+
257+ function _open ( ) {
258+ // Cancel any in-flight close so a stale animationend/timer cannot re-hide us
259+ _cancelCloseAnim ( ) ;
260+ menu . classList . remove ( 'closing' , 'hidden' ) ;
261+ // Block toggle/outside close briefly (mobile ghost-click + same-turn doc click)
262+ _ignoreCloseUntil = Date . now ( ) + 350 ;
263+ _populate ( '' ) ;
264+ if ( window . modelsModule && window . modelsModule . refreshModels ) {
265+ window . modelsModule . refreshModels ( ) . then ( ( ) => {
266+ if ( ! menu . classList . contains ( 'hidden' ) ) _populate ( search . value || '' ) ;
267+ updateModelPicker ( ) ;
268+ } ) . catch ( ( ) => { } ) ;
269+ }
270+ if ( window . innerWidth >= 768 ) search . focus ( ) ;
271+ const _scrollBtn = document . getElementById ( 'scroll-bottom-btn' ) ;
272+ if ( _scrollBtn ) _scrollBtn . style . display = 'none' ;
273+
274+ // Register with Escape stack BEFORE the textarea guard in ui.js — so Escape
275+ // closes the picker even when focus stays on #message (mobile keyboard up).
276+ _unregisterPickerEsc ( ) ;
277+ _unregisterPickerEsc = registerMenuDismiss ( ( ) => { _close ( ) ; } ) ;
278+
279+ // Defer outside-click (capture) so the opening click cannot immediately dismiss
280+ _detachDocClick ( ) ;
281+ _docClickHandler = ( e ) => {
282+ if ( Date . now ( ) < _ignoreCloseUntil ) return ;
283+ if ( menu . classList . contains ( 'hidden' ) ) return ;
284+ if ( ! wrap . contains ( e . target ) ) _close ( ) ;
285+ } ;
286+ setTimeout ( ( ) => {
287+ if ( _docClickHandler && ! menu . classList . contains ( 'hidden' ) ) {
288+ document . addEventListener ( 'click' , _docClickHandler , true ) ;
289+ }
290+ } , 0 ) ;
291+ }
206292
207293 function _openPickerShortcut ( kind ) {
208294 _close ( ) ;
@@ -650,23 +736,14 @@ function _initModelPickerDropdown() {
650736 if ( match ) await _pick ( match ) ;
651737 } ) ;
652738
739+ // Keep composer focus (mobile keyboard) — same pattern as overflow + button
740+ btn . addEventListener ( 'pointerdown' , ( e ) => { e . preventDefault ( ) ; } ) ;
653741 btn . addEventListener ( 'click' , ( e ) => {
742+ e . preventDefault ( ) ;
654743 e . stopPropagation ( ) ;
655744 if ( menu . classList . contains ( 'hidden' ) || menu . classList . contains ( 'closing' ) ) {
656- // Force-clear any in-progress close animation
657- menu . classList . remove ( 'closing' , 'hidden' ) ;
658- _populate ( '' ) ;
659- if ( window . modelsModule && window . modelsModule . refreshModels ) {
660- window . modelsModule . refreshModels ( ) . then ( ( ) => {
661- if ( ! menu . classList . contains ( 'hidden' ) ) _populate ( search . value || '' ) ;
662- updateModelPicker ( ) ;
663- } ) . catch ( ( ) => { } ) ;
664- }
665- if ( window . innerWidth >= 768 ) search . focus ( ) ;
666- // Hide scroll button so it doesn't overlap
667- const _scrollBtn = document . getElementById ( 'scroll-bottom-btn' ) ;
668- if ( _scrollBtn ) _scrollBtn . style . display = 'none' ;
669- } else {
745+ _open ( ) ;
746+ } else if ( Date . now ( ) >= _ignoreCloseUntil ) {
670747 _close ( ) ;
671748 }
672749 } ) ;
@@ -703,11 +780,6 @@ function _initModelPickerDropdown() {
703780 _openPickerShortcut ( 'models' ) ;
704781 } ) ;
705782 }
706- document . addEventListener ( 'click' , ( e ) => {
707- if ( ! menu . classList . contains ( 'hidden' ) && ! menu . contains ( e . target ) && e . target !== btn ) {
708- _close ( ) ;
709- }
710- } ) ;
711783}
712784
713785/**
0 commit comments