free-coding-models 0.5.0 → 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.
Files changed (145) hide show
  1. package/README.md +40 -6
  2. package/bin/free-coding-models.js +10 -0
  3. package/changelog/v0.5.1.md +24 -0
  4. package/changelog/v0.5.4.md +24 -0
  5. package/package.json +11 -3
  6. package/sources.js +2 -0
  7. package/src/core/router-daemon.js +166 -1
  8. package/src/core/utils.js +2 -0
  9. package/src/tui/cli-help.js +2 -0
  10. package/src/tui/key-handler.js +1 -1
  11. package/src/tui/render-table.js +1 -1
  12. package/web/README.md +8 -5
  13. package/web/assets/providers/aistudio/aistudio-text.svg +1 -0
  14. package/web/assets/providers/aistudio/aistudio.svg +1 -0
  15. package/web/assets/providers/cerebras/cerebras-brand-color.svg +1 -0
  16. package/web/assets/providers/cerebras/cerebras-brand.svg +1 -0
  17. package/web/assets/providers/cerebras/cerebras-color.svg +1 -0
  18. package/web/assets/providers/cerebras/cerebras-text.svg +1 -0
  19. package/web/assets/providers/cerebras/cerebras.svg +1 -0
  20. package/web/assets/providers/cloudflare/cloudflare-color.svg +1 -0
  21. package/web/assets/providers/cloudflare/cloudflare-text.svg +1 -0
  22. package/web/assets/providers/cloudflare/cloudflare.svg +1 -0
  23. package/web/assets/providers/gemini/gemini-color.svg +1 -0
  24. package/web/assets/providers/gemini/gemini-text.svg +1 -0
  25. package/web/assets/providers/gemini/gemini.svg +1 -0
  26. package/web/assets/providers/github/github-text.svg +1 -0
  27. package/web/assets/providers/github/github.svg +1 -0
  28. package/web/assets/providers/groq/groq-text.svg +1 -0
  29. package/web/assets/providers/groq/groq.svg +1 -0
  30. package/web/assets/providers/mistral/mistral-color.svg +1 -0
  31. package/web/assets/providers/mistral/mistral-text.svg +1 -0
  32. package/web/assets/providers/mistral/mistral.svg +1 -0
  33. package/web/assets/providers/nvidia/nvidia-color.svg +1 -0
  34. package/web/assets/providers/nvidia/nvidia-text.svg +1 -0
  35. package/web/assets/providers/nvidia/nvidia.svg +1 -0
  36. package/web/assets/providers/opencode/opencode-text.svg +1 -0
  37. package/web/assets/providers/opencode/opencode.svg +1 -0
  38. package/web/assets/providers/openrouter/openrouter (1).svg +1 -0
  39. package/web/assets/providers/openrouter/openrouter-text.svg +1 -0
  40. package/web/assets/providers/openrouter/openrouter.svg +1 -0
  41. package/web/assets/providers/ovhcloud/ovhcloud.svg +12 -0
  42. package/web/assets/providers/qwen/qwen-color.svg +1 -0
  43. package/web/assets/providers/qwen/qwen-text.svg +1 -0
  44. package/web/assets/providers/qwen/qwen.svg +1 -0
  45. package/web/assets/providers/sambanova/sambanova-color.svg +1 -0
  46. package/web/assets/providers/sambanova/sambanova-text.svg +1 -0
  47. package/web/assets/providers/sambanova/sambanova.svg +1 -0
  48. package/web/assets/providers/scalewaylogo/ScalewayLogo.svg +13 -0
  49. package/web/assets/providers/zai/zai-text.svg +1 -0
  50. package/web/assets/providers/zai/zai.svg +1 -0
  51. package/web/dist/assets/index-BoWmUveV.js +39 -0
  52. package/web/dist/assets/index-BrpHevg4.css +1 -0
  53. package/web/dist/favicon.ico +0 -0
  54. package/web/dist/favicons/apple-touch-icon.png +0 -0
  55. package/web/dist/favicons/browserconfig.xml +12 -0
  56. package/web/dist/favicons/favicon-16x16.png +0 -0
  57. package/web/dist/favicons/favicon-192x192.png +0 -0
  58. package/web/dist/favicons/favicon-32x32.png +0 -0
  59. package/web/dist/favicons/favicon-48x48.png +0 -0
  60. package/web/dist/favicons/favicon-512x512.png +0 -0
  61. package/web/dist/favicons/favicon-96x96.png +0 -0
  62. package/web/dist/favicons/favicon.ico +0 -0
  63. package/web/dist/favicons/mstile-150x150.png +0 -0
  64. package/web/dist/favicons/mstile-310x150.png +0 -0
  65. package/web/dist/favicons/mstile-310x310.png +0 -0
  66. package/web/dist/favicons/mstile-512x512.png +0 -0
  67. package/web/dist/favicons/mstile-70x70.png +0 -0
  68. package/web/dist/favicons/site.webmanifest +25 -0
  69. package/web/dist/index.html +42 -6
  70. package/web/index.html +40 -4
  71. package/web/package.json +11 -0
  72. package/web/public/favicon.ico +0 -0
  73. package/web/public/favicons/apple-touch-icon.png +0 -0
  74. package/web/public/favicons/browserconfig.xml +12 -0
  75. package/web/public/favicons/favicon-16x16.png +0 -0
  76. package/web/public/favicons/favicon-192x192.png +0 -0
  77. package/web/public/favicons/favicon-32x32.png +0 -0
  78. package/web/public/favicons/favicon-48x48.png +0 -0
  79. package/web/public/favicons/favicon-512x512.png +0 -0
  80. package/web/public/favicons/favicon-96x96.png +0 -0
  81. package/web/public/favicons/favicon.ico +0 -0
  82. package/web/public/favicons/mstile-150x150.png +0 -0
  83. package/web/public/favicons/mstile-310x150.png +0 -0
  84. package/web/public/favicons/mstile-310x310.png +0 -0
  85. package/web/public/favicons/mstile-512x512.png +0 -0
  86. package/web/public/favicons/mstile-70x70.png +0 -0
  87. package/web/public/favicons/site.webmanifest +25 -0
  88. package/web/server.js +676 -211
  89. package/web/src/App.jsx +233 -71
  90. package/web/src/components/analytics/AnalyticsView.jsx +40 -6
  91. package/web/src/components/analytics/AnalyticsView.module.css +4 -0
  92. package/web/src/components/atoms/AILatencyCell.jsx +38 -0
  93. package/web/src/components/atoms/AILatencyCell.module.css +51 -0
  94. package/web/src/components/atoms/HealthCell.jsx +138 -0
  95. package/web/src/components/atoms/HealthCell.module.css +56 -0
  96. package/web/src/components/atoms/LastPingCell.jsx +35 -0
  97. package/web/src/components/atoms/LastPingCell.module.css +45 -0
  98. package/web/src/components/atoms/MoodCell.jsx +25 -0
  99. package/web/src/components/atoms/MoodCell.module.css +6 -0
  100. package/web/src/components/atoms/NoKeyIcon.jsx +65 -0
  101. package/web/src/components/atoms/NoKeyIcon.module.css +30 -0
  102. package/web/src/components/atoms/ProviderLogo.jsx +188 -0
  103. package/web/src/components/atoms/ProviderLogo.module.css +103 -0
  104. package/web/src/components/atoms/RankCell.jsx +9 -0
  105. package/web/src/components/atoms/RankCell.module.css +9 -0
  106. package/web/src/components/atoms/Sparkline.jsx +21 -2
  107. package/web/src/components/atoms/StabilityCell.module.css +5 -0
  108. package/web/src/components/atoms/StatusDot.module.css +7 -0
  109. package/web/src/components/atoms/TPSCell.jsx +36 -0
  110. package/web/src/components/atoms/TPSCell.module.css +44 -0
  111. package/web/src/components/atoms/TierBadge.module.css +11 -0
  112. package/web/src/components/atoms/Toast.module.css +6 -0
  113. package/web/src/components/atoms/VerdictBadge.jsx +30 -7
  114. package/web/src/components/atoms/VerdictBadge.module.css +36 -15
  115. package/web/src/components/dashboard/DetailPanel.jsx +63 -2
  116. package/web/src/components/dashboard/DetailPanel.module.css +130 -0
  117. package/web/src/components/dashboard/ExportModal.jsx +9 -4
  118. package/web/src/components/dashboard/FilterBar.jsx +228 -33
  119. package/web/src/components/dashboard/FilterBar.module.css +187 -22
  120. package/web/src/components/dashboard/ModelTable.jsx +500 -54
  121. package/web/src/components/dashboard/ModelTable.module.css +318 -31
  122. package/web/src/components/layout/Footer.jsx +1 -1
  123. package/web/src/components/layout/Header.jsx +171 -9
  124. package/web/src/components/layout/Header.module.css +223 -25
  125. package/web/src/components/palette/CommandPalette.jsx +146 -0
  126. package/web/src/components/palette/CommandPalette.module.css +121 -0
  127. package/web/src/components/settings/SettingsView.jsx +24 -6
  128. package/web/src/components/settings/SettingsView.module.css +0 -1
  129. package/web/src/global.css +118 -84
  130. package/web/src/hooks/useColumnSizing.js +211 -0
  131. package/web/src/hooks/useFavorites.js +150 -0
  132. package/web/src/hooks/useFilter.js +262 -37
  133. package/web/src/hooks/useSSE.js +33 -9
  134. package/web/src/hooks/useSocket.js +200 -0
  135. package/web/src/hooks/useTheme.js +72 -16
  136. package/web/src/hooks/useUrlState.js +93 -0
  137. package/web/vite.config.js +41 -0
  138. package/web/dist/assets/index-CGN-0_A0.css +0 -1
  139. package/web/dist/assets/index-CvMUM9Jr.js +0 -11
  140. package/web/src/components/dashboard/StatsBar.jsx +0 -40
  141. package/web/src/components/dashboard/StatsBar.module.css +0 -28
  142. package/web/src/components/layout/Sidebar.jsx +0 -42
  143. package/web/src/components/layout/Sidebar.module.css +0 -76
  144. package/web/src/components/map/MapView.jsx +0 -17
  145. package/web/src/components/map/MapView.module.css +0 -25
@@ -1,81 +1,527 @@
1
1
  /**
2
2
  * @file web/src/components/dashboard/ModelTable.jsx
3
- * @description Main data table with medal rankings for top 3 fastest models.
3
+ * @description Main data table with ALL CLI columns powered by TanStack Table.
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)
10
+ * 📖 Clickable headers for sorting, medal rankings for top 3, horizontal scroll.
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.
4
15
  */
5
- import { useMemo } from 'react'
16
+ import { useMemo, useEffect } from 'react'
17
+ import {
18
+ useReactTable,
19
+ getCoreRowModel,
20
+ flexRender,
21
+ createColumnHelper,
22
+ } from '@tanstack/react-table'
23
+ import { IconStar, IconStarFilled, IconPlayerPlayFilled } from '@tabler/icons-react'
24
+ import { useColumnSizing } from '../../hooks/useColumnSizing.js'
25
+
26
+ import MoodCell from '../atoms/MoodCell.jsx'
6
27
  import TierBadge from '../atoms/TierBadge.jsx'
7
28
  import StatusDot from '../atoms/StatusDot.jsx'
8
- import VerdictBadge from '../atoms/VerdictBadge.jsx'
29
+ import LastPingCell from '../atoms/LastPingCell.jsx'
30
+ import HealthCell from '../atoms/HealthCell.jsx'
9
31
  import StabilityCell from '../atoms/StabilityCell.jsx'
32
+ import VerdictBadge from '../atoms/VerdictBadge.jsx'
10
33
  import Sparkline from '../atoms/Sparkline.jsx'
34
+ import AILatencyCell from '../atoms/AILatencyCell.jsx'
35
+ import TPSCell from '../atoms/TPSCell.jsx'
36
+ import NoKeyIcon from '../atoms/NoKeyIcon.jsx'
37
+ import ProviderLogo from '../atoms/ProviderLogo.jsx'
38
+
11
39
  import { pingClass } from '../../utils/format.js'
12
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'
13
45
  import styles from './ModelTable.module.css'
14
46
 
15
- export default function ModelTable({ filtered, onSelectModel }) {
47
+ const colHelper = createColumnHelper()
48
+
49
+ const SORTABLE_COLUMN_IDS = new Set([
50
+ 'mood', 'idx', 'tier', 'sweScore', 'ctx', 'label', 'origin', 'latestPing',
51
+ 'avg', 'condition', 'verdict', 'stability', 'uptime', 'aiLatency', 'tps', 'trend',
52
+ ])
53
+
54
+ // ─── Cell renderers ───────────────────────────────────────────────────────────
55
+ function MoodCellRenderer({ row }) {
56
+ return <MoodCell verdict={row.original.verdict} />
57
+ }
58
+
59
+ function RankCellRenderer({ row }) {
60
+ return <span className={styles.rankNum}>{row.original.idx ?? row.index + 1}</span>
61
+ }
62
+
63
+ function ModelCellRenderer({ row }) {
64
+ const m = row.original
65
+ const showNoKey = !m.hasApiKey && !m.cliOnly
66
+ return (
67
+ <div className={styles.modelCell}>
68
+ <StatusDot status={m.status} />
69
+ <div className={styles.modelMeta}>
70
+ <div className={styles.modelHeader}>
71
+ <span className={styles.modelName}>{m.label}</span>
72
+ {showNoKey && (
73
+ <NoKeyIcon
74
+ size={14}
75
+ title="No API key configured — health will stay in 'NO KEY' state"
76
+ />
77
+ )}
78
+ </div>
79
+ <div className={styles.modelId}>{m.modelId}</div>
80
+ </div>
81
+ </div>
82
+ )
83
+ }
84
+
85
+ function SWECellRenderer({ row }) {
86
+ const m = row.original
87
+ const cls = sweClass(m.sweScore)
88
+ return <span className={`${styles.swe} ${styles[cls]}`}>{m.sweScore || '—'}</span>
89
+ }
90
+
91
+ function CtxCellRenderer({ row }) {
92
+ return <span className={styles.ctx}>{row.original.ctx || '—'}</span>
93
+ }
94
+
95
+ function ProviderCellRenderer({ row }) {
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} />
100
+ }
101
+
102
+ function LastPingCellRenderer({ row }) {
103
+ const m = row.original
104
+ const hist = m.pingHistory || m.pings || []
105
+ const latest = hist.length > 0 ? hist[hist.length - 1] : null
106
+ return <LastPingCell ms={latest?.ms ?? null} isPinging={Boolean(m.isPinging)} />
107
+ }
108
+
109
+ function AvgPingCellRenderer({ row }) {
110
+ const m = row.original
111
+ const cls = pingClass(m.avg)
112
+ return (
113
+ <span className={`${styles.ping} ${styles[cls]}`}>
114
+ {m.avg == null || m.avg === Infinity || m.avg > 99000 ? '—' : m.avg}
115
+ </span>
116
+ )
117
+ }
118
+
119
+ function HealthCellRenderer({ row }) {
120
+ const m = row.original
121
+ return <HealthCell status={m.status} httpCode={m.httpCode} />
122
+ }
123
+
124
+ function VerdictCellRenderer({ row }) {
125
+ const m = row.original
126
+ return <VerdictBadge verdict={m.verdict} httpCode={m.httpCode} />
127
+ }
128
+
129
+ function StabilityCellRenderer({ row }) {
130
+ return <StabilityCell score={row.original.stability} />
131
+ }
132
+
133
+ function UptimeCellRenderer({ row }) {
134
+ const m = row.original
135
+ return <span className={styles.uptime}>{m.uptime > 0 ? `${m.uptime}%` : '—'}</span>
136
+ }
137
+
138
+ function AILatencyCellRenderer({ row }) {
139
+ const m = row.original
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
+ )
160
+ }
161
+
162
+ function TPSCellRenderer({ row }) {
163
+ const m = row.original
164
+ return <TPSCell result={m.benchmark || null} isRunning={Boolean(m.isBenchmarking)} />
165
+ }
166
+
167
+ function TrendCellRenderer({ row }) {
168
+ return <Sparkline history={row.original.pingHistory} />
169
+ }
170
+
171
+ // ─── Column definitions ──────────────────────────────────────────────────────
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
+ }),
198
+ colHelper.display({
199
+ id: 'mood',
200
+ header: '❔',
201
+ size: 28,
202
+ cell: MoodCellRenderer,
203
+ enableSorting: true,
204
+ }),
205
+ colHelper.accessor('idx', {
206
+ header: '#',
207
+ size: 36,
208
+ enableSorting: true,
209
+ cell: ({ getValue, row }) => (
210
+ <span className={styles.rankNum}>{getValue() ?? row.index + 1}</span>
211
+ ),
212
+ }),
213
+ colHelper.accessor('tier', {
214
+ header: 'Tier',
215
+ size: 48,
216
+ enableSorting: true,
217
+ cell: ({ getValue }) => <TierBadge tier={getValue()} />,
218
+ }),
219
+ colHelper.accessor('sweScore', {
220
+ header: 'SWE%',
221
+ size: 52,
222
+ enableSorting: true,
223
+ cell: SWECellRenderer,
224
+ }),
225
+ colHelper.accessor('ctx', {
226
+ header: 'CTX',
227
+ size: 48,
228
+ enableSorting: true,
229
+ cell: CtxCellRenderer,
230
+ }),
231
+ colHelper.accessor('label', {
232
+ header: 'Model',
233
+ size: 200,
234
+ enableSorting: true,
235
+ cell: ModelCellRenderer,
236
+ }),
237
+ colHelper.accessor('origin', {
238
+ header: 'Provider',
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,
244
+ enableSorting: true,
245
+ cell: ProviderCellRenderer,
246
+ }),
247
+ colHelper.display({
248
+ id: 'latestPing',
249
+ header: 'Last Ping',
250
+ size: 80,
251
+ cell: LastPingCellRenderer,
252
+ enableSorting: true,
253
+ }),
254
+ colHelper.accessor('avg', {
255
+ header: 'Avg',
256
+ size: 72,
257
+ enableSorting: true,
258
+ cell: AvgPingCellRenderer,
259
+ }),
260
+ colHelper.accessor('status', {
261
+ id: 'condition',
262
+ header: 'Health',
263
+ size: 120,
264
+ enableSorting: true,
265
+ cell: HealthCellRenderer,
266
+ }),
267
+ colHelper.accessor('verdict', {
268
+ header: 'Verdict',
269
+ size: 100,
270
+ enableSorting: true,
271
+ cell: VerdictCellRenderer,
272
+ }),
273
+ colHelper.accessor('stability', {
274
+ header: 'Stability',
275
+ size: 90,
276
+ enableSorting: true,
277
+ cell: StabilityCellRenderer,
278
+ }),
279
+ colHelper.accessor('uptime', {
280
+ header: 'Up%',
281
+ size: 48,
282
+ enableSorting: true,
283
+ cell: UptimeCellRenderer,
284
+ }),
285
+ colHelper.display({
286
+ id: 'aiLatency',
287
+ header: 'AI Lat.',
288
+ size: 80,
289
+ cell: AILatencyCellRenderer,
290
+ enableSorting: true,
291
+ }),
292
+ colHelper.display({
293
+ id: 'tps',
294
+ header: 'TPS',
295
+ size: 48,
296
+ cell: TPSCellRenderer,
297
+ enableSorting: true,
298
+ }),
299
+ colHelper.display({
300
+ id: 'trend',
301
+ header: 'Trend',
302
+ size: 96,
303
+ cell: TrendCellRenderer,
304
+ enableSorting: true,
305
+ }),
306
+ ]
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
+
323
+ // ─── Sort icon component ────────────────────────────────────────────────────
324
+ function SortIcon({ column }) {
325
+ if (!column.getCanSort()) return null
326
+ const sorted = column.getIsSorted()
327
+ if (!sorted) return <span className={styles.sortIcon}>⇅</span>
328
+ return <span className={styles.sortIconActive}>{sorted === 'asc' ? '↑' : '↓'}</span>
329
+ }
330
+
331
+ // ─── Main component ───────────────────────────────────────────────────────────
332
+ export default function ModelTable({
333
+ filtered, onSelectModel, onBenchmarkRow, favorites,
334
+ sortColumn, sortDirection, onSort,
335
+ toolMode = null,
336
+ }) {
337
+ // Compute top3 for medal rows
16
338
  const top3Ids = useMemo(() => {
17
- const online = filtered.filter(m => m.status === 'up' && m.avg !== Infinity)
339
+ const online = filtered.filter(m => m.status === 'up' && m.avg != null && m.avg !== Infinity && m.avg < 99000)
18
340
  return new Set([...online].sort((a, b) => a.avg - b.avg).slice(0, 3).map(m => m.modelId))
19
341
  }, [filtered])
20
342
 
21
- const top3Arr = useMemo(() => {
22
- const online = filtered.filter(m => m.status === 'up' && m.avg !== Infinity)
23
- return [...online].sort((a, b) => a.avg - b.avg).slice(0, 3).map(m => m.modelId)
24
- }, [filtered])
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
+ )
25
365
 
26
- if (filtered.length === 0) {
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
+
371
+ // TanStack Table — no getSortedRowModel, sorting lives in useFilter
372
+ const table = useReactTable({
373
+ data: filtered,
374
+ columns,
375
+ defaultColumn: { enableSorting: true },
376
+ getCoreRowModel: getCoreRowModel(),
377
+ columnResizeMode: 'onChange',
378
+ state: { columnSizing },
379
+ onColumnSizingChange: setColumnSizing,
380
+ })
381
+
382
+ const rows = table.getRowModel().rows
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
+
409
+ if (rows.length === 0) {
27
410
  return <div className={styles.empty}>No models match your filters</div>
28
411
  }
29
412
 
30
413
  return (
31
414
  <div className={styles.container}>
32
- <table className={styles.table}>
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
+ >
33
434
  <thead>
34
- <tr>
35
- <th className={styles.th}>#</th>
36
- <th className={styles.th}>Tier</th>
37
- <th className={styles.th}>Model</th>
38
- <th className={styles.th}>Provider</th>
39
- <th className={styles.th}>SWE %</th>
40
- <th className={styles.th}>Ctx</th>
41
- <th className={styles.th}>Ping</th>
42
- <th className={styles.th}>Avg</th>
43
- <th className={styles.th}>Stability</th>
44
- <th className={styles.th}>Verdict</th>
45
- <th className={styles.th}>Uptime</th>
46
- <th className={styles.th}>Trend</th>
47
- </tr>
435
+ {table.getHeaderGroups().map(headerGroup => (
436
+ <tr key={headerGroup.id}>
437
+ {headerGroup.headers.map(header => {
438
+ const col = header.column
439
+ const canSort = SORTABLE_COLUMN_IDS.has(col.id)
440
+ const isResizing = col.getIsResizing?.() === true
441
+ const sizePx = `${header.getSize()}px`
442
+ return (
443
+ <th
444
+ key={header.id}
445
+ className={styles.th}
446
+ onClick={canSort ? () => onSort(header.id) : undefined}
447
+ style={{ width: sizePx, minWidth: sizePx, maxWidth: sizePx, cursor: canSort ? 'pointer' : 'default' }}
448
+ title={`Sort ${col.columnDef.header}: asc → desc → reset`}
449
+ >
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
+ )}
464
+ </span>
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>
483
+ </th>
484
+ )
485
+ })}
486
+ </tr>
487
+ ))}
48
488
  </thead>
49
489
  <tbody>
50
- {filtered.map((m, i) => {
51
- const rankIdx = top3Arr.indexOf(m.modelId)
52
- const medal = rankIdx === 0 ? '🥇' : rankIdx === 1 ? '🥈' : rankIdx === 2 ? '🥉' : ''
53
- const rowCls = rankIdx >= 0 ? styles[`rank${rankIdx + 1}`] : ''
490
+ {rows.map((row, i) => {
491
+ const m = row.original
492
+ const rankIdx = [...top3Ids].indexOf(m.modelId)
493
+ const rowClasses = []
494
+ if (rankIdx >= 0) rowClasses.push(styles[`rank${rankIdx + 1}`])
495
+ // 📖 Per-row benchmark state comes from the backend. The old global
496
+ // 📖 heuristic made every unbenchmarked row spin until the whole scan ended.
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
+ }
54
507
  return (
55
- <tr key={m.modelId} className={rowCls} onClick={() => onSelectModel(m)}>
56
- <td className={styles.tdRank}>{medal || (i + 1)}</td>
57
- <td><TierBadge tier={m.tier} /></td>
58
- <td>
59
- <div className={styles.modelCell}>
60
- <StatusDot status={m.status} />
61
- <span className={styles.modelName}>{m.label}</span>
62
- {!m.hasApiKey && !m.cliOnly && <span className={styles.noKey}>🔑 NO KEY</span>}
63
- <div className={styles.modelId}>{m.modelId}</div>
64
- </div>
65
- </td>
66
- <td><span className={styles.providerPill}>{m.origin}</span></td>
67
- <td className={`${styles.swe} ${styles[sweClass(m.sweScore)]}`}>{m.sweScore || '—'}</td>
68
- <td className={styles.ctx}>{m.ctx || '—'}</td>
69
- <td className={`${styles.ping} ${styles[pingClass(m.latestPing)]}`}>
70
- {m.latestPing == null ? '—' : m.latestCode === '429' ? '429' : m.latestCode === '000' ? 'TIMEOUT' : `${m.latestPing}ms`}
71
- </td>
72
- <td className={`${styles.ping} ${styles[pingClass(m.avg)]}`}>
73
- {m.avg == null || m.avg === Infinity || m.avg > 99000 ? '—' : `${m.avg}ms`}
74
- </td>
75
- <td><StabilityCell score={m.stability} /></td>
76
- <td><VerdictBadge verdict={m.verdict} httpCode={m.httpCode} /></td>
77
- <td className={styles.uptime}>{m.uptime > 0 ? `${m.uptime}%` : '—'}</td>
78
- <td><Sparkline history={m.pingHistory} /></td>
508
+ <tr
509
+ key={row.id}
510
+ className={rowClasses.join(' ')}
511
+ onClick={() => onSelectModel(row.original)}
512
+ >
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
+ })}
79
525
  </tr>
80
526
  )
81
527
  })}
@@ -83,4 +529,4 @@ export default function ModelTable({ filtered, onSelectModel }) {
83
529
  </table>
84
530
  </div>
85
531
  )
86
- }
532
+ }