free-coding-models 0.5.86 → 0.5.88

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 (165) hide show
  1. package/README.md +109 -888
  2. package/bin/free-coding-models.js +4 -1
  3. package/changelog/v0.5.87.md +48 -0
  4. package/changelog/v0.5.88.md +16 -0
  5. package/package.json +13 -5
  6. package/sources.js +4 -4
  7. package/src/core/cache.js +3 -1
  8. package/src/core/config.js +43 -5
  9. package/src/core/endpoint-installer.js +23 -12
  10. package/src/core/installed-models-manager.js +43 -20
  11. package/src/core/kilo-config.js +51 -5
  12. package/src/core/kilo.js +5 -3
  13. package/src/core/legacy-proxy-cleanup.js +22 -5
  14. package/src/core/model-merger.js +4 -0
  15. package/src/core/models-drift.js +9 -0
  16. package/src/core/opencode-config.js +57 -5
  17. package/src/core/opencode.js +67 -41
  18. package/src/core/playground.js +7 -0
  19. package/src/core/probe-cache.js +35 -4
  20. package/src/core/provider-key-tester.js +10 -2
  21. package/src/core/provider-metadata.js +10 -0
  22. package/src/core/provider-quota-fetchers.js +14 -7
  23. package/src/core/router-daemon.js +208 -26
  24. package/src/core/runtime-telemetry.js +27 -1
  25. package/src/core/security.js +170 -24
  26. package/src/core/shared-helpers.js +12 -1
  27. package/src/core/shell-env.js +3 -1
  28. package/src/core/telemetry.js +18 -5
  29. package/src/core/tool-launchers.js +9 -6
  30. package/src/core/updater.js +28 -2
  31. package/src/core/utils.js +173 -71
  32. package/src/tui/app.js +38 -14
  33. package/src/tui/command-palette.js +22 -2
  34. package/src/tui/key-handler.js +123 -179
  35. package/src/tui/overlays.js +21 -71
  36. package/src/tui/render-helpers.js +62 -4
  37. package/src/tui/render-table.js +34 -3
  38. package/src/tui/theme.js +36 -5
  39. package/src/tui/tui-filters.js +28 -8
  40. package/src/tui/tui-state.js +22 -6
  41. package/web/dist/assets/index-CAzFIt8P.css +1 -0
  42. package/web/dist/assets/index-DFg1h0Nd.js +44 -0
  43. package/web/dist/index.html +5 -9
  44. package/web/index.html +3 -7
  45. package/web/server.js +97 -23
  46. package/web/vite.config.js +8 -0
  47. package/web/app.legacy.js +0 -900
  48. package/web/dist/assets/index-C5-Ywvv3.css +0 -1
  49. package/web/dist/assets/index-Dm-Xfby9.js +0 -40
  50. package/web/public/favicon.ico +0 -0
  51. package/web/public/favicons/apple-touch-icon.png +0 -0
  52. package/web/public/favicons/browserconfig.xml +0 -12
  53. package/web/public/favicons/favicon-16x16.png +0 -0
  54. package/web/public/favicons/favicon-192x192.png +0 -0
  55. package/web/public/favicons/favicon-32x32.png +0 -0
  56. package/web/public/favicons/favicon-48x48.png +0 -0
  57. package/web/public/favicons/favicon-512x512.png +0 -0
  58. package/web/public/favicons/favicon-96x96.png +0 -0
  59. package/web/public/favicons/favicon.ico +0 -0
  60. package/web/public/favicons/mstile-150x150.png +0 -0
  61. package/web/public/favicons/mstile-310x150.png +0 -0
  62. package/web/public/favicons/mstile-310x310.png +0 -0
  63. package/web/public/favicons/mstile-512x512.png +0 -0
  64. package/web/public/favicons/mstile-70x70.png +0 -0
  65. package/web/public/favicons/site.webmanifest +0 -25
  66. package/web/src/App.jsx +0 -521
  67. package/web/src/components/analytics/AnalyticsView.jsx +0 -147
  68. package/web/src/components/analytics/AnalyticsView.module.css +0 -190
  69. package/web/src/components/analytics/TokenUsagePanel.jsx +0 -105
  70. package/web/src/components/analytics/TokenUsagePanel.module.css +0 -126
  71. package/web/src/components/atoms/AILatencyCell.jsx +0 -38
  72. package/web/src/components/atoms/AILatencyCell.module.css +0 -51
  73. package/web/src/components/atoms/HealthCell.jsx +0 -143
  74. package/web/src/components/atoms/HealthCell.module.css +0 -60
  75. package/web/src/components/atoms/LastPingCell.jsx +0 -35
  76. package/web/src/components/atoms/LastPingCell.module.css +0 -45
  77. package/web/src/components/atoms/MoodCell.jsx +0 -25
  78. package/web/src/components/atoms/MoodCell.module.css +0 -6
  79. package/web/src/components/atoms/NoKeyIcon.jsx +0 -65
  80. package/web/src/components/atoms/NoKeyIcon.module.css +0 -30
  81. package/web/src/components/atoms/ProviderLogo.jsx +0 -187
  82. package/web/src/components/atoms/ProviderLogo.module.css +0 -103
  83. package/web/src/components/atoms/RankCell.jsx +0 -9
  84. package/web/src/components/atoms/RankCell.module.css +0 -9
  85. package/web/src/components/atoms/Sparkline.jsx +0 -63
  86. package/web/src/components/atoms/StabilityCell.jsx +0 -18
  87. package/web/src/components/atoms/StabilityCell.module.css +0 -13
  88. package/web/src/components/atoms/StatusDot.jsx +0 -13
  89. package/web/src/components/atoms/StatusDot.module.css +0 -29
  90. package/web/src/components/atoms/TPSCell.jsx +0 -36
  91. package/web/src/components/atoms/TPSCell.module.css +0 -44
  92. package/web/src/components/atoms/TierBadge.jsx +0 -10
  93. package/web/src/components/atoms/TierBadge.module.css +0 -29
  94. package/web/src/components/atoms/Toast.jsx +0 -25
  95. package/web/src/components/atoms/Toast.module.css +0 -41
  96. package/web/src/components/atoms/ToastContainer.jsx +0 -16
  97. package/web/src/components/atoms/ToastContainer.module.css +0 -10
  98. package/web/src/components/atoms/VerdictBadge.jsx +0 -36
  99. package/web/src/components/atoms/VerdictBadge.module.css +0 -40
  100. package/web/src/components/changelog/ChangelogView.jsx +0 -135
  101. package/web/src/components/changelog/ChangelogView.module.css +0 -159
  102. package/web/src/components/dashboard/DetailPanel.jsx +0 -217
  103. package/web/src/components/dashboard/DetailPanel.module.css +0 -260
  104. package/web/src/components/dashboard/ExpandedDetailRow.jsx +0 -361
  105. package/web/src/components/dashboard/ExpandedDetailRow.module.css +0 -356
  106. package/web/src/components/dashboard/ExportModal.jsx +0 -84
  107. package/web/src/components/dashboard/ExportModal.module.css +0 -99
  108. package/web/src/components/dashboard/FilterBar.jsx +0 -338
  109. package/web/src/components/dashboard/FilterBar.module.css +0 -342
  110. package/web/src/components/dashboard/ModelTable.jsx +0 -672
  111. package/web/src/components/dashboard/ModelTable.module.css +0 -446
  112. package/web/src/components/dashboard/ProviderDropdown.jsx +0 -156
  113. package/web/src/components/dashboard/ProviderDropdown.module.css +0 -248
  114. package/web/src/components/help/HelpView.jsx +0 -201
  115. package/web/src/components/help/HelpView.module.css +0 -156
  116. package/web/src/components/install/InstallEndpointsView.jsx +0 -309
  117. package/web/src/components/install/InstallEndpointsView.module.css +0 -427
  118. package/web/src/components/installed/InstalledModelsView.jsx +0 -89
  119. package/web/src/components/installed/InstalledModelsView.module.css +0 -200
  120. package/web/src/components/launch/IncompatibleFallbackModal.jsx +0 -69
  121. package/web/src/components/launch/LaunchButton.jsx +0 -30
  122. package/web/src/components/launch/LaunchButton.module.css +0 -35
  123. package/web/src/components/launch/LaunchModal.module.css +0 -125
  124. package/web/src/components/layout/Footer.jsx +0 -19
  125. package/web/src/components/layout/Footer.module.css +0 -10
  126. package/web/src/components/layout/Header.jsx +0 -265
  127. package/web/src/components/layout/Header.module.css +0 -357
  128. package/web/src/components/palette/CommandPalette.jsx +0 -301
  129. package/web/src/components/palette/CommandPalette.module.css +0 -121
  130. package/web/src/components/playground/PlaygroundChat.jsx +0 -527
  131. package/web/src/components/playground/PlaygroundChat.module.css +0 -382
  132. package/web/src/components/playground/PlaygroundView.jsx +0 -371
  133. package/web/src/components/playground/PlaygroundView.module.css +0 -504
  134. package/web/src/components/recommend/RecommendView.jsx +0 -121
  135. package/web/src/components/recommend/RecommendView.module.css +0 -57
  136. package/web/src/components/router/RouterView.jsx +0 -1212
  137. package/web/src/components/router/RouterView.module.css +0 -1289
  138. package/web/src/components/settings/SettingsView.jsx +0 -555
  139. package/web/src/components/settings/SettingsView.module.css +0 -550
  140. package/web/src/components/tools/ToolPicker.jsx +0 -86
  141. package/web/src/components/tools/ToolPicker.module.css +0 -97
  142. package/web/src/components/update/UpdateChip.jsx +0 -104
  143. package/web/src/components/update/UpdateChip.module.css +0 -146
  144. package/web/src/global.css +0 -298
  145. package/web/src/hooks/urlState.constants.js +0 -28
  146. package/web/src/hooks/useChangelog.js +0 -51
  147. package/web/src/hooks/useColumnSizing.js +0 -211
  148. package/web/src/hooks/useFavorites.js +0 -150
  149. package/web/src/hooks/useFilter.js +0 -310
  150. package/web/src/hooks/useInstalledModels.js +0 -42
  151. package/web/src/hooks/useRecommend.js +0 -67
  152. package/web/src/hooks/useRouterDashboard.js +0 -133
  153. package/web/src/hooks/useSSE.js +0 -73
  154. package/web/src/hooks/useSocket.js +0 -203
  155. package/web/src/hooks/useTheme.js +0 -83
  156. package/web/src/hooks/useTokenUsage.js +0 -103
  157. package/web/src/hooks/useToolMode.js +0 -77
  158. package/web/src/hooks/useUpdateChecker.js +0 -91
  159. package/web/src/hooks/useUrlState.js +0 -170
  160. package/web/src/main.jsx +0 -15
  161. package/web/src/utils/download.js +0 -15
  162. package/web/src/utils/format.js +0 -42
  163. package/web/src/utils/m3.js +0 -57
  164. package/web/src/utils/ranks.js +0 -37
  165. package/web/styles.legacy.css +0 -963
@@ -1,672 +0,0 @@
1
- /**
2
- * @file web/src/components/dashboard/ModelTable.jsx
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.
15
- */
16
- import { useMemo, useEffect, useState, useCallback, useRef } from 'react'
17
- import {
18
- useReactTable,
19
- getCoreRowModel,
20
- flexRender,
21
- createColumnHelper,
22
- } from '@tanstack/react-table'
23
- import { useVirtualizer } from '@tanstack/react-virtual'
24
- import { IconStar, IconStarFilled, IconPlayerPlayFilled } from '@tabler/icons-react'
25
- import { useColumnSizing } from '../../hooks/useColumnSizing.js'
26
-
27
- import MoodCell from '../atoms/MoodCell.jsx'
28
- import TierBadge from '../atoms/TierBadge.jsx'
29
- import StatusDot from '../atoms/StatusDot.jsx'
30
- import LastPingCell from '../atoms/LastPingCell.jsx'
31
- import HealthCell from '../atoms/HealthCell.jsx'
32
- import StabilityCell from '../atoms/StabilityCell.jsx'
33
- import VerdictBadge from '../atoms/VerdictBadge.jsx'
34
- import Sparkline from '../atoms/Sparkline.jsx'
35
- import AILatencyCell from '../atoms/AILatencyCell.jsx'
36
- import TPSCell from '../atoms/TPSCell.jsx'
37
- import NoKeyIcon from '../atoms/NoKeyIcon.jsx'
38
- import ProviderLogo from '../atoms/ProviderLogo.jsx'
39
- import LaunchButton from '../launch/LaunchButton.jsx'
40
-
41
- import { pingClass } from '../../utils/format.js'
42
- import { sweClass } from '../../utils/ranks.js'
43
- // 📖 Pure-JS, browser-safe import of the TUI's tool compatibility helper. Same
44
- // 📖 file the TUI uses; both surfaces share the source of truth for which
45
- // 📖 providers are compatible with which tools.
46
- import { getCompatibleTools } from '../../../../src/core/tool-metadata.js'
47
- import { supportsUsagePercent } from '../../../../src/core/quota-capabilities.js'
48
- import ExpandedDetailRow from './ExpandedDetailRow.jsx'
49
- import styles from './ModelTable.module.css'
50
-
51
- const colHelper = createColumnHelper()
52
-
53
- const SORTABLE_COLUMN_IDS = new Set([
54
- 'mood', 'idx', 'tier', 'sweScore', 'ctx', 'label', 'origin', 'latestPing',
55
- 'avg', 'condition', 'verdict', 'stability', 'uptime', 'aiLatency', 'tps', 'quota', 'trend',
56
- ])
57
-
58
- // ─── Cell renderers ───────────────────────────────────────────────────────────
59
- function MoodCellRenderer({ row }) {
60
- return <MoodCell verdict={row.original.verdict} />
61
- }
62
-
63
- function RankCellRenderer({ row }) {
64
- return <span className={styles.rankNum}>{row.original.idx ?? row.index + 1}</span>
65
- }
66
-
67
- function ModelCellRenderer({ row }) {
68
- const m = row.original
69
- const showNoKey = !m.hasApiKey && !m.cliOnly
70
- return (
71
- <div className={styles.modelCell}>
72
- <StatusDot status={m.status} inRouterSet={m.inRouterSet} />
73
- <div className={styles.modelMeta}>
74
- <div className={styles.modelHeader}>
75
- <span className={styles.modelName}>{m.label}</span>
76
- {showNoKey && (
77
- <NoKeyIcon
78
- size={14}
79
- title="No API key configured — health will stay in 'NO KEY' state"
80
- />
81
- )}
82
- </div>
83
- <div className={styles.modelId}>{m.modelId}</div>
84
- </div>
85
- </div>
86
- )
87
- }
88
-
89
- function SWECellRenderer({ row }) {
90
- const m = row.original
91
- const cls = sweClass(m.sweScore)
92
- return <span className={`${styles.swe} ${styles[cls]}`}>{m.sweScore || '—'}</span>
93
- }
94
-
95
- function CtxCellRenderer({ row }) {
96
- return <span className={styles.ctx}>{row.original.ctx || '—'}</span>
97
- }
98
-
99
- function ProviderCellRenderer({ row }) {
100
- // 📖 Renders the provider as [icon + text-wordmark] on a single row-height
101
- // 📖 line. Replaces the old bordered `.providerPill` text label. See
102
- // 📖 atoms/ProviderLogo.jsx for the asset resolution + theme logic.
103
- return <ProviderLogo providerKey={row.original.providerKey} origin={row.original.origin} />
104
- }
105
-
106
- function LastPingCellRenderer({ row }) {
107
- const m = row.original
108
- const hist = m.pingHistory || m.pings || []
109
- const latest = hist.length > 0 ? hist[hist.length - 1] : null
110
- return <LastPingCell ms={latest?.ms ?? null} isPinging={Boolean(m.isPinging)} />
111
- }
112
-
113
- function AvgPingCellRenderer({ row }) {
114
- const m = row.original
115
- const cls = pingClass(m.avg)
116
- return (
117
- <span className={`${styles.ping} ${styles[cls]}`}>
118
- {m.avg == null || m.avg === Infinity || m.avg > 99000 ? '—' : m.avg}
119
- </span>
120
- )
121
- }
122
-
123
- function HealthCellRenderer({ row }) {
124
- const m = row.original
125
- return <HealthCell status={m.status} httpCode={m.httpCode} inRouterSet={m.inRouterSet} />
126
- }
127
-
128
- function VerdictCellRenderer({ row }) {
129
- const m = row.original
130
- return <VerdictBadge verdict={m.verdict} httpCode={m.httpCode} />
131
- }
132
-
133
- function StabilityCellRenderer({ row }) {
134
- return <StabilityCell score={row.original.stability} />
135
- }
136
-
137
- function UptimeCellRenderer({ row }) {
138
- const m = row.original
139
- return <span className={styles.uptime}>{m.uptime > 0 ? `${m.uptime}%` : '—'}</span>
140
- }
141
-
142
- function AILatencyCellRenderer({ row }) {
143
- const m = row.original
144
- const isRunning = Boolean(m.isBenchmarking)
145
- return (
146
- <div
147
- className={`${styles.aiLatencyWrap} ${isRunning ? styles.aiLatencyRunning : ''}`}
148
- onClick={(e) => {
149
- // 📖 Click anywhere in the cell = fire per-row benchmark (TUI: Ctrl+A).
150
- e.stopPropagation()
151
- const handler = window.__fcmRowBenchmarkHandler
152
- if (typeof handler === 'function') handler(m)
153
- }}
154
- title={isRunning ? 'Benchmarking…' : 'Click to run AI Speed Test (TUI: Ctrl+A)'}
155
- >
156
- <AILatencyCell result={m.benchmark || null} isRunning={isRunning} />
157
- {!isRunning && (
158
- <span className={styles.aiLatencyPlay} aria-hidden="true">
159
- <IconPlayerPlayFilled size={10} stroke={1.5} />
160
- </span>
161
- )}
162
- </div>
163
- )
164
- }
165
-
166
- function TPSCellRenderer({ row }) {
167
- const m = row.original
168
- return <TPSCell result={m.benchmark || null} isRunning={Boolean(m.isBenchmarking)} />
169
- }
170
-
171
- function QuotaCellRenderer({ row }) {
172
- const m = row.original
173
- if (!m.hasApiKey) return <span className={`${styles.quota} ${styles.quotaNone}`}>-</span>
174
- if (!supportsUsagePercent(m.providerKey)) return <span className={`${styles.quota} ${styles.quotaNA}`}>N/A</span>
175
- if (typeof m.usagePercent !== 'number' || !Number.isFinite(m.usagePercent)) {
176
- return <span className={`${styles.quota} ${styles.quotaNone}`}>…</span>
177
- }
178
- const pct = Math.round(m.usagePercent)
179
- const cls = pct >= 50 ? styles.quotaGood : pct >= 20 ? styles.quotaWarn : styles.quotaBad
180
- return <span className={`${styles.quota} ${cls}`}>{pct}%</span>
181
- }
182
-
183
- function TrendCellRenderer({ row }) {
184
- return <Sparkline history={row.original.pingHistory} />
185
- }
186
-
187
- // ─── Column definitions ──────────────────────────────────────────────────────
188
- // 📖 Note: `favorites` / `onBenchmarkRow` are read by the cell renderers below
189
- // 📖 via closure. We rebuild the columns list whenever any of these change so
190
- // 📖 the cells always see fresh values.
191
- const buildColumns = ({ favorites, onBenchmarkRow, onSelectModel, onLaunch, toolMode }) => [
192
- // 📖 Star column — leftmost, mirrors the TUI's `F` key.
193
- // 📖 First column = bookmarks (TUI `F` key). Widened from 32→104 so the
194
- // 📖 "BOOKMARKS" header label fits horizontally without clipping.
195
- colHelper.display({
196
- id: 'fav',
197
- header: 'BOOKMARKS',
198
- size: 104,
199
- enableSorting: false,
200
- cell: ({ row }) => {
201
- const m = row.original
202
- const isFav = favorites.isFavorite(m)
203
- return (
204
- <button
205
- className={`${styles.favBtn} ${isFav ? styles.favBtnActive : ''}`}
206
- onClick={(e) => { e.stopPropagation(); favorites.toggle(m) }}
207
- title={isFav ? `Unfavorite ${m.label} (TUI: F)` : `Favorite ${m.label} (TUI: F)`}
208
- aria-label={isFav ? `Unfavorite ${m.label}` : `Favorite ${m.label}`}
209
- aria-pressed={isFav}
210
- >
211
- {isFav ? <IconStarFilled size={14} stroke={1.5} /> : <IconStar size={14} stroke={1.5} />}
212
- </button>
213
- )
214
- },
215
- }),
216
- colHelper.display({
217
- id: 'mood',
218
- header: '❔',
219
- size: 28,
220
- cell: MoodCellRenderer,
221
- enableSorting: true,
222
- }),
223
- colHelper.accessor('idx', {
224
- header: '#',
225
- size: 36,
226
- enableSorting: true,
227
- cell: ({ getValue, row }) => (
228
- <span className={styles.rankNum}>{getValue() ?? row.index + 1}</span>
229
- ),
230
- }),
231
- colHelper.accessor('tier', {
232
- header: 'Tier',
233
- size: 48,
234
- enableSorting: true,
235
- cell: ({ getValue }) => <TierBadge tier={getValue()} />,
236
- }),
237
- colHelper.accessor('sweScore', {
238
- header: 'SWE%',
239
- size: 52,
240
- enableSorting: true,
241
- cell: SWECellRenderer,
242
- }),
243
- colHelper.accessor('ctx', {
244
- header: 'CTX',
245
- size: 48,
246
- enableSorting: true,
247
- cell: CtxCellRenderer,
248
- }),
249
- colHelper.accessor('label', {
250
- header: 'Model',
251
- size: 200,
252
- enableSorting: true,
253
- cell: ModelCellRenderer,
254
- }),
255
- colHelper.accessor('origin', {
256
- header: 'Provider',
257
- // 📖 150px fits the widest wordmark (cloudflare ~145px) at 12px height,
258
- // 📖 with a small buffer. Users can still drag the column wider if they
259
- // 📖 want a larger render. The default before this was 110px, but the
260
- // 📖 icon+wordmark layout needs more horizontal room.
261
- size: 150,
262
- enableSorting: true,
263
- cell: ProviderCellRenderer,
264
- }),
265
- colHelper.display({
266
- id: 'latestPing',
267
- header: 'Last Ping',
268
- size: 80,
269
- cell: LastPingCellRenderer,
270
- enableSorting: true,
271
- }),
272
- colHelper.accessor('avg', {
273
- header: 'Avg',
274
- size: 72,
275
- enableSorting: true,
276
- cell: AvgPingCellRenderer,
277
- }),
278
- colHelper.accessor('status', {
279
- id: 'condition',
280
- header: 'Health',
281
- size: 120,
282
- enableSorting: true,
283
- cell: HealthCellRenderer,
284
- }),
285
- colHelper.accessor('verdict', {
286
- header: 'Verdict',
287
- size: 100,
288
- enableSorting: true,
289
- cell: VerdictCellRenderer,
290
- }),
291
- colHelper.accessor('stability', {
292
- header: 'Stability',
293
- size: 90,
294
- enableSorting: true,
295
- cell: StabilityCellRenderer,
296
- }),
297
- colHelper.accessor('uptime', {
298
- header: 'Up%',
299
- size: 48,
300
- enableSorting: true,
301
- cell: UptimeCellRenderer,
302
- }),
303
- colHelper.display({
304
- id: 'aiLatency',
305
- header: 'AI Lat.',
306
- size: 80,
307
- cell: AILatencyCellRenderer,
308
- enableSorting: true,
309
- }),
310
- colHelper.display({
311
- id: 'tps',
312
- header: 'TPS',
313
- size: 48,
314
- cell: TPSCellRenderer,
315
- enableSorting: true,
316
- }),
317
- colHelper.display({
318
- id: 'quota',
319
- header: 'Quota',
320
- size: 64,
321
- cell: QuotaCellRenderer,
322
- enableSorting: true,
323
- }),
324
- colHelper.display({
325
- id: 'trend',
326
- header: 'Trend',
327
- size: 96,
328
- cell: TrendCellRenderer,
329
- enableSorting: true,
330
- }),
331
- colHelper.display({
332
- id: 'launch',
333
- header: '🔌',
334
- size: 42,
335
- enableSorting: false,
336
- cell: ({ row }) => (
337
- <div className={styles.launchCell}>
338
- <LaunchButton model={row.original} toolMode={toolMode} onLaunch={onLaunch} variant="icon" />
339
- </div>
340
- ),
341
- }),
342
- ]
343
-
344
- // 📖 DEFAULT_COLUMN_SIZING: extracted from the columns above. This is the single
345
- // 📖 source of truth for both the table layout and the useColumnSizing hook's reset target.
346
- // 📖 The 'Reset columns' button restores these exact widths.
347
- // 📖 We build a placeholder columns list (no callbacks needed) to extract sizes.
348
- const PLACEHOLDER_COLUMNS = buildColumns({ favorites: { isFavorite: () => false, toggle: () => {} }, onBenchmarkRow: null, onSelectModel: null, onLaunch: null, toolMode: 'opencode' })
349
- export const DEFAULT_COLUMN_SIZING = PLACEHOLDER_COLUMNS.reduce((acc, c) => {
350
- if (c.id) acc[c.id] = c.size ?? 80
351
- return acc
352
- }, {})
353
-
354
- // 📖 RESIZE_MIN/MAX bounds (px) — used by the handle's drag preview and mirrored in
355
- // 📖 the hook's clamp. Keep them in sync if you change one.
356
- const COLUMN_RESIZE_MIN = 24
357
- const COLUMN_RESIZE_MAX = 1200
358
-
359
- // ─── Sort icon component ────────────────────────────────────────────────────
360
- function SortIcon({ column }) {
361
- if (!column.getCanSort()) return null
362
- const sorted = column.getIsSorted()
363
- if (!sorted) return <span className={styles.sortIcon}>⇅</span>
364
- return <span className={styles.sortIconActive}>{sorted === 'asc' ? '↑' : '↓'}</span>
365
- }
366
-
367
- // ─── Main component ───────────────────────────────────────────────────────────
368
- export default function ModelTable({
369
- filtered, onSelectModel, onBenchmarkRow, onLaunch, favorites,
370
- sortColumn, sortDirection, onSort,
371
- toolMode = null,
372
- onToast, onSetToolMode, onCycleToolMode, onOpenFallback,
373
- }) {
374
- // 📖 Expand row state — only one row expanded at a time (accordion)
375
- const [expandedRowId, setExpandedRowId] = useState(null)
376
-
377
- // 📖 scrollRef is the internal scroll container — shared by the sticky
378
- // 📖 <thead> header and the row virtualizer (only visible rows are in the
379
- // 📖 DOM; off-screen rows stay in React state so live pings/probes keep
380
- // 📖 updating them and re-render correctly when scrolled into view).
381
- const scrollRef = useRef(null)
382
-
383
- const toggleExpand = useCallback((model) => {
384
- const key = `${model.providerKey}/${model.modelId}`
385
- setExpandedRowId((prev) => prev === key ? null : key)
386
- }, [])
387
- // Compute top3 for medal rows
388
- const top3Ids = useMemo(() => {
389
- const online = filtered.filter(m => m.status === 'up' && m.avg != null && m.avg !== Infinity && m.avg < 99000)
390
- return new Set([...online].sort((a, b) => a.avg - b.avg).slice(0, 3).map(m => m.modelId))
391
- }, [filtered])
392
-
393
- // 📖 Wire the per-row benchmark handler through a window-level ref so the
394
- // 📖 AI Lat. cell can call it without prop-drilling through TanStack's
395
- // 📖 cell render context. Cleaner than threading `meta` through 17 columns.
396
- useEffect(() => {
397
- if (typeof onBenchmarkRow !== 'function') {
398
- window.__fcmRowBenchmarkHandler = null
399
- return
400
- }
401
- window.__fcmRowBenchmarkHandler = onBenchmarkRow
402
- return () => {
403
- if (window.__fcmRowBenchmarkHandler === onBenchmarkRow) {
404
- window.__fcmRowBenchmarkHandler = null
405
- }
406
- }
407
- }, [onBenchmarkRow])
408
-
409
- // 📖 Build the columns with the current favorites/onBenchmarkRow handlers.
410
- // 📖 Re-derive when favorites reference changes so the star cells re-render.
411
- const columns = useMemo(
412
- () => buildColumns({ favorites, onBenchmarkRow, onSelectModel, onLaunch, toolMode }),
413
- [favorites, onBenchmarkRow, onSelectModel, onLaunch, toolMode]
414
- )
415
-
416
- // 📖 Column sizing state with localStorage persistence (see useColumnSizing.js).
417
- // 📖 onColumnSizingChange + state.columnSizing are TanStack's contract for resize.
418
- const { columnSizing, setColumnSizing, resetColumnSizing, hasCustomSizing } =
419
- useColumnSizing(DEFAULT_COLUMN_SIZING)
420
-
421
- // TanStack Table — no getSortedRowModel, sorting lives in useFilter
422
- const table = useReactTable({
423
- data: filtered,
424
- columns,
425
- defaultColumn: { enableSorting: true },
426
- getCoreRowModel: getCoreRowModel(),
427
- columnResizeMode: 'onChange',
428
- state: { columnSizing },
429
- onColumnSizingChange: setColumnSizing,
430
- })
431
-
432
- const rows = table.getRowModel().rows
433
-
434
- // 📖 Flatten rows + any expanded detail panel into a single virtualized
435
- // 📖 list. One <tr> per item keeps TanStack Virtual's dynamic measurement
436
- // 📖 clean (one measured element per virtual item). The expand item only
437
- // 📖 exists for the single accordion-open row, so count changes by at most 1.
438
- const flatRows = useMemo(() => {
439
- const out = []
440
- for (const row of rows) {
441
- const m = row.original
442
- const key = `${m.providerKey}/${m.modelId}`
443
- out.push({ type: 'row', row, key })
444
- if (expandedRowId === key) {
445
- out.push({ type: 'expand', row, key: `${key}__expand` })
446
- }
447
- }
448
- return out
449
- }, [rows, expandedRowId])
450
-
451
- // 📖 Row virtualizer: renders only the ~15-25 rows in the viewport (+overscan)
452
- // 📖 instead of all 190+. Spacer <tr>s above/below preserve the full scroll
453
- // 📖 height. This is a RENDER-only optimization — ALL model data (including
454
- // 📖 live pings/probes for off-screen rows) still lives in React state via
455
- // 📖 useSocket, so updates continue uninterrupted and show fresh values the
456
- // 📖 moment a row scrolls back into view. Nothing about probing changes.
457
- const rowVirtualizer = useVirtualizer({
458
- count: flatRows.length,
459
- getScrollElement: () => scrollRef.current,
460
- estimateSize: (i) => (flatRows[i]?.type === 'expand' ? 260 : 47),
461
- overscan: 10,
462
- getItemKey: (i) => flatRows[i].key,
463
- })
464
- const virtualItems = rowVirtualizer.getVirtualItems()
465
- const totalSize = rowVirtualizer.getTotalSize()
466
- const padTop = virtualItems.length ? Math.max(0, virtualItems[0].start) : 0
467
- const lastVi = virtualItems.length ? virtualItems[virtualItems.length - 1] : null
468
- const padBottom = lastVi ? Math.max(0, totalSize - lastVi.end) : 0
469
-
470
- // 📖 Total table width = sum of all column sizes. Used to drop the min-width
471
- // 📖 override once the user has manually tuned the columns, so horizontal
472
- // 📖 scrolling reflects the user's layout rather than the 1200px default.
473
- const totalTableWidth = useMemo(
474
- () => table.getAllColumns().reduce((sum, c) => sum + c.getSize(), 0),
475
- // 📖 Re-derive when the sizing state object changes (a new object is created per change).
476
- [columnSizing]
477
- )
478
-
479
- // 📖 Global cursor lock: when the user is dragging a resize handle, the cursor
480
- // 📖 can leave the handle briefly. Adding a body class keeps the col-resize
481
- // 📖 cursor visible AND blocks accidental text selection until the drag ends.
482
- const isResizing = Boolean(table.getState().columnSizingInfo?.isResizingColumn)
483
- useEffect(() => {
484
- if (typeof document === 'undefined') return
485
- if (isResizing) {
486
- document.body.classList.add('col-resizing')
487
- } else {
488
- document.body.classList.remove('col-resizing')
489
- }
490
- return () => {
491
- document.body.classList.remove('col-resizing')
492
- }
493
- }, [isResizing])
494
-
495
- if (rows.length === 0) {
496
- return <div className={styles.empty}>No models match your filters</div>
497
- }
498
-
499
- return (
500
- <div className={styles.container}>
501
- {/* 📖 resizeToolbar lives OUTSIDE the scroller so it never overlaps the
502
- 📖 sticky table header (thead th sticks to the top of scrollInner). */}
503
- {hasCustomSizing && (
504
- <div className={styles.resizeToolbar}>
505
- <span className={styles.resizeToolbarHint}>
506
- 📐 Custom column widths · total <strong>{Math.round(totalTableWidth)}px</strong>
507
- </span>
508
- <button
509
- type="button"
510
- className={styles.resizeResetButton}
511
- onClick={resetColumnSizing}
512
- title="Restore the default column widths"
513
- >
514
- ↺ Reset columns
515
- </button>
516
- </div>
517
- )}
518
- {/* 📖 scrollInner is the actual scroller. It is the scroll element for
519
- 📖 both the sticky <thead> header and the row virtualizer. Bounded by
520
- 📖 .dashboardView (viewport height) so the table scrolls internally. */}
521
- <div className={styles.scrollInner} ref={scrollRef}>
522
- <table
523
- className={styles.table}
524
- style={hasCustomSizing ? { minWidth: `${totalTableWidth}px` } : undefined}
525
- >
526
- <thead>
527
- {table.getHeaderGroups().map(headerGroup => (
528
- <tr key={headerGroup.id}>
529
- {headerGroup.headers.map(header => {
530
- const col = header.column
531
- const canSort = SORTABLE_COLUMN_IDS.has(col.id)
532
- const isResizing = col.getIsResizing?.() === true
533
- const sizePx = `${header.getSize()}px`
534
- return (
535
- <th
536
- key={header.id}
537
- className={styles.th}
538
- onClick={canSort ? () => onSort(header.id) : undefined}
539
- style={{ width: sizePx, minWidth: sizePx, maxWidth: sizePx, cursor: canSort ? 'pointer' : 'default' }}
540
- title={`Sort ${col.columnDef.header}: asc → desc → reset`}
541
- >
542
- <div className={styles.thInner}>
543
- <span className={styles.thLabel}>
544
- {flexRender(col.columnDef.header, header.getContext())}
545
- {canSort && (
546
- <span className={
547
- sortColumn === col.id
548
- ? styles.sortIconActive
549
- : styles.sortIcon
550
- }>
551
- {sortColumn === col.id
552
- ? (sortDirection === 'asc' ? '↑' : '↓')
553
- : '⇅'}
554
- </span>
555
- )}
556
- </span>
557
- {/* 📖 Resize handle: full-height grab zone on the right edge of every header.
558
- 📖 TanStack's column.getResizeHandler() wires mouse + touch to the
559
- 📖 table's columnSizing state via the onColumnSizingChange callback. */}
560
- <span
561
- className={`${styles.resizeHandle} ${isResizing ? styles.resizeHandleActive : ''}`}
562
- onClick={(e) => e.stopPropagation()}
563
- onMouseDown={header.getResizeHandler()}
564
- onTouchStart={header.getResizeHandler()}
565
- onDoubleClick={() => {
566
- // 📖 Double-click resets just this column to its declared default width.
567
- setColumnSizing((prev) => ({ ...prev, [col.id]: DEFAULT_COLUMN_SIZING[col.id] }))
568
- }}
569
- title={`Drag to resize ${col.columnDef.header} (double-click to reset this column)`}
570
- role="separator"
571
- aria-orientation="vertical"
572
- aria-label={`Resize ${col.columnDef.header} column`}
573
- />
574
- </div>
575
- </th>
576
- )
577
- })}
578
- </tr>
579
- ))}
580
- </thead>
581
- <tbody>
582
- {/* 📖 Top spacer: represents the scroll height of all virtualized-out
583
- 📖 rows ABOVE the viewport. aria-hidden + empty cell keeps it
584
- 📖 invisible while preserving table column layout. */}
585
- {padTop > 0 && (
586
- <tr aria-hidden="true" style={{ height: padTop }}>
587
- <td style={{ height: padTop, padding: 0, border: 0 }} />
588
- </tr>
589
- )}
590
- {virtualItems.map((vi) => {
591
- const item = flatRows[vi.index]
592
- // 📖 Expanded detail panel row — its own virtual item so dynamic
593
- // 📖 measurement stays one-element-per-item (clean heights).
594
- if (item.type === 'expand') {
595
- return (
596
- <tr
597
- key={item.key}
598
- className={styles.expandRowWrapper}
599
- data-index={vi.index}
600
- ref={rowVirtualizer.measureElement}
601
- >
602
- <td colSpan={item.row.getVisibleCells().length} className={styles.expandRowCell}>
603
- <ExpandedDetailRow
604
- model={item.row.original}
605
- favorites={favorites}
606
- onBenchmark={onBenchmarkRow}
607
- onLaunch={onLaunch}
608
- onToast={onToast}
609
- toolMode={toolMode}
610
- onSetToolMode={onSetToolMode}
611
- onCycleToolMode={onCycleToolMode}
612
- onOpenFallback={onOpenFallback}
613
- />
614
- </td>
615
- </tr>
616
- )
617
- }
618
- const row = item.row
619
- const m = row.original
620
- const isExpanded = expandedRowId === item.key
621
- const rankIdx = [...top3Ids].indexOf(m.modelId)
622
- const rowClasses = []
623
- if (rankIdx >= 0) rowClasses.push(styles[`rank${rankIdx + 1}`])
624
- if (m.isBenchmarking) rowClasses.push(styles.benchRow)
625
- if (isExpanded) rowClasses.push(styles.expandedRow)
626
- if (toolMode) {
627
- const compat = getCompatibleTools(m.providerKey)
628
- if (!compat.includes(toolMode)) {
629
- rowClasses.push(styles.incompatible)
630
- }
631
- }
632
- if (m.status === 'noauth' || m.status === 'auth_error') {
633
- rowClasses.push(styles.unusable)
634
- }
635
- if (m.status === 'pending' && m.inRouterSet === false) {
636
- rowClasses.push(styles.notInSetRow)
637
- }
638
- return (
639
- <tr
640
- key={item.key}
641
- className={rowClasses.join(' ')}
642
- onClick={() => toggleExpand(m)}
643
- data-index={vi.index}
644
- ref={rowVirtualizer.measureElement}
645
- >
646
- {row.getVisibleCells().map(cell => {
647
- const sizePx = `${cell.column.getSize()}px`
648
- return (
649
- <td
650
- key={cell.id}
651
- className={styles.td}
652
- style={{ width: sizePx, minWidth: sizePx, maxWidth: sizePx }}
653
- >
654
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
655
- </td>
656
- )
657
- })}
658
- </tr>
659
- )
660
- })}
661
- {/* 📖 Bottom spacer: scroll height of all virtualized-out rows BELOW. */}
662
- {padBottom > 0 && (
663
- <tr aria-hidden="true" style={{ height: padBottom }}>
664
- <td style={{ height: padBottom, padding: 0, border: 0 }} />
665
- </tr>
666
- )}
667
- </tbody>
668
- </table>
669
- </div>
670
- </div>
671
- )
672
- }