free-coding-models 0.5.14 → 0.5.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog/v0.5.12.md +86 -2
- package/changelog/v0.5.13.md +19 -53
- package/changelog/v0.5.15.md +17 -0
- package/changelog/v0.5.16.md +15 -0
- package/package.json +4 -3
- package/src/tui/app.js +16 -4
- package/src/tui/cli-help.js +87 -0
- package/src/tui/key-handler.js +2 -14
- package/src/tui/render-table.js +46 -26
- package/src/tui/theme.js +70 -0
- package/web/README.md +1 -1
- package/web/dist/assets/index-avN2GM3H.css +1 -0
- package/web/dist/assets/index-vsysCImz.js +41 -0
- package/web/dist/index.html +6 -2
- package/web/index.html +4 -0
- package/web/server.js +249 -15
- package/web/src/App.jsx +46 -15
- package/web/src/components/dashboard/DetailPanel.jsx +1 -1
- package/web/src/components/dashboard/DetailPanel.module.css +5 -0
- package/web/src/components/dashboard/ExpandedDetailRow.jsx +464 -0
- package/web/src/components/dashboard/ExpandedDetailRow.module.css +347 -0
- package/web/src/components/dashboard/FilterBar.jsx +98 -58
- package/web/src/components/dashboard/FilterBar.module.css +103 -33
- package/web/src/components/dashboard/ModelTable.jsx +49 -29
- package/web/src/components/dashboard/ModelTable.module.css +12 -0
- package/web/src/components/dashboard/ProviderDropdown.jsx +156 -0
- package/web/src/components/dashboard/ProviderDropdown.module.css +248 -0
- package/web/src/components/help/HelpView.jsx +13 -0
- package/web/src/components/playground/PlaygroundView.jsx +193 -12
- package/web/src/components/playground/PlaygroundView.module.css +82 -0
- package/web/src/components/router/RouterView.jsx +15 -6
- package/web/vite.config.js +1 -1
- package/web/dist/assets/index-Bzqz7KbT.js +0 -39
- package/web/dist/assets/index-H9JWDRIh.css +0 -1
|
@@ -24,19 +24,112 @@
|
|
|
24
24
|
white-space: nowrap;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
/* ──
|
|
28
|
-
.
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
/* ── Collapsible filter groups (Tier, Status, Verdict, Health) ── */
|
|
28
|
+
.filterGroup {
|
|
29
|
+
position: relative;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.filterTrigger {
|
|
33
|
+
display: inline-flex; align-items: center; gap: 4px;
|
|
34
|
+
font-size: 11px; font-family: var(--font-mono);
|
|
31
35
|
padding: 4px 8px; border-radius: 5px;
|
|
32
|
-
border: 1px solid var(--color-border);
|
|
33
|
-
|
|
36
|
+
border: 1px solid var(--color-border);
|
|
37
|
+
background: var(--color-surface);
|
|
38
|
+
color: var(--color-text-muted);
|
|
39
|
+
cursor: pointer; transition: all 150ms;
|
|
40
|
+
white-space: nowrap;
|
|
41
|
+
user-select: none;
|
|
42
|
+
}
|
|
43
|
+
.filterTrigger:hover {
|
|
44
|
+
background: var(--color-bg-hover);
|
|
45
|
+
color: var(--color-text);
|
|
46
|
+
border-color: var(--color-text-muted);
|
|
47
|
+
}
|
|
48
|
+
.filterTriggerActive {
|
|
49
|
+
border-color: var(--color-accent);
|
|
50
|
+
color: var(--color-text);
|
|
51
|
+
}
|
|
52
|
+
.filterTriggerExpanded {
|
|
53
|
+
border-color: var(--color-accent);
|
|
54
|
+
background: var(--color-bg-hover);
|
|
55
|
+
color: var(--color-text);
|
|
56
|
+
border-bottom-left-radius: 0;
|
|
57
|
+
border-bottom-right-radius: 0;
|
|
58
|
+
z-index: 101;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.filterTriggerLabel {
|
|
62
|
+
font-size: 10px; font-weight: 700;
|
|
63
|
+
text-transform: uppercase; letter-spacing: 0.5px;
|
|
64
|
+
color: var(--color-text-muted);
|
|
65
|
+
}
|
|
66
|
+
.filterTriggerSep {
|
|
67
|
+
color: var(--color-border);
|
|
68
|
+
margin: 0 -2px;
|
|
69
|
+
}
|
|
70
|
+
.filterTriggerValue {
|
|
71
|
+
font-weight: 700;
|
|
72
|
+
color: var(--color-text);
|
|
73
|
+
}
|
|
74
|
+
.filterTriggerActive .filterTriggerValue {
|
|
75
|
+
color: var(--color-accent);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.filterTriggerChevron {
|
|
79
|
+
flex-shrink: 0;
|
|
80
|
+
transition: transform 200ms ease;
|
|
81
|
+
opacity: 0.6;
|
|
82
|
+
}
|
|
83
|
+
.chevronOpen {
|
|
84
|
+
transform: rotate(180deg);
|
|
85
|
+
opacity: 1;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.filterDropdown {
|
|
89
|
+
position: absolute; top: 100%; left: 0;
|
|
90
|
+
z-index: 100;
|
|
91
|
+
padding: 6px 8px 7px;
|
|
92
|
+
background: var(--color-surface);
|
|
93
|
+
border: 1px solid var(--color-accent);
|
|
94
|
+
border-top: none;
|
|
95
|
+
border-radius: 0 0 6px 6px;
|
|
96
|
+
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
|
97
|
+
min-width: max-content;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.filterChipRow {
|
|
101
|
+
display: flex;
|
|
102
|
+
gap: 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.filterChip {
|
|
106
|
+
font-size: 11px; font-weight: 600; font-family: var(--font-mono);
|
|
107
|
+
padding: 4px 9px;
|
|
108
|
+
border: 1px solid var(--color-border);
|
|
109
|
+
background: var(--color-bg-card);
|
|
110
|
+
color: var(--color-text-muted);
|
|
111
|
+
cursor: pointer; transition: all 120ms;
|
|
34
112
|
white-space: nowrap;
|
|
113
|
+
margin-left: -1px;
|
|
35
114
|
}
|
|
36
|
-
.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
115
|
+
.filterChip:first-child {
|
|
116
|
+
border-radius: 4px 0 0 4px;
|
|
117
|
+
margin-left: 0;
|
|
118
|
+
}
|
|
119
|
+
.filterChip:last-child {
|
|
120
|
+
border-radius: 0 4px 4px 0;
|
|
121
|
+
}
|
|
122
|
+
.filterChip:hover {
|
|
123
|
+
background: var(--color-bg-hover);
|
|
124
|
+
color: var(--color-text);
|
|
125
|
+
z-index: 1;
|
|
126
|
+
}
|
|
127
|
+
.filterChipActive {
|
|
128
|
+
background: var(--chip-active-color, var(--color-accent)) !important;
|
|
129
|
+
color: #fff !important;
|
|
130
|
+
border-color: var(--chip-active-color, var(--color-accent)) !important;
|
|
131
|
+
z-index: 2;
|
|
132
|
+
font-weight: 700;
|
|
40
133
|
}
|
|
41
134
|
|
|
42
135
|
/* ── Select inputs (provider, visibility) ── */
|
|
@@ -121,23 +214,6 @@
|
|
|
121
214
|
}
|
|
122
215
|
.customFilterClear:hover { background: var(--color-accent); color: var(--color-bg); }
|
|
123
216
|
|
|
124
|
-
/* ── Ping interval selector ── */
|
|
125
|
-
.pingRow { display: flex; gap: 3px; }
|
|
126
|
-
.pingBtn {
|
|
127
|
-
font-size: 11px; font-weight: 600; font-family: var(--font-mono);
|
|
128
|
-
padding: 4px 8px; border-radius: 5px;
|
|
129
|
-
border: 1px solid var(--color-border); background: var(--color-surface);
|
|
130
|
-
color: var(--color-text-muted); cursor: pointer; transition: all 150ms;
|
|
131
|
-
white-space: nowrap;
|
|
132
|
-
}
|
|
133
|
-
.pingBtn:hover { background: var(--color-bg-hover); color: var(--color-text); border-color: var(--color-text-muted); }
|
|
134
|
-
.pingBtnActive {
|
|
135
|
-
background: var(--color-bg-hover) !important;
|
|
136
|
-
color: var(--ping-active-color, var(--color-accent)) !important;
|
|
137
|
-
border-color: var(--ping-active-color, var(--color-accent)) !important;
|
|
138
|
-
font-weight: 700;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
217
|
/* ── Next ping countdown (TUI parity) ── */
|
|
142
218
|
.nextPing {
|
|
143
219
|
display: flex; align-items: center; gap: 6px;
|
|
@@ -157,12 +233,6 @@
|
|
|
157
233
|
text-align: right;
|
|
158
234
|
font-variant-numeric: tabular-nums;
|
|
159
235
|
}
|
|
160
|
-
.pingingDot {
|
|
161
|
-
width: 7px; height: 7px; border-radius: 50%;
|
|
162
|
-
background: #ff4466; animation: pulseDot 0.6s ease-in-out infinite;
|
|
163
|
-
flex-shrink: 0;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
236
|
@keyframes pulseDot {
|
|
167
237
|
0%, 100% { box-shadow: 0 0 0 0 var(--color-accent-glow); }
|
|
168
238
|
50% { box-shadow: 0 0 0 6px transparent; }
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* 📖 Custom widths persist in localStorage via the useColumnSizing hook and survive reloads.
|
|
14
14
|
* 📖 A "Reset columns" button appears in the toolbar only when the user has custom widths.
|
|
15
15
|
*/
|
|
16
|
-
import { useMemo, useEffect } from 'react'
|
|
16
|
+
import { useMemo, useEffect, useState, useCallback, Fragment } from 'react'
|
|
17
17
|
import {
|
|
18
18
|
useReactTable,
|
|
19
19
|
getCoreRowModel,
|
|
@@ -43,6 +43,7 @@ import { sweClass } from '../../utils/ranks.js'
|
|
|
43
43
|
// 📖 file the TUI uses; both surfaces share the source of truth for which
|
|
44
44
|
// 📖 providers are compatible with which tools.
|
|
45
45
|
import { getCompatibleTools } from '../../../../src/core/tool-metadata.js'
|
|
46
|
+
import ExpandedDetailRow from './ExpandedDetailRow.jsx'
|
|
46
47
|
import styles from './ModelTable.module.css'
|
|
47
48
|
|
|
48
49
|
const colHelper = createColumnHelper()
|
|
@@ -345,7 +346,15 @@ export default function ModelTable({
|
|
|
345
346
|
filtered, onSelectModel, onBenchmarkRow, onLaunch, favorites,
|
|
346
347
|
sortColumn, sortDirection, onSort,
|
|
347
348
|
toolMode = null,
|
|
349
|
+
onToast, onSetToolMode, onCycleToolMode, onOpenFallback,
|
|
348
350
|
}) {
|
|
351
|
+
// 📖 Expand row state — only one row expanded at a time (accordion)
|
|
352
|
+
const [expandedRowId, setExpandedRowId] = useState(null)
|
|
353
|
+
|
|
354
|
+
const toggleExpand = useCallback((model) => {
|
|
355
|
+
const key = `${model.providerKey}/${model.modelId}`
|
|
356
|
+
setExpandedRowId((prev) => prev === key ? null : key)
|
|
357
|
+
}, [])
|
|
349
358
|
// Compute top3 for medal rows
|
|
350
359
|
const top3Ids = useMemo(() => {
|
|
351
360
|
const online = filtered.filter(m => m.status === 'up' && m.avg != null && m.avg !== Infinity && m.avg < 99000)
|
|
@@ -501,49 +510,60 @@ export default function ModelTable({
|
|
|
501
510
|
<tbody>
|
|
502
511
|
{rows.map((row, i) => {
|
|
503
512
|
const m = row.original
|
|
513
|
+
const rowKey = `${m.providerKey}/${m.modelId}`
|
|
514
|
+
const isExpanded = expandedRowId === rowKey
|
|
504
515
|
const rankIdx = [...top3Ids].indexOf(m.modelId)
|
|
505
516
|
const rowClasses = []
|
|
506
517
|
if (rankIdx >= 0) rowClasses.push(styles[`rank${rankIdx + 1}`])
|
|
507
|
-
// 📖 Per-row benchmark state comes from the backend. The old global
|
|
508
|
-
// 📖 heuristic made every unbenchmarked row spin until the whole scan ended.
|
|
509
518
|
if (m.isBenchmarking) rowClasses.push(styles.benchRow)
|
|
510
|
-
|
|
511
|
-
// 📖 model isn't compatible with it. Mirrors the TUI render-table.js
|
|
512
|
-
// 📖 behavior. The picker itself ships in M3; for M1 the visual is wired.
|
|
519
|
+
if (isExpanded) rowClasses.push(styles.expandedRow)
|
|
513
520
|
if (toolMode) {
|
|
514
521
|
const compat = getCompatibleTools(m.providerKey)
|
|
515
522
|
if (!compat.includes(toolMode)) {
|
|
516
523
|
rowClasses.push(styles.incompatible)
|
|
517
524
|
}
|
|
518
525
|
}
|
|
519
|
-
// 📖 "Unusable" rows (no API key / bad API key) get the same 80% opacity
|
|
520
|
-
// 📖 fade the TUI uses, so the user can see at a glance which models
|
|
521
|
-
// 📖 they cannot actually use. The fade is applied as opacity on the
|
|
522
|
-
// 📖 whole <tr> so it composes cleanly with rank/bench/incompatible
|
|
523
|
-
// 📖 styles. Cursor hover is not implemented in the web, so the
|
|
524
|
-
// 📖 fade is never overridden by an active-selection highlight.
|
|
525
526
|
if (m.status === 'noauth' || m.status === 'auth_error') {
|
|
526
527
|
rowClasses.push(styles.unusable)
|
|
527
528
|
}
|
|
528
529
|
return (
|
|
529
|
-
<
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
530
|
+
<Fragment key={row.id}>
|
|
531
|
+
<tr
|
|
532
|
+
className={rowClasses.join(' ')}
|
|
533
|
+
onClick={() => toggleExpand(m)}
|
|
534
|
+
>
|
|
535
|
+
{row.getVisibleCells().map(cell => {
|
|
536
|
+
const sizePx = `${cell.column.getSize()}px`
|
|
537
|
+
return (
|
|
538
|
+
<td
|
|
539
|
+
key={cell.id}
|
|
540
|
+
className={styles.td}
|
|
541
|
+
style={{ width: sizePx, minWidth: sizePx, maxWidth: sizePx }}
|
|
542
|
+
>
|
|
543
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
544
|
+
</td>
|
|
545
|
+
)
|
|
546
|
+
})}
|
|
547
|
+
</tr>
|
|
548
|
+
{/* 📖 Expanded detail row — 3-column panel with info, playground, AI latency */}
|
|
549
|
+
{isExpanded && (
|
|
550
|
+
<tr className={styles.expandRowWrapper}>
|
|
551
|
+
<td colSpan={row.getVisibleCells().length} className={styles.expandRowCell}>
|
|
552
|
+
<ExpandedDetailRow
|
|
553
|
+
model={m}
|
|
554
|
+
favorites={favorites}
|
|
555
|
+
onBenchmark={onBenchmarkRow}
|
|
556
|
+
onLaunch={onLaunch}
|
|
557
|
+
onToast={onToast}
|
|
558
|
+
toolMode={toolMode}
|
|
559
|
+
onSetToolMode={onSetToolMode}
|
|
560
|
+
onCycleToolMode={onCycleToolMode}
|
|
561
|
+
onOpenFallback={onOpenFallback}
|
|
562
|
+
/>
|
|
543
563
|
</td>
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
</
|
|
564
|
+
</tr>
|
|
565
|
+
)}
|
|
566
|
+
</Fragment>
|
|
547
567
|
)
|
|
548
568
|
})}
|
|
549
569
|
</tbody>
|
|
@@ -356,4 +356,16 @@
|
|
|
356
356
|
.incompatible .launchCell,
|
|
357
357
|
.benchRow .launchCell {
|
|
358
358
|
opacity: 1;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/* ─── Expandable row styles ─── */
|
|
362
|
+
.expandedRow {
|
|
363
|
+
background: var(--color-bg-active) !important;
|
|
364
|
+
}
|
|
365
|
+
.expandRowWrapper {
|
|
366
|
+
border: none;
|
|
367
|
+
}
|
|
368
|
+
.expandRowCell {
|
|
369
|
+
padding: 0 !important;
|
|
370
|
+
border-bottom: 2px solid var(--color-accent) !important;
|
|
359
371
|
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/dashboard/ProviderDropdown.jsx
|
|
3
|
+
* @description Custom dropdown for provider filtering with inline SVG logos + health indicator.
|
|
4
|
+
*
|
|
5
|
+
* @details
|
|
6
|
+
* - Replaces the native `<select>` for providers with a custom dropdown that
|
|
7
|
+
* renders each provider as [SVG icon + wordmark + health dot + model count].
|
|
8
|
+
* - Uses the same `ProviderLogo` component as the model table so branding is
|
|
9
|
+
* visually consistent across the dashboard.
|
|
10
|
+
* - The health indicator dot shows:
|
|
11
|
+
* 🟢 Green (glow) — provider has at least one model with status 'up' (key works).
|
|
12
|
+
* 🟡 Yellow — provider has keys configured but no models are 'up' yet (pending).
|
|
13
|
+
* 🔴 Red — provider has keys but models are down / auth errors.
|
|
14
|
+
* ⚪ Gray — provider has no API key configured at all.
|
|
15
|
+
* - Theme-aware: colors adapt for both dark and light modes via CSS variables.
|
|
16
|
+
* - Click-outside + Escape to close, keyboard-friendly, scrollable for long lists.
|
|
17
|
+
*
|
|
18
|
+
* @param {object} props
|
|
19
|
+
* @param {Array} props.providers — Array of { key, name, count, hasKey, anyUp }
|
|
20
|
+
* @param {string} props.value — Currently selected provider key ('all' for no filter).
|
|
21
|
+
* @param {function} props.onChange — Callback with new provider key.
|
|
22
|
+
*
|
|
23
|
+
* @functions
|
|
24
|
+
* → ProviderDropdown (default export)
|
|
25
|
+
*
|
|
26
|
+
* @see web/src/components/dashboard/FilterBar.jsx (consumer)
|
|
27
|
+
* @see web/src/components/atoms/ProviderLogo.jsx (logo renderer)
|
|
28
|
+
*/
|
|
29
|
+
import { useState, useEffect, useRef, useCallback } from 'react'
|
|
30
|
+
import { IconChevronDown } from '@tabler/icons-react'
|
|
31
|
+
import ProviderLogo from '../atoms/ProviderLogo.jsx'
|
|
32
|
+
import styles from './ProviderDropdown.module.css'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 📖 Derives the health indicator state from aggregated provider data.
|
|
36
|
+
* Returns one of: 'active' | 'pending' | 'down' | 'nokey'.
|
|
37
|
+
*/
|
|
38
|
+
function providerHealthState(provider) {
|
|
39
|
+
if (!provider.hasKey) return 'nokey'
|
|
40
|
+
if (provider.anyUp) return 'active'
|
|
41
|
+
return 'down'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function HealthIndicator({ state }) {
|
|
45
|
+
const cls = styles[`dot_${state}`] || styles.dot_nokey
|
|
46
|
+
const titles = {
|
|
47
|
+
active: 'API key works — at least one model is UP',
|
|
48
|
+
pending: 'API key set — waiting for health data',
|
|
49
|
+
down: 'API key set — models are DOWN or have auth errors',
|
|
50
|
+
nokey: 'No API key configured',
|
|
51
|
+
}
|
|
52
|
+
return <span className={`${styles.healthDot} ${cls}`} title={titles[state] || ''} />
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default function ProviderDropdown({ providers, value, onChange }) {
|
|
56
|
+
const [open, setOpen] = useState(false)
|
|
57
|
+
const ref = useRef(null)
|
|
58
|
+
const listRef = useRef(null)
|
|
59
|
+
|
|
60
|
+
const close = useCallback(() => setOpen(false), [])
|
|
61
|
+
|
|
62
|
+
// 📖 Close on outside click or Escape.
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!open) return
|
|
65
|
+
const handleClickOutside = (e) => {
|
|
66
|
+
if (ref.current && !ref.current.contains(e.target)) close()
|
|
67
|
+
}
|
|
68
|
+
const handleKey = (e) => { if (e.key === 'Escape') close() }
|
|
69
|
+
document.addEventListener('mousedown', handleClickOutside)
|
|
70
|
+
document.addEventListener('keydown', handleKey)
|
|
71
|
+
return () => {
|
|
72
|
+
document.removeEventListener('mousedown', handleClickOutside)
|
|
73
|
+
document.removeEventListener('keydown', handleKey)
|
|
74
|
+
}
|
|
75
|
+
}, [open, close])
|
|
76
|
+
|
|
77
|
+
// 📖 Scroll selected item into view when dropdown opens.
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!open || !listRef.current) return
|
|
80
|
+
const selected = listRef.current.querySelector(`[data-active="true"]`)
|
|
81
|
+
if (selected) selected.scrollIntoView({ block: 'nearest' })
|
|
82
|
+
}, [open])
|
|
83
|
+
|
|
84
|
+
const selectedProvider = providers.find(p => p.key === value)
|
|
85
|
+
const isFiltered = value !== 'all'
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div className={styles.dropdown} ref={ref}>
|
|
89
|
+
<button
|
|
90
|
+
className={`${styles.trigger} ${isFiltered ? styles.triggerActive : ''} ${open ? styles.triggerOpen : ''}`}
|
|
91
|
+
onClick={() => setOpen(!open)}
|
|
92
|
+
aria-expanded={open}
|
|
93
|
+
aria-haspopup="listbox"
|
|
94
|
+
title={isFiltered ? `Provider: ${selectedProvider?.name || value}` : 'All providers'}
|
|
95
|
+
>
|
|
96
|
+
<span className={styles.triggerContent}>
|
|
97
|
+
{isFiltered && selectedProvider ? (
|
|
98
|
+
<>
|
|
99
|
+
<span className={styles.triggerLogo}>
|
|
100
|
+
<ProviderLogo providerKey={selectedProvider.key} origin={selectedProvider.name} />
|
|
101
|
+
</span>
|
|
102
|
+
<span className={styles.triggerCount}>{selectedProvider.count}</span>
|
|
103
|
+
</>
|
|
104
|
+
) : (
|
|
105
|
+
<span className={styles.triggerLabel}>All Providers</span>
|
|
106
|
+
)}
|
|
107
|
+
</span>
|
|
108
|
+
<IconChevronDown
|
|
109
|
+
size={12}
|
|
110
|
+
stroke={2}
|
|
111
|
+
className={`${styles.chevron} ${open ? styles.chevronOpen : ''}`}
|
|
112
|
+
/>
|
|
113
|
+
</button>
|
|
114
|
+
|
|
115
|
+
{open && (
|
|
116
|
+
<div className={styles.menu} role="listbox" ref={listRef}>
|
|
117
|
+
{/* ── "All Providers" option ── */}
|
|
118
|
+
<button
|
|
119
|
+
role="option"
|
|
120
|
+
aria-selected={value === 'all'}
|
|
121
|
+
data-active={value === 'all'}
|
|
122
|
+
className={`${styles.option} ${value === 'all' ? styles.optionActive : ''}`}
|
|
123
|
+
onClick={() => { onChange('all'); close() }}
|
|
124
|
+
>
|
|
125
|
+
<span className={styles.optionLabel}>All Providers</span>
|
|
126
|
+
<span className={styles.optionCount}>{providers.reduce((s, p) => s + p.count, 0)}</span>
|
|
127
|
+
</button>
|
|
128
|
+
|
|
129
|
+
<div className={styles.separator} />
|
|
130
|
+
|
|
131
|
+
{/* ── Per-provider options ── */}
|
|
132
|
+
{providers.map((p) => {
|
|
133
|
+
const healthState = providerHealthState(p)
|
|
134
|
+
return (
|
|
135
|
+
<button
|
|
136
|
+
key={p.key}
|
|
137
|
+
role="option"
|
|
138
|
+
aria-selected={value === p.key}
|
|
139
|
+
data-active={value === p.key}
|
|
140
|
+
className={`${styles.option} ${value === p.key ? styles.optionActive : ''}`}
|
|
141
|
+
onClick={() => { onChange(p.key); close() }}
|
|
142
|
+
title={`${p.name} — ${p.count} model${p.count !== 1 ? 's' : ''}`}
|
|
143
|
+
>
|
|
144
|
+
<span className={styles.optionLogo}>
|
|
145
|
+
<ProviderLogo providerKey={p.key} origin={p.name} />
|
|
146
|
+
</span>
|
|
147
|
+
<HealthIndicator state={healthState} />
|
|
148
|
+
<span className={styles.optionCount}>{p.count}</span>
|
|
149
|
+
</button>
|
|
150
|
+
)
|
|
151
|
+
})}
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
</div>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/dashboard/ProviderDropdown.module.css
|
|
3
|
+
* @description Styles for the custom provider dropdown — SVG logos + health dots.
|
|
4
|
+
*
|
|
5
|
+
* 📖 Theme-aware: uses CSS variables for all colors. Dark + light mode both
|
|
6
|
+
* 📖 fully supported. Health dot colors are defined per-theme with explicit
|
|
7
|
+
* 📖 overrides for light mode to maintain contrast.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
.dropdown {
|
|
11
|
+
position: relative;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/* ── Trigger button ── */
|
|
15
|
+
.trigger {
|
|
16
|
+
display: inline-flex;
|
|
17
|
+
align-items: center;
|
|
18
|
+
gap: 6px;
|
|
19
|
+
padding: 4px 10px 4px 8px;
|
|
20
|
+
border: 1px solid var(--color-border);
|
|
21
|
+
border-radius: 5px;
|
|
22
|
+
background: var(--color-surface);
|
|
23
|
+
color: var(--color-text);
|
|
24
|
+
font-size: 11px;
|
|
25
|
+
font-family: var(--font-sans);
|
|
26
|
+
cursor: pointer;
|
|
27
|
+
transition: all 150ms;
|
|
28
|
+
min-width: 130px;
|
|
29
|
+
max-width: 240px;
|
|
30
|
+
user-select: none;
|
|
31
|
+
outline: none;
|
|
32
|
+
}
|
|
33
|
+
.trigger:hover {
|
|
34
|
+
background: var(--color-bg-hover);
|
|
35
|
+
border-color: var(--color-text-muted);
|
|
36
|
+
}
|
|
37
|
+
.trigger:focus {
|
|
38
|
+
border-color: var(--color-accent);
|
|
39
|
+
}
|
|
40
|
+
.triggerActive {
|
|
41
|
+
border-color: var(--color-accent);
|
|
42
|
+
color: var(--color-text);
|
|
43
|
+
}
|
|
44
|
+
.triggerOpen {
|
|
45
|
+
border-color: var(--color-accent);
|
|
46
|
+
border-bottom-left-radius: 0;
|
|
47
|
+
border-bottom-right-radius: 0;
|
|
48
|
+
z-index: 101;
|
|
49
|
+
background: var(--color-bg-hover);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.triggerContent {
|
|
53
|
+
display: inline-flex;
|
|
54
|
+
align-items: center;
|
|
55
|
+
gap: 8px;
|
|
56
|
+
flex: 1;
|
|
57
|
+
min-width: 0;
|
|
58
|
+
overflow: hidden;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.triggerLogo {
|
|
62
|
+
display: inline-flex;
|
|
63
|
+
align-items: center;
|
|
64
|
+
min-width: 0;
|
|
65
|
+
overflow: hidden;
|
|
66
|
+
/* 📖 Inherit max-height from ProviderLogo's row. */
|
|
67
|
+
--pl-max-h: 20px;
|
|
68
|
+
--pl-icon-h: 14px;
|
|
69
|
+
--pl-text-h: 10px;
|
|
70
|
+
}
|
|
71
|
+
/* 📖 Clamp ProviderLogo width inside trigger so long wordmarks don't overflow. */
|
|
72
|
+
.triggerLogo > span {
|
|
73
|
+
max-width: 160px;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
.triggerLabel {
|
|
77
|
+
font-size: 11px;
|
|
78
|
+
font-weight: 600;
|
|
79
|
+
color: var(--color-text-muted);
|
|
80
|
+
white-space: nowrap;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.triggerCount {
|
|
84
|
+
font-size: 10px;
|
|
85
|
+
font-weight: 700;
|
|
86
|
+
color: var(--color-text-muted);
|
|
87
|
+
font-family: var(--font-mono);
|
|
88
|
+
margin-left: auto;
|
|
89
|
+
flex-shrink: 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.chevron {
|
|
93
|
+
flex-shrink: 0;
|
|
94
|
+
opacity: 0.5;
|
|
95
|
+
transition: transform 200ms ease;
|
|
96
|
+
}
|
|
97
|
+
.chevronOpen {
|
|
98
|
+
transform: rotate(180deg);
|
|
99
|
+
opacity: 0.8;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* ── Dropdown menu ── */
|
|
103
|
+
.menu {
|
|
104
|
+
position: absolute;
|
|
105
|
+
top: 100%;
|
|
106
|
+
left: 0;
|
|
107
|
+
z-index: 100;
|
|
108
|
+
min-width: 100%;
|
|
109
|
+
max-width: 300px;
|
|
110
|
+
max-height: 320px;
|
|
111
|
+
overflow-y: auto;
|
|
112
|
+
background: var(--color-surface);
|
|
113
|
+
border: 1px solid var(--color-accent);
|
|
114
|
+
border-top: none;
|
|
115
|
+
border-radius: 0 0 6px 6px;
|
|
116
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
|
117
|
+
padding: 4px 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/* 📖 Custom scrollbar for the dropdown. */
|
|
121
|
+
.menu::-webkit-scrollbar {
|
|
122
|
+
width: 5px;
|
|
123
|
+
}
|
|
124
|
+
.menu::-webkit-scrollbar-track {
|
|
125
|
+
background: transparent;
|
|
126
|
+
}
|
|
127
|
+
.menu::-webkit-scrollbar-thumb {
|
|
128
|
+
background: var(--color-border);
|
|
129
|
+
border-radius: 3px;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* ── Menu options ── */
|
|
133
|
+
.option {
|
|
134
|
+
display: flex;
|
|
135
|
+
align-items: center;
|
|
136
|
+
gap: 8px;
|
|
137
|
+
width: 100%;
|
|
138
|
+
padding: 6px 10px;
|
|
139
|
+
border: none;
|
|
140
|
+
background: transparent;
|
|
141
|
+
color: var(--color-text-muted);
|
|
142
|
+
font-size: 11px;
|
|
143
|
+
font-family: var(--font-sans);
|
|
144
|
+
cursor: pointer;
|
|
145
|
+
transition: all 100ms;
|
|
146
|
+
text-align: left;
|
|
147
|
+
outline: none;
|
|
148
|
+
}
|
|
149
|
+
.option:hover {
|
|
150
|
+
background: var(--color-bg-hover);
|
|
151
|
+
color: var(--color-text);
|
|
152
|
+
}
|
|
153
|
+
.option:focus-visible {
|
|
154
|
+
background: var(--color-bg-hover);
|
|
155
|
+
color: var(--color-text);
|
|
156
|
+
box-shadow: inset 0 0 0 1px var(--color-accent);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.optionActive {
|
|
160
|
+
background: var(--color-accent-dim);
|
|
161
|
+
color: var(--color-text);
|
|
162
|
+
font-weight: 600;
|
|
163
|
+
}
|
|
164
|
+
.optionActive:hover {
|
|
165
|
+
background: var(--color-bg-hover);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.optionLabel {
|
|
169
|
+
font-weight: 600;
|
|
170
|
+
white-space: nowrap;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.optionLogo {
|
|
174
|
+
display: inline-flex;
|
|
175
|
+
align-items: center;
|
|
176
|
+
min-width: 0;
|
|
177
|
+
flex: 1;
|
|
178
|
+
overflow: hidden;
|
|
179
|
+
/* 📖 Slightly larger than trigger for readability in the expanded menu. */
|
|
180
|
+
--pl-max-h: 20px;
|
|
181
|
+
--pl-icon-h: 15px;
|
|
182
|
+
--pl-text-h: 11px;
|
|
183
|
+
}
|
|
184
|
+
.optionLogo > span {
|
|
185
|
+
max-width: 180px;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.optionCount {
|
|
189
|
+
font-size: 10px;
|
|
190
|
+
font-weight: 700;
|
|
191
|
+
font-family: var(--font-mono);
|
|
192
|
+
color: var(--color-text-dim, #666);
|
|
193
|
+
flex-shrink: 0;
|
|
194
|
+
margin-left: auto;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* ── Separator between "All" and per-provider options ── */
|
|
198
|
+
.separator {
|
|
199
|
+
height: 1px;
|
|
200
|
+
background: var(--color-border);
|
|
201
|
+
margin: 4px 8px;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/* ── Health indicator dots ── */
|
|
205
|
+
.healthDot {
|
|
206
|
+
display: inline-block;
|
|
207
|
+
width: 7px;
|
|
208
|
+
height: 7px;
|
|
209
|
+
border-radius: 50%;
|
|
210
|
+
flex-shrink: 0;
|
|
211
|
+
transition: background 200ms ease, box-shadow 200ms ease;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/* 📖 Active: green glow — key works and models are UP. */
|
|
215
|
+
.dot_active {
|
|
216
|
+
background: #00ff88;
|
|
217
|
+
box-shadow: 0 0 5px rgba(0, 255, 136, 0.5);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/* 📖 Pending: yellow — key set, health data not yet available. */
|
|
221
|
+
.dot_pending {
|
|
222
|
+
background: #ffaa00;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/* 📖 Down: red — key set but models are down / auth errors. */
|
|
226
|
+
.dot_down {
|
|
227
|
+
background: #ff4444;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/* 📖 No key: gray — no API key configured for this provider. */
|
|
231
|
+
.dot_nokey {
|
|
232
|
+
background: #555570;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/* ── Light theme overrides — deeper colors for white background contrast ── */
|
|
236
|
+
:global([data-theme="light"]) .dot_active {
|
|
237
|
+
background: #008f4d;
|
|
238
|
+
box-shadow: 0 0 4px rgba(0, 143, 77, 0.35);
|
|
239
|
+
}
|
|
240
|
+
:global([data-theme="light"]) .dot_pending {
|
|
241
|
+
background: #b38600;
|
|
242
|
+
}
|
|
243
|
+
:global([data-theme="light"]) .dot_down {
|
|
244
|
+
background: #c8143a;
|
|
245
|
+
}
|
|
246
|
+
:global([data-theme="light"]) .dot_nokey {
|
|
247
|
+
background: #aaa;
|
|
248
|
+
}
|