NeonClary commited on
Commit
d57958f
Β·
1 Parent(s): 8297932

Replace orchestrator modal with sub-menu to fix backdrop-filter containment bug

Browse files

The modal used position:fixed inside a header with backdrop-filter:blur(),
which creates a new containing block per CSS spec, constraining the modal
to the 61px header instead of the viewport. Replaced with a sub-menu panel
using position:absolute off the Developer dropdown.

Made-with: Cursor

frontend/src/components/DevMenu.js CHANGED
@@ -1,6 +1,5 @@
1
- import React, { useState } from 'react';
2
- import { ChevronDown, Download, Settings2 } from 'lucide-react';
3
- import OrchestratorPicker from './OrchestratorPicker';
4
 
5
  export default function DevMenu({
6
  allModels,
@@ -16,9 +15,43 @@ export default function DevMenu({
16
  }) {
17
  const [open, setOpen] = useState(false);
18
  const [orchOpen, setOrchOpen] = useState(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  return (
21
- <div className="dev-wrap">
22
  <div className="dev-download-btns">
23
  <button className="btn-sm btn-outline" disabled={!hasChat} onClick={onDownloadChatTxt}>
24
  <Download size={14} /> .txt
@@ -29,13 +62,13 @@ export default function DevMenu({
29
  </div>
30
 
31
  <div className="dev-dropdown-header">
32
- <button className="btn-sm btn-ghost" onClick={() => setOpen(o => !o)}>
33
  <Settings2 size={14} /> Developer <ChevronDown size={12} />
34
  </button>
35
  {open && (
36
  <div className="dev-panel">
37
- <button onClick={() => { setOrchOpen(true); setOpen(false); }}>
38
- Orchestrator model…
39
  </button>
40
  <button
41
  disabled={personaMode === 'structured'}
@@ -54,15 +87,48 @@ export default function DevMenu({
54
  </button>
55
  </div>
56
  )}
57
- </div>
58
 
59
- <OrchestratorPicker
60
- open={orchOpen}
61
- onClose={() => setOrchOpen(false)}
62
- models={allModels}
63
- currentModel={orchestratorModel}
64
- onSelect={(id) => { onOrchestratorChange(id); setOrchOpen(false); }}
65
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  </div>
67
  );
68
  }
 
1
+ import React, { useState, useMemo, useRef, useEffect } from 'react';
2
+ import { ChevronDown, ChevronRight, Download, Settings2, Search } from 'lucide-react';
 
3
 
4
  export default function DevMenu({
5
  allModels,
 
15
  }) {
16
  const [open, setOpen] = useState(false);
17
  const [orchOpen, setOrchOpen] = useState(false);
18
+ const [q, setQ] = useState('');
19
+ const wrapRef = useRef(null);
20
+ const searchRef = useRef(null);
21
+
22
+ useEffect(() => {
23
+ if (orchOpen && searchRef.current) searchRef.current.focus();
24
+ }, [orchOpen]);
25
+
26
+ useEffect(() => {
27
+ function handleClickOutside(e) {
28
+ if (wrapRef.current && !wrapRef.current.contains(e.target)) {
29
+ setOpen(false);
30
+ setOrchOpen(false);
31
+ setQ('');
32
+ }
33
+ }
34
+ document.addEventListener('mousedown', handleClickOutside);
35
+ return () => document.removeEventListener('mousedown', handleClickOutside);
36
+ }, []);
37
+
38
+ const filtered = useMemo(() => {
39
+ const s = q.trim().toLowerCase();
40
+ if (!s) return allModels;
41
+ return allModels.filter(row => {
42
+ const hay = `${row.name} ${row.id} ${row.provider || ''}`.toLowerCase();
43
+ return hay.includes(s);
44
+ });
45
+ }, [allModels, q]);
46
+
47
+ const currentName = useMemo(() => {
48
+ if (!orchestratorModel) return 'Default (backend)';
49
+ const m = allModels.find(m => m.id === orchestratorModel);
50
+ return m ? m.name : orchestratorModel;
51
+ }, [orchestratorModel, allModels]);
52
 
53
  return (
54
+ <div className="dev-wrap" ref={wrapRef}>
55
  <div className="dev-download-btns">
56
  <button className="btn-sm btn-outline" disabled={!hasChat} onClick={onDownloadChatTxt}>
57
  <Download size={14} /> .txt
 
62
  </div>
63
 
64
  <div className="dev-dropdown-header">
65
+ <button className="btn-sm btn-ghost" onClick={() => { setOpen(o => !o); setOrchOpen(false); setQ(''); }}>
66
  <Settings2 size={14} /> Developer <ChevronDown size={12} />
67
  </button>
68
  {open && (
69
  <div className="dev-panel">
70
+ <button onClick={() => { setOrchOpen(o => !o); setQ(''); }}>
71
+ Orchestrator model… <ChevronRight size={12} style={{ marginLeft: 'auto', opacity: 0.5 }} />
72
  </button>
73
  <button
74
  disabled={personaMode === 'structured'}
 
87
  </button>
88
  </div>
89
  )}
 
90
 
91
+ {open && orchOpen && (
92
+ <div className="dev-sub-panel">
93
+ <div className="dev-sub-header">
94
+ <span className="dev-sub-title">Orchestrator</span>
95
+ <span className="dev-sub-current">{currentName}</span>
96
+ </div>
97
+ <div className="dev-sub-search">
98
+ <Search size={14} className="dev-sub-search-icon" />
99
+ <input
100
+ ref={searchRef}
101
+ type="search"
102
+ placeholder="Search models…"
103
+ value={q}
104
+ onChange={e => setQ(e.target.value)}
105
+ />
106
+ </div>
107
+ <ul className="dev-sub-list">
108
+ <li>
109
+ <button
110
+ className={`dev-sub-item ${!orchestratorModel ? 'dev-sub-item-active' : ''}`}
111
+ onClick={() => { onOrchestratorChange(null); setOrchOpen(false); setOpen(false); setQ(''); }}
112
+ >
113
+ <strong>Default (backend)</strong>
114
+ <span className="dev-sub-provider">Use server default</span>
115
+ </button>
116
+ </li>
117
+ {filtered.map(m => (
118
+ <li key={m.id}>
119
+ <button
120
+ className={`dev-sub-item ${orchestratorModel === m.id ? 'dev-sub-item-active' : ''}`}
121
+ onClick={() => { onOrchestratorChange(m.id); setOrchOpen(false); setOpen(false); setQ(''); }}
122
+ >
123
+ <strong>{m.name}</strong>
124
+ <span className="dev-sub-provider">{m.provider}</span>
125
+ </button>
126
+ </li>
127
+ ))}
128
+ </ul>
129
+ </div>
130
+ )}
131
+ </div>
132
  </div>
133
  );
134
  }
frontend/src/components/OrchestratorPicker.js DELETED
@@ -1,71 +0,0 @@
1
- import React, { useState, useMemo } from 'react';
2
- import { Search, X } from 'lucide-react';
3
-
4
- export default function OrchestratorPicker({ open, onClose, models, currentModel, onSelect }) {
5
- const [q, setQ] = useState('');
6
-
7
- const filtered = useMemo(() => {
8
- const s = q.trim().toLowerCase();
9
- if (!s) return models;
10
- return models.filter(row => {
11
- const hay = `${row.name} ${row.id} ${row.provider || ''}`.toLowerCase();
12
- return hay.includes(s);
13
- });
14
- }, [models, q]);
15
-
16
- if (!open) return null;
17
-
18
- return (
19
- <div className="modal-backdrop" role="dialog" aria-modal="true" aria-label="Orchestrator model" onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
20
- <div className="modal-panel orchestrator-picker">
21
- <div className="modal-header">
22
- <h3>Orchestrator model</h3>
23
- <button className="modal-close-btn" onClick={onClose} aria-label="Close">
24
- <X size={18} />
25
- </button>
26
- </div>
27
- <p className="modal-hint">
28
- Choose any model from the app registry to use as the conversation orchestrator.
29
- </p>
30
-
31
- <div className="picker-search">
32
- <Search size={16} className="picker-search-icon" />
33
- <input
34
- type="search"
35
- placeholder="Search by any part of name or id…"
36
- value={q}
37
- onChange={e => setQ(e.target.value)}
38
- autoFocus
39
- />
40
- </div>
41
-
42
- <ul className="picker-list">
43
- <li>
44
- <button
45
- className={`picker-item ${!currentModel ? 'picker-item-active' : ''}`}
46
- onClick={() => onSelect(null)}
47
- >
48
- <span className="picker-item-info">
49
- <strong className="picker-item-name">Default (backend)</strong>
50
- <span className="picker-item-provider">Use server default orchestrator</span>
51
- </span>
52
- </button>
53
- </li>
54
- {filtered.map(m => (
55
- <li key={m.id}>
56
- <button
57
- className={`picker-item ${currentModel === m.id ? 'picker-item-active' : ''}`}
58
- onClick={() => onSelect(m.id)}
59
- >
60
- <span className="picker-item-info">
61
- <strong className="picker-item-name">{m.name}</strong>
62
- <span className="picker-item-provider">{m.provider}</span>
63
- </span>
64
- </button>
65
- </li>
66
- ))}
67
- </ul>
68
- </div>
69
- </div>
70
- );
71
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/styles/components.css CHANGED
@@ -648,77 +648,56 @@
648
  }
649
 
650
 
651
- /* ── Modal / Picker ────────────────────────────────────────────── */
652
 
653
- .modal-backdrop {
654
- position: fixed;
655
- inset: 0;
656
- background: rgba(0, 0, 0, 0.55);
657
- display: flex;
658
- align-items: center;
659
- justify-content: center;
660
- z-index: 200;
661
- padding: 20px;
662
- }
663
-
664
- .modal-panel {
665
- width: 100%;
666
- max-width: 480px;
667
- max-height: 80vh;
668
- overflow: hidden;
669
  display: flex;
670
  flex-direction: column;
671
  background: var(--card-bg);
672
  border: 1px solid var(--border-primary);
673
- border-radius: 14px;
674
- padding: 16px;
675
- box-shadow: var(--shadow-lg);
 
676
  }
677
 
678
- .modal-header {
679
  display: flex;
680
- align-items: center;
681
- justify-content: space-between;
682
- margin-bottom: 8px;
 
 
683
  }
684
 
685
- .modal-header h3 {
686
- font-size: 16px;
687
  font-weight: 600;
688
  color: var(--text-primary);
689
- margin: 0;
690
- }
691
-
692
- .modal-close-btn {
693
- border: none;
694
- background: transparent;
695
- color: var(--text-secondary);
696
- cursor: pointer;
697
- padding: 4px;
698
- border-radius: 6px;
699
- display: inline-flex;
700
- align-items: center;
701
- }
702
-
703
- .modal-close-btn:hover {
704
- background: var(--bg-tertiary);
705
- color: var(--text-primary);
706
  }
707
 
708
- .modal-hint {
709
- font-size: 13px;
710
  color: var(--text-muted);
711
- margin: 0 0 10px;
712
  }
713
 
714
- .picker-search {
715
  position: relative;
716
- margin-bottom: 10px;
717
  }
718
 
719
- .picker-search-icon {
720
  position: absolute;
721
- left: 10px;
722
  top: 50%;
723
  transform: translateY(-50%);
724
  color: var(--text-muted);
@@ -726,83 +705,87 @@
726
  pointer-events: none;
727
  }
728
 
729
- .picker-search input {
730
  width: 100%;
731
  box-sizing: border-box;
732
- padding: 10px 12px 10px 36px;
733
  border: 1px solid var(--border-primary);
734
- border-radius: 10px;
735
  background: var(--bg-primary);
736
  color: var(--text-primary);
737
- font-size: 14px;
738
  }
739
 
740
- .picker-search input:focus {
741
  outline: none;
742
  border-color: var(--accent-primary);
743
  }
744
 
745
- .picker-search input::placeholder {
746
  color: var(--text-muted);
747
  }
748
 
749
- .picker-list {
750
  list-style: none;
751
  margin: 0;
752
  padding: 0;
753
  overflow-y: auto;
754
  flex: 1;
755
- max-height: 50vh;
756
  }
757
 
758
- .picker-list li {
759
- margin-bottom: 4px;
760
  }
761
 
762
- .picker-item {
763
  width: 100%;
764
  text-align: left;
765
- padding: 10px 12px;
766
  border: 1px solid transparent;
767
- border-radius: 8px;
768
  background: transparent;
769
  color: var(--text-primary);
770
  cursor: pointer;
771
  display: flex;
772
  flex-direction: column;
773
- gap: 2px;
774
- font-size: 13px;
775
  transition: background 0.1s;
776
  }
777
 
778
- .picker-item:hover {
779
  background: var(--bg-tertiary);
780
  }
781
 
782
- .picker-item-active {
783
  border-color: var(--accent-primary);
784
  background: var(--accent-light);
785
  }
786
 
787
- .picker-item-active:hover {
788
  background: var(--accent-light);
789
  }
790
 
791
- .picker-item-info {
792
- display: flex;
793
- flex-direction: column;
794
- gap: 1px;
795
- }
796
-
797
- .picker-item-name {
798
  font-weight: 500;
 
799
  }
800
 
801
- .picker-item-provider {
802
- font-size: 11px;
803
  color: var(--text-muted);
804
  }
805
 
 
 
 
 
 
 
 
 
 
 
806
  /* ── Status / Loading ──────────────────────────────────────────── */
807
 
808
  .status-bar {
 
648
  }
649
 
650
 
651
+ /* ── Orchestrator sub-menu ─────────────────────────────────────── */
652
 
653
+ .dev-sub-panel {
654
+ position: absolute;
655
+ right: 100%;
656
+ top: 0;
657
+ margin-right: 4px;
658
+ width: 280px;
659
+ max-height: 70vh;
 
 
 
 
 
 
 
 
 
660
  display: flex;
661
  flex-direction: column;
662
  background: var(--card-bg);
663
  border: 1px solid var(--border-primary);
664
+ border-radius: 10px;
665
+ padding: 8px;
666
+ z-index: 51;
667
+ box-shadow: var(--shadow-md);
668
  }
669
 
670
+ .dev-sub-header {
671
  display: flex;
672
+ flex-direction: column;
673
+ gap: 2px;
674
+ padding: 4px 6px 8px;
675
+ border-bottom: 1px solid var(--border-primary);
676
+ margin-bottom: 6px;
677
  }
678
 
679
+ .dev-sub-title {
680
+ font-size: 12px;
681
  font-weight: 600;
682
  color: var(--text-primary);
683
+ text-transform: uppercase;
684
+ letter-spacing: 0.5px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
685
  }
686
 
687
+ .dev-sub-current {
688
+ font-size: 11px;
689
  color: var(--text-muted);
690
+ font-style: italic;
691
  }
692
 
693
+ .dev-sub-search {
694
  position: relative;
695
+ margin-bottom: 6px;
696
  }
697
 
698
+ .dev-sub-search-icon {
699
  position: absolute;
700
+ left: 8px;
701
  top: 50%;
702
  transform: translateY(-50%);
703
  color: var(--text-muted);
 
705
  pointer-events: none;
706
  }
707
 
708
+ .dev-sub-search input {
709
  width: 100%;
710
  box-sizing: border-box;
711
+ padding: 7px 8px 7px 30px;
712
  border: 1px solid var(--border-primary);
713
+ border-radius: 8px;
714
  background: var(--bg-primary);
715
  color: var(--text-primary);
716
+ font-size: 12px;
717
  }
718
 
719
+ .dev-sub-search input:focus {
720
  outline: none;
721
  border-color: var(--accent-primary);
722
  }
723
 
724
+ .dev-sub-search input::placeholder {
725
  color: var(--text-muted);
726
  }
727
 
728
+ .dev-sub-list {
729
  list-style: none;
730
  margin: 0;
731
  padding: 0;
732
  overflow-y: auto;
733
  flex: 1;
 
734
  }
735
 
736
+ .dev-sub-list li {
737
+ margin-bottom: 2px;
738
  }
739
 
740
+ .dev-sub-item {
741
  width: 100%;
742
  text-align: left;
743
+ padding: 6px 8px;
744
  border: 1px solid transparent;
745
+ border-radius: 6px;
746
  background: transparent;
747
  color: var(--text-primary);
748
  cursor: pointer;
749
  display: flex;
750
  flex-direction: column;
751
+ gap: 1px;
752
+ font-size: 12px;
753
  transition: background 0.1s;
754
  }
755
 
756
+ .dev-sub-item:hover {
757
  background: var(--bg-tertiary);
758
  }
759
 
760
+ .dev-sub-item-active {
761
  border-color: var(--accent-primary);
762
  background: var(--accent-light);
763
  }
764
 
765
+ .dev-sub-item-active:hover {
766
  background: var(--accent-light);
767
  }
768
 
769
+ .dev-sub-item strong {
 
 
 
 
 
 
770
  font-weight: 500;
771
+ font-size: 12px;
772
  }
773
 
774
+ .dev-sub-provider {
775
+ font-size: 10px;
776
  color: var(--text-muted);
777
  }
778
 
779
+ @media (max-width: 600px) {
780
+ .dev-sub-panel {
781
+ right: 0;
782
+ top: 100%;
783
+ margin-right: 0;
784
+ margin-top: 4px;
785
+ width: 260px;
786
+ }
787
+ }
788
+
789
  /* ── Status / Loading ──────────────────────────────────────────── */
790
 
791
  .status-bar {