free-coding-models 0.5.1 โ 0.5.4
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/README.md +31 -5
- package/changelog/v0.5.4.md +24 -0
- package/package.json +5 -2
- package/sources.js +2 -0
- package/src/tui/key-handler.js +1 -1
- package/web/assets/providers/aistudio/aistudio-text.svg +1 -0
- package/web/assets/providers/aistudio/aistudio.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-brand-color.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-brand.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-color.svg +1 -0
- package/web/assets/providers/cerebras/cerebras-text.svg +1 -0
- package/web/assets/providers/cerebras/cerebras.svg +1 -0
- package/web/assets/providers/cloudflare/cloudflare-color.svg +1 -0
- package/web/assets/providers/cloudflare/cloudflare-text.svg +1 -0
- package/web/assets/providers/cloudflare/cloudflare.svg +1 -0
- package/web/assets/providers/gemini/gemini-color.svg +1 -0
- package/web/assets/providers/gemini/gemini-text.svg +1 -0
- package/web/assets/providers/gemini/gemini.svg +1 -0
- package/web/assets/providers/github/github-text.svg +1 -0
- package/web/assets/providers/github/github.svg +1 -0
- package/web/assets/providers/groq/groq-text.svg +1 -0
- package/web/assets/providers/groq/groq.svg +1 -0
- package/web/assets/providers/mistral/mistral-color.svg +1 -0
- package/web/assets/providers/mistral/mistral-text.svg +1 -0
- package/web/assets/providers/mistral/mistral.svg +1 -0
- package/web/assets/providers/nvidia/nvidia-color.svg +1 -0
- package/web/assets/providers/nvidia/nvidia-text.svg +1 -0
- package/web/assets/providers/nvidia/nvidia.svg +1 -0
- package/web/assets/providers/opencode/opencode-text.svg +1 -0
- package/web/assets/providers/opencode/opencode.svg +1 -0
- package/web/assets/providers/openrouter/openrouter (1).svg +1 -0
- package/web/assets/providers/openrouter/openrouter-text.svg +1 -0
- package/web/assets/providers/openrouter/openrouter.svg +1 -0
- package/web/assets/providers/ovhcloud/ovhcloud.svg +12 -0
- package/web/assets/providers/qwen/qwen-color.svg +1 -0
- package/web/assets/providers/qwen/qwen-text.svg +1 -0
- package/web/assets/providers/qwen/qwen.svg +1 -0
- package/web/assets/providers/sambanova/sambanova-color.svg +1 -0
- package/web/assets/providers/sambanova/sambanova-text.svg +1 -0
- package/web/assets/providers/sambanova/sambanova.svg +1 -0
- package/web/assets/providers/scalewaylogo/ScalewayLogo.svg +13 -0
- package/web/assets/providers/zai/zai-text.svg +1 -0
- package/web/assets/providers/zai/zai.svg +1 -0
- package/web/dist/assets/index-BoWmUveV.js +39 -0
- package/web/dist/assets/index-BrpHevg4.css +1 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/favicons/apple-touch-icon.png +0 -0
- package/web/dist/favicons/browserconfig.xml +12 -0
- package/web/dist/favicons/favicon-16x16.png +0 -0
- package/web/dist/favicons/favicon-192x192.png +0 -0
- package/web/dist/favicons/favicon-32x32.png +0 -0
- package/web/dist/favicons/favicon-48x48.png +0 -0
- package/web/dist/favicons/favicon-512x512.png +0 -0
- package/web/dist/favicons/favicon-96x96.png +0 -0
- package/web/dist/favicons/favicon.ico +0 -0
- package/web/dist/favicons/mstile-150x150.png +0 -0
- package/web/dist/favicons/mstile-310x150.png +0 -0
- package/web/dist/favicons/mstile-310x310.png +0 -0
- package/web/dist/favicons/mstile-512x512.png +0 -0
- package/web/dist/favicons/mstile-70x70.png +0 -0
- package/web/dist/favicons/site.webmanifest +25 -0
- package/web/dist/index.html +42 -3
- package/web/index.html +40 -1
- package/web/public/favicon.ico +0 -0
- package/web/public/favicons/apple-touch-icon.png +0 -0
- package/web/public/favicons/browserconfig.xml +12 -0
- package/web/public/favicons/favicon-16x16.png +0 -0
- package/web/public/favicons/favicon-192x192.png +0 -0
- package/web/public/favicons/favicon-32x32.png +0 -0
- package/web/public/favicons/favicon-48x48.png +0 -0
- package/web/public/favicons/favicon-512x512.png +0 -0
- package/web/public/favicons/favicon-96x96.png +0 -0
- package/web/public/favicons/favicon.ico +0 -0
- package/web/public/favicons/mstile-150x150.png +0 -0
- package/web/public/favicons/mstile-310x150.png +0 -0
- package/web/public/favicons/mstile-310x310.png +0 -0
- package/web/public/favicons/mstile-512x512.png +0 -0
- package/web/public/favicons/mstile-70x70.png +0 -0
- package/web/public/favicons/site.webmanifest +25 -0
- package/web/server.js +71 -1
- package/web/src/App.jsx +209 -89
- package/web/src/components/analytics/AnalyticsView.jsx +30 -2
- package/web/src/components/analytics/AnalyticsView.module.css +4 -0
- package/web/src/components/atoms/AILatencyCell.module.css +9 -1
- package/web/src/components/atoms/HealthCell.jsx +113 -28
- package/web/src/components/atoms/HealthCell.module.css +43 -2
- package/web/src/components/atoms/LastPingCell.module.css +10 -0
- package/web/src/components/atoms/NoKeyIcon.jsx +65 -0
- package/web/src/components/atoms/NoKeyIcon.module.css +30 -0
- package/web/src/components/atoms/ProviderLogo.jsx +188 -0
- package/web/src/components/atoms/ProviderLogo.module.css +103 -0
- package/web/src/components/atoms/Sparkline.jsx +21 -2
- package/web/src/components/atoms/StabilityCell.module.css +5 -0
- package/web/src/components/atoms/StatusDot.module.css +7 -0
- package/web/src/components/atoms/TPSCell.module.css +7 -1
- package/web/src/components/atoms/TierBadge.module.css +11 -0
- package/web/src/components/atoms/Toast.module.css +6 -0
- package/web/src/components/atoms/VerdictBadge.module.css +12 -0
- package/web/src/components/dashboard/DetailPanel.jsx +63 -2
- package/web/src/components/dashboard/DetailPanel.module.css +130 -0
- package/web/src/components/dashboard/FilterBar.jsx +146 -53
- package/web/src/components/dashboard/FilterBar.module.css +138 -58
- package/web/src/components/dashboard/ModelTable.jsx +235 -30
- package/web/src/components/dashboard/ModelTable.module.css +205 -16
- package/web/src/components/layout/Header.jsx +140 -12
- package/web/src/components/layout/Header.module.css +191 -27
- package/web/src/components/palette/CommandPalette.jsx +146 -0
- package/web/src/components/palette/CommandPalette.module.css +121 -0
- package/web/src/global.css +52 -15
- package/web/src/hooks/useColumnSizing.js +211 -0
- package/web/src/hooks/useFavorites.js +150 -0
- package/web/src/hooks/useFilter.js +157 -24
- package/web/src/hooks/useTheme.js +72 -16
- package/web/src/hooks/useUrlState.js +93 -0
- package/web/dist/assets/index-ByGf4Kq-.js +0 -14
- package/web/dist/assets/index-Ds7wmHBv.css +0 -1
- package/web/src/components/dashboard/StatsBar.jsx +0 -42
- package/web/src/components/dashboard/StatsBar.module.css +0 -28
- package/web/src/components/layout/Sidebar.jsx +0 -50
- package/web/src/components/layout/Sidebar.module.css +0 -86
- package/web/src/components/map/MapView.jsx +0 -17
- package/web/src/components/map/MapView.module.css +0 -25
|
@@ -1,63 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file web/src/components/dashboard/FilterBar.module.css
|
|
3
|
+
* @description Filter bar styles โ M1 parity chips (tier, status, verdict, health,
|
|
4
|
+
* visibility, provider, custom text filter, reset, ping mode).
|
|
5
|
+
* ๐ Sticky below the header (`top: 60px` = header height) so the filter chips
|
|
6
|
+
* ๐ stay visible while the user scrolls a long table.
|
|
7
|
+
*/
|
|
8
|
+
|
|
1
9
|
.filters {
|
|
2
|
-
position:
|
|
3
|
-
display: flex; align-items: center; gap:
|
|
4
|
-
padding:
|
|
10
|
+
position: sticky; top: 60px; z-index: 99;
|
|
11
|
+
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
|
|
12
|
+
padding: 10px 20px;
|
|
13
|
+
background: var(--color-bg-card);
|
|
14
|
+
border-bottom: 1px solid var(--color-border);
|
|
15
|
+
backdrop-filter: blur(20px) saturate(1.5);
|
|
16
|
+
-webkit-backdrop-filter: blur(20px) saturate(1.5);
|
|
5
17
|
}
|
|
6
|
-
|
|
18
|
+
|
|
19
|
+
.group { display: flex; align-items: center; gap: 6px; }
|
|
20
|
+
|
|
7
21
|
.filterLabel {
|
|
8
|
-
font-size:
|
|
22
|
+
font-size: 10px; font-weight: 700; color: var(--color-text-muted);
|
|
9
23
|
text-transform: uppercase; letter-spacing: 0.5px;
|
|
24
|
+
white-space: nowrap;
|
|
10
25
|
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
26
|
+
|
|
27
|
+
/* โโ Chip rows (TUI cycle behavior) โโ */
|
|
28
|
+
.chipRow { display: flex; gap: 3px; }
|
|
29
|
+
.chip {
|
|
30
|
+
font-size: 11px; font-weight: 600; font-family: var(--font-mono);
|
|
31
|
+
padding: 4px 8px; border-radius: 5px;
|
|
15
32
|
border: 1px solid var(--color-border); background: var(--color-surface);
|
|
16
33
|
color: var(--color-text-muted); cursor: pointer; transition: all 150ms;
|
|
34
|
+
white-space: nowrap;
|
|
17
35
|
}
|
|
18
|
-
.
|
|
19
|
-
.
|
|
36
|
+
.chip:hover { background: var(--color-bg-hover); color: var(--color-text); }
|
|
37
|
+
.chipActive {
|
|
20
38
|
background: var(--color-accent) !important; color: var(--color-bg) !important;
|
|
21
39
|
border-color: var(--color-accent) !important; font-weight: 700;
|
|
22
40
|
}
|
|
23
|
-
|
|
41
|
+
|
|
42
|
+
/* โโ Select inputs (provider, visibility) โโ */
|
|
43
|
+
.select, .providerSelect {
|
|
24
44
|
background: var(--color-surface); color: var(--color-text);
|
|
25
|
-
border: 1px solid var(--color-border); border-radius:
|
|
26
|
-
padding:
|
|
45
|
+
border: 1px solid var(--color-border); border-radius: 5px;
|
|
46
|
+
padding: 4px 8px; font-size: 11px; font-family: var(--font-sans);
|
|
27
47
|
cursor: pointer; outline: none;
|
|
48
|
+
min-width: 130px;
|
|
28
49
|
}
|
|
29
|
-
.providerSelect:focus { border-color: var(--color-accent); }
|
|
50
|
+
.select:focus, .providerSelect:focus { border-color: var(--color-accent); }
|
|
51
|
+
|
|
52
|
+
/* โโ Spacer + reset โโ */
|
|
30
53
|
.spacer { flex: 1; }
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
54
|
+
|
|
55
|
+
.resetBtn {
|
|
56
|
+
display: inline-flex; align-items: center; gap: 5px;
|
|
57
|
+
font-size: 11px; font-weight: 700; font-family: var(--font-mono);
|
|
58
|
+
padding: 4px 9px; border-radius: 5px;
|
|
59
|
+
border: 1px solid var(--color-border-hover);
|
|
60
|
+
background: var(--color-accent-dim);
|
|
61
|
+
color: var(--color-text);
|
|
62
|
+
cursor: pointer; transition: all 150ms;
|
|
63
|
+
}
|
|
64
|
+
.resetBtn:hover {
|
|
65
|
+
background: var(--color-accent);
|
|
66
|
+
color: var(--color-bg);
|
|
67
|
+
}
|
|
68
|
+
.resetBadge {
|
|
69
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
70
|
+
min-width: 16px; height: 16px;
|
|
71
|
+
font-size: 9px; font-weight: 800;
|
|
72
|
+
background: var(--color-bg);
|
|
73
|
+
color: var(--color-accent);
|
|
74
|
+
border-radius: 999px;
|
|
75
|
+
padding: 0 4px;
|
|
35
76
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
77
|
+
|
|
78
|
+
/* โโ Custom text filter chip (TUI Ctrl+P "Apply text filter") โโ */
|
|
79
|
+
.customFilterChip {
|
|
80
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
81
|
+
font-size: 11px; font-weight: 600; font-family: var(--font-mono);
|
|
82
|
+
padding: 3px 4px 3px 8px;
|
|
83
|
+
border: 1px solid var(--color-accent);
|
|
84
|
+
background: var(--color-accent-dim);
|
|
85
|
+
color: var(--color-accent);
|
|
86
|
+
border-radius: 5px;
|
|
87
|
+
max-width: 240px;
|
|
88
|
+
}
|
|
89
|
+
.customFilterIcon { display: inline-flex; flex-shrink: 0; }
|
|
90
|
+
.customFilterLabel {
|
|
91
|
+
overflow: hidden;
|
|
92
|
+
text-overflow: ellipsis;
|
|
93
|
+
white-space: nowrap;
|
|
39
94
|
}
|
|
95
|
+
.customFilterClear {
|
|
96
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
97
|
+
width: 18px; height: 18px;
|
|
98
|
+
background: transparent;
|
|
99
|
+
border: none;
|
|
100
|
+
color: var(--color-accent);
|
|
101
|
+
border-radius: 3px;
|
|
102
|
+
cursor: pointer;
|
|
103
|
+
flex-shrink: 0;
|
|
104
|
+
}
|
|
105
|
+
.customFilterClear:hover { background: var(--color-accent); color: var(--color-bg); }
|
|
40
106
|
|
|
41
|
-
/* โโ
|
|
107
|
+
/* โโ Ping interval selector โโ */
|
|
108
|
+
.pingRow { display: flex; gap: 3px; }
|
|
109
|
+
.pingBtn {
|
|
110
|
+
font-size: 11px; font-weight: 600; font-family: var(--font-mono);
|
|
111
|
+
padding: 4px 8px; border-radius: 5px;
|
|
112
|
+
border: 1px solid var(--color-border); background: var(--color-surface);
|
|
113
|
+
color: var(--color-text-muted); cursor: pointer; transition: all 150ms;
|
|
114
|
+
white-space: nowrap;
|
|
115
|
+
}
|
|
116
|
+
.pingBtn:hover { background: var(--color-bg-hover); color: var(--color-text); border-color: var(--color-text-muted); }
|
|
117
|
+
.pingBtnActive {
|
|
118
|
+
background: var(--color-bg-hover) !important;
|
|
119
|
+
color: var(--ping-active-color, var(--color-accent)) !important;
|
|
120
|
+
border-color: var(--ping-active-color, var(--color-accent)) !important;
|
|
121
|
+
font-weight: 700;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* โโ Next ping countdown (TUI parity) โโ */
|
|
42
125
|
.nextPing {
|
|
43
126
|
display: flex; align-items: center; gap: 6px;
|
|
44
|
-
font-size:
|
|
127
|
+
font-size: 11px; font-family: var(--font-mono);
|
|
128
|
+
padding: 4px 8px;
|
|
129
|
+
border: 1px solid var(--color-border);
|
|
130
|
+
border-radius: 5px;
|
|
131
|
+
background: var(--color-surface);
|
|
45
132
|
}
|
|
46
133
|
.nextPingLabel {
|
|
47
|
-
font-size:
|
|
134
|
+
font-size: 10px; font-weight: 500; color: var(--color-text-muted);
|
|
48
135
|
text-transform: uppercase; letter-spacing: 0.5px;
|
|
49
136
|
}
|
|
50
137
|
.nextPingTime {
|
|
51
|
-
font-size:
|
|
138
|
+
font-size: 13px; font-weight: 700; color: var(--color-text);
|
|
52
139
|
min-width: 32px;
|
|
140
|
+
text-align: right;
|
|
141
|
+
font-variant-numeric: tabular-nums;
|
|
53
142
|
}
|
|
54
143
|
.pingingDot {
|
|
55
|
-
width:
|
|
144
|
+
width: 7px; height: 7px; border-radius: 50%;
|
|
56
145
|
background: #ff4466; animation: pulseDot 0.6s ease-in-out infinite;
|
|
57
|
-
|
|
58
|
-
.pingingText {
|
|
59
|
-
font-size: 12px; font-weight: 600; color: #ff4466;
|
|
60
|
-
font-family: var(--font-mono);
|
|
146
|
+
flex-shrink: 0;
|
|
61
147
|
}
|
|
62
148
|
|
|
63
149
|
@keyframes pulseDot {
|
|
@@ -65,50 +151,44 @@
|
|
|
65
151
|
50% { box-shadow: 0 0 0 6px transparent; }
|
|
66
152
|
}
|
|
67
153
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
padding: 4px 10px; border-radius: 6px;
|
|
73
|
-
border: 1px solid var(--color-border); background: var(--color-surface);
|
|
74
|
-
color: var(--color-text-muted); cursor: pointer; transition: all 150ms;
|
|
75
|
-
white-space: nowrap;
|
|
154
|
+
.live {
|
|
155
|
+
display: flex; align-items: center; gap: 5px;
|
|
156
|
+
font-size: 10px; font-weight: 700; color: var(--color-accent);
|
|
157
|
+
text-transform: uppercase; letter-spacing: 1px;
|
|
76
158
|
}
|
|
77
|
-
.
|
|
78
|
-
|
|
79
|
-
background: var(--color-
|
|
80
|
-
color: var(--ping-active-color, var(--color-accent)) !important;
|
|
81
|
-
border-color: var(--ping-active-color, var(--color-accent)) !important;
|
|
82
|
-
font-weight: 700;
|
|
159
|
+
.liveDot {
|
|
160
|
+
width: 7px; height: 7px; border-radius: 50%;
|
|
161
|
+
background: var(--color-accent); animation: pulseDot 2s ease-in-out infinite;
|
|
83
162
|
}
|
|
84
163
|
|
|
85
164
|
/* โโ Global benchmark progress bar (Ctrl+U) โโ */
|
|
86
165
|
.benchmarkBar {
|
|
87
|
-
display: flex; align-items: center; gap:
|
|
166
|
+
display: flex; align-items: center; gap: 10px;
|
|
88
167
|
background: rgba(180, 0, 255, 0.12);
|
|
89
168
|
border: 1px solid rgba(180, 0, 255, 0.4);
|
|
90
|
-
border-radius:
|
|
91
|
-
padding:
|
|
169
|
+
border-radius: 6px;
|
|
170
|
+
padding: 6px 12px;
|
|
92
171
|
flex-shrink: 0;
|
|
172
|
+
width: 100%;
|
|
173
|
+
margin-bottom: 4px;
|
|
93
174
|
}
|
|
94
175
|
.benchmarkLabel {
|
|
95
|
-
display: flex; align-items: center; gap:
|
|
96
|
-
font-size:
|
|
176
|
+
display: flex; align-items: center; gap: 5px;
|
|
177
|
+
font-size: 11px; font-weight: 700;
|
|
97
178
|
color: #b400ff; font-family: var(--font-mono);
|
|
98
179
|
white-space: nowrap;
|
|
99
180
|
}
|
|
100
181
|
.benchmarkSpinner {
|
|
101
182
|
display: inline-block;
|
|
102
183
|
animation: spinSp 0.8s linear infinite;
|
|
103
|
-
font-size:
|
|
104
|
-
}
|
|
105
|
-
.benchmarkCount {
|
|
106
|
-
font-size: 11px; opacity: 0.8;
|
|
184
|
+
font-size: 12px; line-height: 1;
|
|
107
185
|
}
|
|
186
|
+
.benchmarkCount { font-size: 10px; opacity: 0.8; }
|
|
108
187
|
.benchmarkTrack {
|
|
109
|
-
width: 120px; height:
|
|
188
|
+
width: 120px; height: 5px;
|
|
110
189
|
background: rgba(255,255,255,0.1);
|
|
111
190
|
border-radius: 3px; overflow: hidden;
|
|
191
|
+
flex: 1;
|
|
112
192
|
}
|
|
113
193
|
.benchmarkFill {
|
|
114
194
|
height: 100%;
|
|
@@ -117,12 +197,12 @@
|
|
|
117
197
|
transition: width 0.5s ease;
|
|
118
198
|
}
|
|
119
199
|
.benchmarkPct {
|
|
120
|
-
font-size:
|
|
200
|
+
font-size: 11px; font-weight: 700;
|
|
121
201
|
color: #b400ff; font-family: var(--font-mono);
|
|
122
|
-
min-width:
|
|
202
|
+
min-width: 32px; text-align: right;
|
|
123
203
|
}
|
|
124
204
|
|
|
125
205
|
@keyframes spinSp {
|
|
126
206
|
from { transform: rotate(0deg); }
|
|
127
207
|
to { transform: rotate(360deg); }
|
|
128
|
-
}
|
|
208
|
+
}
|
|
@@ -1,17 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file web/src/components/dashboard/ModelTable.jsx
|
|
3
3
|
* @description Main data table with ALL CLI columns powered by TanStack Table.
|
|
4
|
-
* ๐ Full CLI column parity: โ(mood) | # | Tier | SWE% | Ctx | Model | Provider | Last Ping | Avg | Health | Verdict | Stability | Up% | AI Lat. | TPS | Trend
|
|
4
|
+
* ๐ Full CLI column parity: โญ(fav) | โ(mood) | # | Tier | SWE% | Ctx | Model | Provider | Last Ping | Avg | Health | Verdict | Stability | Up% | AI Lat. | TPS | Trend
|
|
5
|
+
* ๐ M1 additions:
|
|
6
|
+
* - โญ/โ star button per row (TUI `F` key)
|
|
7
|
+
* - Clickable AI Lat. cell โ per-row benchmark (TUI `Ctrl+A`)
|
|
8
|
+
* - Dark-red row class for incompatible models when `toolMode` is set
|
|
9
|
+
* (TUI behavior; tool mode picker ships in M3, but the class is ready now)
|
|
5
10
|
* ๐ Clickable headers for sorting, medal rankings for top 3, horizontal scroll.
|
|
6
11
|
* ๐ Sorting is handled by useFilter hook which pushes null/Infinity values to bottom.
|
|
12
|
+
* ๐ Columns are user-resizable: drag the right edge of any header to resize.
|
|
13
|
+
* ๐ Custom widths persist in localStorage via the useColumnSizing hook and survive reloads.
|
|
14
|
+
* ๐ A "Reset columns" button appears in the toolbar only when the user has custom widths.
|
|
7
15
|
*/
|
|
8
|
-
import { useMemo } from 'react'
|
|
16
|
+
import { useMemo, useEffect } from 'react'
|
|
9
17
|
import {
|
|
10
18
|
useReactTable,
|
|
11
19
|
getCoreRowModel,
|
|
12
20
|
flexRender,
|
|
13
21
|
createColumnHelper,
|
|
14
22
|
} from '@tanstack/react-table'
|
|
23
|
+
import { IconStar, IconStarFilled, IconPlayerPlayFilled } from '@tabler/icons-react'
|
|
24
|
+
import { useColumnSizing } from '../../hooks/useColumnSizing.js'
|
|
15
25
|
|
|
16
26
|
import MoodCell from '../atoms/MoodCell.jsx'
|
|
17
27
|
import TierBadge from '../atoms/TierBadge.jsx'
|
|
@@ -23,9 +33,15 @@ import VerdictBadge from '../atoms/VerdictBadge.jsx'
|
|
|
23
33
|
import Sparkline from '../atoms/Sparkline.jsx'
|
|
24
34
|
import AILatencyCell from '../atoms/AILatencyCell.jsx'
|
|
25
35
|
import TPSCell from '../atoms/TPSCell.jsx'
|
|
36
|
+
import NoKeyIcon from '../atoms/NoKeyIcon.jsx'
|
|
37
|
+
import ProviderLogo from '../atoms/ProviderLogo.jsx'
|
|
26
38
|
|
|
27
39
|
import { pingClass } from '../../utils/format.js'
|
|
28
40
|
import { sweClass } from '../../utils/ranks.js'
|
|
41
|
+
// ๐ Pure-JS, browser-safe import of the TUI's tool compatibility helper. Same
|
|
42
|
+
// ๐ file the TUI uses; both surfaces share the source of truth for which
|
|
43
|
+
// ๐ providers are compatible with which tools.
|
|
44
|
+
import { getCompatibleTools } from '../../../../src/core/tool-metadata.js'
|
|
29
45
|
import styles from './ModelTable.module.css'
|
|
30
46
|
|
|
31
47
|
const colHelper = createColumnHelper()
|
|
@@ -46,13 +62,19 @@ function RankCellRenderer({ row }) {
|
|
|
46
62
|
|
|
47
63
|
function ModelCellRenderer({ row }) {
|
|
48
64
|
const m = row.original
|
|
65
|
+
const showNoKey = !m.hasApiKey && !m.cliOnly
|
|
49
66
|
return (
|
|
50
67
|
<div className={styles.modelCell}>
|
|
51
68
|
<StatusDot status={m.status} />
|
|
52
69
|
<div className={styles.modelMeta}>
|
|
53
70
|
<div className={styles.modelHeader}>
|
|
54
71
|
<span className={styles.modelName}>{m.label}</span>
|
|
55
|
-
{
|
|
72
|
+
{showNoKey && (
|
|
73
|
+
<NoKeyIcon
|
|
74
|
+
size={14}
|
|
75
|
+
title="No API key configured โ health will stay in 'NO KEY' state"
|
|
76
|
+
/>
|
|
77
|
+
)}
|
|
56
78
|
</div>
|
|
57
79
|
<div className={styles.modelId}>{m.modelId}</div>
|
|
58
80
|
</div>
|
|
@@ -71,7 +93,10 @@ function CtxCellRenderer({ row }) {
|
|
|
71
93
|
}
|
|
72
94
|
|
|
73
95
|
function ProviderCellRenderer({ row }) {
|
|
74
|
-
|
|
96
|
+
// ๐ Renders the provider as [icon + text-wordmark] on a single row-height
|
|
97
|
+
// ๐ line. Replaces the old bordered `.providerPill` text label. See
|
|
98
|
+
// ๐ atoms/ProviderLogo.jsx for the asset resolution + theme logic.
|
|
99
|
+
return <ProviderLogo providerKey={row.original.providerKey} origin={row.original.origin} />
|
|
75
100
|
}
|
|
76
101
|
|
|
77
102
|
function LastPingCellRenderer({ row }) {
|
|
@@ -112,7 +137,26 @@ function UptimeCellRenderer({ row }) {
|
|
|
112
137
|
|
|
113
138
|
function AILatencyCellRenderer({ row }) {
|
|
114
139
|
const m = row.original
|
|
115
|
-
|
|
140
|
+
const isRunning = Boolean(m.isBenchmarking)
|
|
141
|
+
return (
|
|
142
|
+
<div
|
|
143
|
+
className={`${styles.aiLatencyWrap} ${isRunning ? styles.aiLatencyRunning : ''}`}
|
|
144
|
+
onClick={(e) => {
|
|
145
|
+
// ๐ Click anywhere in the cell = fire per-row benchmark (TUI: Ctrl+A).
|
|
146
|
+
e.stopPropagation()
|
|
147
|
+
const handler = window.__fcmRowBenchmarkHandler
|
|
148
|
+
if (typeof handler === 'function') handler(m)
|
|
149
|
+
}}
|
|
150
|
+
title={isRunning ? 'Benchmarkingโฆ' : 'Click to run AI Speed Test (TUI: Ctrl+A)'}
|
|
151
|
+
>
|
|
152
|
+
<AILatencyCell result={m.benchmark || null} isRunning={isRunning} />
|
|
153
|
+
{!isRunning && (
|
|
154
|
+
<span className={styles.aiLatencyPlay} aria-hidden="true">
|
|
155
|
+
<IconPlayerPlayFilled size={10} stroke={1.5} />
|
|
156
|
+
</span>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
)
|
|
116
160
|
}
|
|
117
161
|
|
|
118
162
|
function TPSCellRenderer({ row }) {
|
|
@@ -125,7 +169,32 @@ function TrendCellRenderer({ row }) {
|
|
|
125
169
|
}
|
|
126
170
|
|
|
127
171
|
// โโโ Column definitions โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
128
|
-
|
|
172
|
+
// ๐ Note: `favorites` / `onBenchmarkRow` are read by the cell renderers below
|
|
173
|
+
// ๐ via closure. We rebuild the columns list whenever any of these change so
|
|
174
|
+
// ๐ the cells always see fresh values.
|
|
175
|
+
const buildColumns = ({ favorites, onBenchmarkRow, onSelectModel }) => [
|
|
176
|
+
// ๐ Star column โ leftmost, mirrors the TUI's `F` key.
|
|
177
|
+
colHelper.display({
|
|
178
|
+
id: 'fav',
|
|
179
|
+
header: 'โญ',
|
|
180
|
+
size: 32,
|
|
181
|
+
enableSorting: false,
|
|
182
|
+
cell: ({ row }) => {
|
|
183
|
+
const m = row.original
|
|
184
|
+
const isFav = favorites.isFavorite(m)
|
|
185
|
+
return (
|
|
186
|
+
<button
|
|
187
|
+
className={`${styles.favBtn} ${isFav ? styles.favBtnActive : ''}`}
|
|
188
|
+
onClick={(e) => { e.stopPropagation(); favorites.toggle(m) }}
|
|
189
|
+
title={isFav ? `Unfavorite ${m.label} (TUI: F)` : `Favorite ${m.label} (TUI: F)`}
|
|
190
|
+
aria-label={isFav ? `Unfavorite ${m.label}` : `Favorite ${m.label}`}
|
|
191
|
+
aria-pressed={isFav}
|
|
192
|
+
>
|
|
193
|
+
{isFav ? <IconStarFilled size={14} stroke={1.5} /> : <IconStar size={14} stroke={1.5} />}
|
|
194
|
+
</button>
|
|
195
|
+
)
|
|
196
|
+
},
|
|
197
|
+
}),
|
|
129
198
|
colHelper.display({
|
|
130
199
|
id: 'mood',
|
|
131
200
|
header: 'โ',
|
|
@@ -167,7 +236,11 @@ const columns = [
|
|
|
167
236
|
}),
|
|
168
237
|
colHelper.accessor('origin', {
|
|
169
238
|
header: 'Provider',
|
|
170
|
-
|
|
239
|
+
// ๐ 150px fits the widest wordmark (cloudflare ~145px) at 12px height,
|
|
240
|
+
// ๐ with a small buffer. Users can still drag the column wider if they
|
|
241
|
+
// ๐ want a larger render. The default before this was 110px, but the
|
|
242
|
+
// ๐ icon+wordmark layout needs more horizontal room.
|
|
243
|
+
size: 150,
|
|
171
244
|
enableSorting: true,
|
|
172
245
|
cell: ProviderCellRenderer,
|
|
173
246
|
}),
|
|
@@ -232,6 +305,21 @@ const columns = [
|
|
|
232
305
|
}),
|
|
233
306
|
]
|
|
234
307
|
|
|
308
|
+
// ๐ DEFAULT_COLUMN_SIZING: extracted from the columns above. This is the single
|
|
309
|
+
// ๐ source of truth for both the table layout and the useColumnSizing hook's reset target.
|
|
310
|
+
// ๐ The 'Reset columns' button restores these exact widths.
|
|
311
|
+
// ๐ We build a placeholder columns list (no callbacks needed) to extract sizes.
|
|
312
|
+
const PLACEHOLDER_COLUMNS = buildColumns({ favorites: { isFavorite: () => false, toggle: () => {} }, onBenchmarkRow: null, onSelectModel: null })
|
|
313
|
+
export const DEFAULT_COLUMN_SIZING = PLACEHOLDER_COLUMNS.reduce((acc, c) => {
|
|
314
|
+
if (c.id) acc[c.id] = c.size ?? 80
|
|
315
|
+
return acc
|
|
316
|
+
}, {})
|
|
317
|
+
|
|
318
|
+
// ๐ RESIZE_MIN/MAX bounds (px) โ used by the handle's drag preview and mirrored in
|
|
319
|
+
// ๐ the hook's clamp. Keep them in sync if you change one.
|
|
320
|
+
const COLUMN_RESIZE_MIN = 24
|
|
321
|
+
const COLUMN_RESIZE_MAX = 1200
|
|
322
|
+
|
|
235
323
|
// โโโ Sort icon component โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
236
324
|
function SortIcon({ column }) {
|
|
237
325
|
if (!column.getCanSort()) return null
|
|
@@ -241,56 +329,157 @@ function SortIcon({ column }) {
|
|
|
241
329
|
}
|
|
242
330
|
|
|
243
331
|
// โโโ Main component โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
244
|
-
export default function ModelTable({
|
|
332
|
+
export default function ModelTable({
|
|
333
|
+
filtered, onSelectModel, onBenchmarkRow, favorites,
|
|
334
|
+
sortColumn, sortDirection, onSort,
|
|
335
|
+
toolMode = null,
|
|
336
|
+
}) {
|
|
245
337
|
// Compute top3 for medal rows
|
|
246
338
|
const top3Ids = useMemo(() => {
|
|
247
339
|
const online = filtered.filter(m => m.status === 'up' && m.avg != null && m.avg !== Infinity && m.avg < 99000)
|
|
248
340
|
return new Set([...online].sort((a, b) => a.avg - b.avg).slice(0, 3).map(m => m.modelId))
|
|
249
341
|
}, [filtered])
|
|
250
342
|
|
|
343
|
+
// ๐ Wire the per-row benchmark handler through a window-level ref so the
|
|
344
|
+
// ๐ AI Lat. cell can call it without prop-drilling through TanStack's
|
|
345
|
+
// ๐ cell render context. Cleaner than threading `meta` through 17 columns.
|
|
346
|
+
useEffect(() => {
|
|
347
|
+
if (typeof onBenchmarkRow !== 'function') {
|
|
348
|
+
window.__fcmRowBenchmarkHandler = null
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
window.__fcmRowBenchmarkHandler = onBenchmarkRow
|
|
352
|
+
return () => {
|
|
353
|
+
if (window.__fcmRowBenchmarkHandler === onBenchmarkRow) {
|
|
354
|
+
window.__fcmRowBenchmarkHandler = null
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}, [onBenchmarkRow])
|
|
358
|
+
|
|
359
|
+
// ๐ Build the columns with the current favorites/onBenchmarkRow handlers.
|
|
360
|
+
// ๐ Re-derive when favorites reference changes so the star cells re-render.
|
|
361
|
+
const columns = useMemo(
|
|
362
|
+
() => buildColumns({ favorites, onBenchmarkRow, onSelectModel }),
|
|
363
|
+
[favorites, onBenchmarkRow, onSelectModel]
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
// ๐ Column sizing state with localStorage persistence (see useColumnSizing.js).
|
|
367
|
+
// ๐ onColumnSizingChange + state.columnSizing are TanStack's contract for resize.
|
|
368
|
+
const { columnSizing, setColumnSizing, resetColumnSizing, hasCustomSizing } =
|
|
369
|
+
useColumnSizing(DEFAULT_COLUMN_SIZING)
|
|
370
|
+
|
|
251
371
|
// TanStack Table โ no getSortedRowModel, sorting lives in useFilter
|
|
252
372
|
const table = useReactTable({
|
|
253
373
|
data: filtered,
|
|
254
374
|
columns,
|
|
255
375
|
defaultColumn: { enableSorting: true },
|
|
256
376
|
getCoreRowModel: getCoreRowModel(),
|
|
377
|
+
columnResizeMode: 'onChange',
|
|
378
|
+
state: { columnSizing },
|
|
379
|
+
onColumnSizingChange: setColumnSizing,
|
|
257
380
|
})
|
|
258
381
|
|
|
259
382
|
const rows = table.getRowModel().rows
|
|
260
383
|
|
|
384
|
+
// ๐ Total table width = sum of all column sizes. Used to drop the min-width
|
|
385
|
+
// ๐ override once the user has manually tuned the columns, so horizontal
|
|
386
|
+
// ๐ scrolling reflects the user's layout rather than the 1200px default.
|
|
387
|
+
const totalTableWidth = useMemo(
|
|
388
|
+
() => table.getAllColumns().reduce((sum, c) => sum + c.getSize(), 0),
|
|
389
|
+
// ๐ Re-derive when the sizing state object changes (a new object is created per change).
|
|
390
|
+
[columnSizing]
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
// ๐ Global cursor lock: when the user is dragging a resize handle, the cursor
|
|
394
|
+
// ๐ can leave the handle briefly. Adding a body class keeps the col-resize
|
|
395
|
+
// ๐ cursor visible AND blocks accidental text selection until the drag ends.
|
|
396
|
+
const isResizing = Boolean(table.getState().columnSizingInfo?.isResizingColumn)
|
|
397
|
+
useEffect(() => {
|
|
398
|
+
if (typeof document === 'undefined') return
|
|
399
|
+
if (isResizing) {
|
|
400
|
+
document.body.classList.add('col-resizing')
|
|
401
|
+
} else {
|
|
402
|
+
document.body.classList.remove('col-resizing')
|
|
403
|
+
}
|
|
404
|
+
return () => {
|
|
405
|
+
document.body.classList.remove('col-resizing')
|
|
406
|
+
}
|
|
407
|
+
}, [isResizing])
|
|
408
|
+
|
|
261
409
|
if (rows.length === 0) {
|
|
262
410
|
return <div className={styles.empty}>No models match your filters</div>
|
|
263
411
|
}
|
|
264
412
|
|
|
265
413
|
return (
|
|
266
414
|
<div className={styles.container}>
|
|
267
|
-
|
|
415
|
+
{hasCustomSizing && (
|
|
416
|
+
<div className={styles.resizeToolbar}>
|
|
417
|
+
<span className={styles.resizeToolbarHint}>
|
|
418
|
+
๐ Custom column widths ยท total <strong>{Math.round(totalTableWidth)}px</strong>
|
|
419
|
+
</span>
|
|
420
|
+
<button
|
|
421
|
+
type="button"
|
|
422
|
+
className={styles.resizeResetButton}
|
|
423
|
+
onClick={resetColumnSizing}
|
|
424
|
+
title="Restore the default column widths"
|
|
425
|
+
>
|
|
426
|
+
โบ Reset columns
|
|
427
|
+
</button>
|
|
428
|
+
</div>
|
|
429
|
+
)}
|
|
430
|
+
<table
|
|
431
|
+
className={styles.table}
|
|
432
|
+
style={hasCustomSizing ? { minWidth: `${totalTableWidth}px` } : undefined}
|
|
433
|
+
>
|
|
268
434
|
<thead>
|
|
269
435
|
{table.getHeaderGroups().map(headerGroup => (
|
|
270
436
|
<tr key={headerGroup.id}>
|
|
271
437
|
{headerGroup.headers.map(header => {
|
|
272
438
|
const col = header.column
|
|
273
439
|
const canSort = SORTABLE_COLUMN_IDS.has(col.id)
|
|
440
|
+
const isResizing = col.getIsResizing?.() === true
|
|
441
|
+
const sizePx = `${header.getSize()}px`
|
|
274
442
|
return (
|
|
275
443
|
<th
|
|
276
444
|
key={header.id}
|
|
277
445
|
className={styles.th}
|
|
278
446
|
onClick={canSort ? () => onSort(header.id) : undefined}
|
|
279
|
-
style={{ cursor: canSort ? 'pointer' : 'default' }}
|
|
447
|
+
style={{ width: sizePx, minWidth: sizePx, maxWidth: sizePx, cursor: canSort ? 'pointer' : 'default' }}
|
|
280
448
|
title={`Sort ${col.columnDef.header}: asc โ desc โ reset`}
|
|
281
449
|
>
|
|
282
|
-
{
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
450
|
+
<div className={styles.thInner}>
|
|
451
|
+
<span className={styles.thLabel}>
|
|
452
|
+
{flexRender(col.columnDef.header, header.getContext())}
|
|
453
|
+
{canSort && (
|
|
454
|
+
<span className={
|
|
455
|
+
sortColumn === col.id
|
|
456
|
+
? styles.sortIconActive
|
|
457
|
+
: styles.sortIcon
|
|
458
|
+
}>
|
|
459
|
+
{sortColumn === col.id
|
|
460
|
+
? (sortDirection === 'asc' ? 'โ' : 'โ')
|
|
461
|
+
: 'โ
'}
|
|
462
|
+
</span>
|
|
463
|
+
)}
|
|
292
464
|
</span>
|
|
293
|
-
|
|
465
|
+
{/* ๐ Resize handle: full-height grab zone on the right edge of every header.
|
|
466
|
+
๐ TanStack's column.getResizeHandler() wires mouse + touch to the
|
|
467
|
+
๐ table's columnSizing state via the onColumnSizingChange callback. */}
|
|
468
|
+
<span
|
|
469
|
+
className={`${styles.resizeHandle} ${isResizing ? styles.resizeHandleActive : ''}`}
|
|
470
|
+
onClick={(e) => e.stopPropagation()}
|
|
471
|
+
onMouseDown={header.getResizeHandler()}
|
|
472
|
+
onTouchStart={header.getResizeHandler()}
|
|
473
|
+
onDoubleClick={() => {
|
|
474
|
+
// ๐ Double-click resets just this column to its declared default width.
|
|
475
|
+
setColumnSizing((prev) => ({ ...prev, [col.id]: DEFAULT_COLUMN_SIZING[col.id] }))
|
|
476
|
+
}}
|
|
477
|
+
title={`Drag to resize ${col.columnDef.header} (double-click to reset this column)`}
|
|
478
|
+
role="separator"
|
|
479
|
+
aria-orientation="vertical"
|
|
480
|
+
aria-label={`Resize ${col.columnDef.header} column`}
|
|
481
|
+
/>
|
|
482
|
+
</div>
|
|
294
483
|
</th>
|
|
295
484
|
)
|
|
296
485
|
})}
|
|
@@ -301,22 +490,38 @@ export default function ModelTable({ filtered, onSelectModel, sortColumn, sortDi
|
|
|
301
490
|
{rows.map((row, i) => {
|
|
302
491
|
const m = row.original
|
|
303
492
|
const rankIdx = [...top3Ids].indexOf(m.modelId)
|
|
304
|
-
const
|
|
493
|
+
const rowClasses = []
|
|
494
|
+
if (rankIdx >= 0) rowClasses.push(styles[`rank${rankIdx + 1}`])
|
|
305
495
|
// ๐ Per-row benchmark state comes from the backend. The old global
|
|
306
496
|
// ๐ heuristic made every unbenchmarked row spin until the whole scan ended.
|
|
307
|
-
|
|
308
|
-
|
|
497
|
+
if (m.isBenchmarking) rowClasses.push(styles.benchRow)
|
|
498
|
+
// ๐ Tool-compat dark-red row when an active tool mode is set and the
|
|
499
|
+
// ๐ model isn't compatible with it. Mirrors the TUI render-table.js
|
|
500
|
+
// ๐ behavior. The picker itself ships in M3; for M1 the visual is wired.
|
|
501
|
+
if (toolMode) {
|
|
502
|
+
const compat = getCompatibleTools(m.providerKey)
|
|
503
|
+
if (!compat.includes(toolMode)) {
|
|
504
|
+
rowClasses.push(styles.incompatible)
|
|
505
|
+
}
|
|
506
|
+
}
|
|
309
507
|
return (
|
|
310
508
|
<tr
|
|
311
509
|
key={row.id}
|
|
312
|
-
className={
|
|
510
|
+
className={rowClasses.join(' ')}
|
|
313
511
|
onClick={() => onSelectModel(row.original)}
|
|
314
512
|
>
|
|
315
|
-
{row.getVisibleCells().map(cell =>
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
513
|
+
{row.getVisibleCells().map(cell => {
|
|
514
|
+
const sizePx = `${cell.column.getSize()}px`
|
|
515
|
+
return (
|
|
516
|
+
<td
|
|
517
|
+
key={cell.id}
|
|
518
|
+
className={styles.td}
|
|
519
|
+
style={{ width: sizePx, minWidth: sizePx, maxWidth: sizePx }}
|
|
520
|
+
>
|
|
521
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
522
|
+
</td>
|
|
523
|
+
)
|
|
524
|
+
})}
|
|
320
525
|
</tr>
|
|
321
526
|
)
|
|
322
527
|
})}
|