free-coding-models 0.5.14 → 0.5.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/changelog/v0.5.12.md +86 -2
  2. package/changelog/v0.5.13.md +19 -53
  3. package/changelog/v0.5.15.md +17 -0
  4. package/changelog/v0.5.16.md +15 -0
  5. package/package.json +4 -3
  6. package/src/tui/app.js +16 -4
  7. package/src/tui/cli-help.js +87 -0
  8. package/src/tui/key-handler.js +2 -14
  9. package/src/tui/render-table.js +46 -26
  10. package/src/tui/theme.js +70 -0
  11. package/web/README.md +1 -1
  12. package/web/dist/assets/index-avN2GM3H.css +1 -0
  13. package/web/dist/assets/index-vsysCImz.js +41 -0
  14. package/web/dist/index.html +6 -2
  15. package/web/index.html +4 -0
  16. package/web/server.js +249 -15
  17. package/web/src/App.jsx +46 -15
  18. package/web/src/components/dashboard/DetailPanel.jsx +1 -1
  19. package/web/src/components/dashboard/DetailPanel.module.css +5 -0
  20. package/web/src/components/dashboard/ExpandedDetailRow.jsx +464 -0
  21. package/web/src/components/dashboard/ExpandedDetailRow.module.css +347 -0
  22. package/web/src/components/dashboard/FilterBar.jsx +98 -58
  23. package/web/src/components/dashboard/FilterBar.module.css +103 -33
  24. package/web/src/components/dashboard/ModelTable.jsx +49 -29
  25. package/web/src/components/dashboard/ModelTable.module.css +12 -0
  26. package/web/src/components/dashboard/ProviderDropdown.jsx +156 -0
  27. package/web/src/components/dashboard/ProviderDropdown.module.css +248 -0
  28. package/web/src/components/help/HelpView.jsx +13 -0
  29. package/web/src/components/playground/PlaygroundView.jsx +193 -12
  30. package/web/src/components/playground/PlaygroundView.module.css +82 -0
  31. package/web/src/components/router/RouterView.jsx +15 -6
  32. package/web/vite.config.js +1 -1
  33. package/web/dist/assets/index-Bzqz7KbT.js +0 -39
  34. package/web/dist/assets/index-H9JWDRIh.css +0 -1
@@ -48,10 +48,14 @@
48
48
  <link rel="preconnect" href="https://fonts.googleapis.com">
49
49
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
50
50
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
51
- <script type="module" crossorigin src="/assets/index-Bzqz7KbT.js"></script>
52
- <link rel="stylesheet" crossorigin href="/assets/index-H9JWDRIh.css">
51
+ <script type="module" crossorigin src="/assets/index-vsysCImz.js"></script>
52
+ <link rel="stylesheet" crossorigin href="/assets/index-avN2GM3H.css">
53
53
  </head>
54
54
  <body>
55
55
  <div id="root"></div>
56
+ <!-- 📖 PostHog analytics — privacy-first, self-hosted option available -->
57
+ <script>
58
+ !function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.",".js."),function(t,e,o,n){var i,s;for(n=function(t,e){return t[e]},s=e.split("."),i=0;i<s.length-1;i++)n=n(s[i],t);(a=n[s.pop()])&&a.apply(t,[].slice.call(arguments,0))}(e,"5.0.0")},e.init('phc_PLACEHOLDER_REPLACE_WITH_YOUR_KEY',{api_host:'https://us.i.posthog.com'}))}(document,window.posthog||[]);
59
+ </script>
56
60
  </body>
57
61
  </html>
package/web/index.html CHANGED
@@ -52,5 +52,9 @@
52
52
  <body>
53
53
  <div id="root"></div>
54
54
  <script type="module" src="/src/main.jsx"></script>
55
+ <!-- 📖 PostHog analytics — privacy-first, self-hosted option available -->
56
+ <script>
57
+ !function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.crossOrigin="anonymous",p.async=!0,p.src=s.api_host.replace(".i.",".js."),function(t,e,o,n){var i,s;for(n=function(t,e){return t[e]},s=e.split("."),i=0;i<s.length-1;i++)n=n(s[i],t);(a=n[s.pop()])&&a.apply(t,[].slice.call(arguments,0))}(e,"5.0.0")},e.init('phc_PLACEHOLDER_REPLACE_WITH_YOUR_KEY',{api_host:'https://us.i.posthog.com'}))}(document,window.posthog||[]);
58
+ </script>
55
59
  </body>
56
60
  </html>
package/web/server.js CHANGED
@@ -45,7 +45,7 @@ import {
45
45
  getAvg, getVerdict, getUptime, getP95, getJitter,
46
46
  getStabilityScore,
47
47
  } from '../src/core/utils.js'
48
- import { benchmarkModel, BENCHMARK_TIMEOUT_MS } from '../src/core/benchmark.js'
48
+ import { benchmarkModel, BENCHMARK_TIMEOUT_MS, BENCHMARK_PROMPT } from '../src/core/benchmark.js'
49
49
  import { getInstallTargetModes, installProviderEndpoints, getConfiguredInstallableProviders, getProviderCatalogModels } from '../src/core/endpoint-installer.js'
50
50
  import { isModelCompatibleWithTool } from '../src/core/tool-metadata.js'
51
51
  import { sendUsageTelemetry } from '../src/core/telemetry.js'
@@ -1030,6 +1030,171 @@ async function handleRequest(req, res) {
1030
1030
  return
1031
1031
  }
1032
1032
 
1033
+ case '/api/benchmark-stream': {
1034
+ if (req.method !== 'POST') {
1035
+ res.writeHead(405)
1036
+ res.end('Method Not Allowed')
1037
+ return
1038
+ }
1039
+
1040
+ let body
1041
+ try {
1042
+ body = await readJsonBody(req)
1043
+ } catch {
1044
+ res.writeHead(400)
1045
+ res.end('Invalid JSON')
1046
+ return
1047
+ }
1048
+
1049
+ const result = getResult(body.providerKey, body.modelId)
1050
+ if (!result) {
1051
+ sendJson(res, 404, { error: 'Model not found' })
1052
+ return
1053
+ }
1054
+
1055
+ const source = sources[result.providerKey]
1056
+ const apiKey = getApiKey(config, result.providerKey)
1057
+ if (!apiKey) {
1058
+ sendJson(res, 401, { error: 'No API key configured' })
1059
+ return
1060
+ }
1061
+
1062
+ const key = getResultKey(result)
1063
+ benchmarkRunning.add(key)
1064
+ broadcastUpdate({ immediate: true })
1065
+
1066
+ // 📖 Set SSE headers for streaming response to the browser
1067
+ res.writeHead(200, {
1068
+ 'Content-Type': 'text/event-stream',
1069
+ 'Cache-Control': 'no-cache',
1070
+ 'Connection': 'keep-alive',
1071
+ 'X-Accel-Buffering': 'no',
1072
+ })
1073
+
1074
+ try {
1075
+ // Build the upstream URL — append /v1/chat/completions if needed
1076
+ let upstreamUrl = source?.url || result.url
1077
+ if (!upstreamUrl.includes('/chat/completions')) {
1078
+ upstreamUrl = upstreamUrl.replace(/\/+$/, '') + '/v1/chat/completions'
1079
+ }
1080
+
1081
+ // 📖 ZAI provider: strip the "zai/" prefix from modelId for the API
1082
+ let apiModelId = result.modelId
1083
+ if (result.providerKey === 'zai' && apiModelId.startsWith('zai/')) {
1084
+ apiModelId = apiModelId.slice(4)
1085
+ }
1086
+
1087
+ const upstreamHeaders = {
1088
+ 'Content-Type': 'application/json',
1089
+ 'Authorization': `Bearer ${apiKey}`,
1090
+ }
1091
+
1092
+ // 📖 OpenRouter requires HTTP-Referer and X-Title headers
1093
+ if (result.providerKey === 'openrouter') {
1094
+ upstreamHeaders['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
1095
+ upstreamHeaders['X-Title'] = 'free-coding-models'
1096
+ }
1097
+
1098
+ const reqBody = {
1099
+ model: apiModelId,
1100
+ messages: [{ role: 'user', content: BENCHMARK_PROMPT }],
1101
+ max_tokens: 140,
1102
+ temperature: 0,
1103
+ stream: true,
1104
+ }
1105
+
1106
+ const resp = await fetch(upstreamUrl, {
1107
+ method: 'POST',
1108
+ headers: upstreamHeaders,
1109
+ body: JSON.stringify(reqBody),
1110
+ signal: AbortSignal.timeout(20000),
1111
+ })
1112
+
1113
+ if (!resp.ok) {
1114
+ let message = `HTTP ${resp.status}`
1115
+ try {
1116
+ const errJson = await resp.json()
1117
+ message = errJson?.error?.message || errJson?.error || errJson?.message || message
1118
+ } catch { /* keep default */ }
1119
+ res.write('event: error\ndata: ' + JSON.stringify({ error: message }) + '\n\n')
1120
+ res.end()
1121
+ benchmarkRunning.delete(key)
1122
+ broadcastUpdate({ immediate: true })
1123
+ return
1124
+ }
1125
+
1126
+ // 📖 Parse SSE stream from the provider — extract tokens and measure TPS
1127
+ const reader = resp.body.getReader()
1128
+ const decoder = new TextDecoder()
1129
+ const t0 = performance.now()
1130
+ let tokenCount = 0
1131
+ let fullText = ''
1132
+ let buffer = ''
1133
+
1134
+ while (true) {
1135
+ const { done, value } = await reader.read()
1136
+ if (done) break
1137
+
1138
+ buffer += decoder.decode(value, { stream: true })
1139
+ const lines = buffer.split('\n')
1140
+ buffer = lines.pop() || '' // keep incomplete line in buffer
1141
+
1142
+ for (const line of lines) {
1143
+ const trimmed = line.trim()
1144
+ if (!trimmed.startsWith('data: ')) continue
1145
+ const payload = trimmed.slice(6)
1146
+ if (payload === '[DONE]') continue
1147
+
1148
+ let parsed
1149
+ try { parsed = JSON.parse(payload) } catch { continue }
1150
+
1151
+ const delta = parsed?.choices?.[0]?.delta?.content
1152
+ if (!delta) continue
1153
+
1154
+ fullText += delta
1155
+ tokenCount++
1156
+ res.write('event: token\ndata: ' + JSON.stringify({
1157
+ token: delta,
1158
+ totalMs: Math.round(performance.now() - t0),
1159
+ tokens: tokenCount,
1160
+ tps: Math.round(tokenCount / ((performance.now() - t0) / 1000) * 10) / 10,
1161
+ text: fullText,
1162
+ }) + '\n\n')
1163
+ }
1164
+ }
1165
+
1166
+ const totalMs = Math.round(performance.now() - t0)
1167
+ const tps = tokenCount / (totalMs / 1000)
1168
+
1169
+ res.write('event: done\ndata: ' + JSON.stringify({
1170
+ totalMs,
1171
+ outputTokens: tokenCount,
1172
+ tokensPerSecond: Math.round(tps * 10) / 10,
1173
+ answerPreview: fullText.slice(0, 100),
1174
+ }) + '\n\n')
1175
+ res.end()
1176
+
1177
+ // 📖 Update shared benchmark state so the dashboard reflects the result
1178
+ const benchmarkResult = {
1179
+ ok: true,
1180
+ totalMs,
1181
+ outputTokens: tokenCount,
1182
+ tokensPerSecond: tps,
1183
+ answerPreview: fullText.slice(0, 60),
1184
+ }
1185
+ benchmarkResults.set(key, benchmarkResult)
1186
+ benchmarkRunning.delete(key)
1187
+ updateHealthFromBenchmark(result, benchmarkResult)
1188
+ broadcastUpdate({ immediate: true })
1189
+ } catch (err) {
1190
+ res.write('event: error\ndata: ' + JSON.stringify({ error: err?.message || 'Stream failed' }) + '\n\n')
1191
+ res.end()
1192
+ benchmarkRunning.delete(key)
1193
+ broadcastUpdate({ immediate: true })
1194
+ }
1195
+ return
1196
+ }
1197
+
1033
1198
  case '/api/global-benchmark': {
1034
1199
  if (req.method === 'GET') {
1035
1200
  sendJson(res, 200, {
@@ -1360,32 +1525,101 @@ async function handleRequest(req, res) {
1360
1525
  }
1361
1526
 
1362
1527
  case '/api/playground/chat': {
1363
- // 📖 Playground proxy: forwards a chat-completions request to the
1364
- // 📖 local router daemon and returns the response. Streams SSE
1365
- // 📖 events when stream: true is set. The web playground UI is just
1366
- // 📖 a thin client over this endpoint so the browser never talks to
1367
- // 📖 an external domain (no CORS, no exposed provider keys).
1528
+ // 📖 Playground proxy: routes chat-completions requests either to
1529
+ // 📖 the local router daemon (for "fcm" auto-router) or directly
1530
+ // 📖 to the provider (for specific models). This way the playground
1531
+ // 📖 works immediately with any "up" model, even without the daemon.
1368
1532
  if (req.method !== 'POST') { res.writeHead(405); res.end('Method Not Allowed'); return }
1369
- const port = await readDaemonPort()
1370
- if (!port) {
1533
+ const body = await readJsonBody(req)
1534
+ const wantsStream = body?.stream === true
1535
+ const requestedModel = body?.model || 'fcm'
1536
+ const isFcm = requestedModel === 'fcm'
1537
+
1538
+ // 📖 Resolve direct provider routing for non-fcm models.
1539
+ // 📖 Format: "providerKey/modelId" → look up source URL + API key.
1540
+ let directUrl = null
1541
+ let directApiKey = null
1542
+ let directModelId = null
1543
+ if (!isFcm) {
1544
+ const slashIdx = requestedModel.indexOf('/')
1545
+ if (slashIdx > 0) {
1546
+ const providerKey = requestedModel.slice(0, slashIdx)
1547
+ const modelId = requestedModel.slice(slashIdx + 1)
1548
+ const source = sources[providerKey]
1549
+ if (source?.url) {
1550
+ directApiKey = getApiKey(config, providerKey)
1551
+ // 📖 Always resolve the URL so we can differentiate "no key"
1552
+ // 📖 from "unknown model" in the error handler below.
1553
+ let baseUrl = source.url
1554
+ if (!baseUrl.includes('/chat/completions')) {
1555
+ baseUrl = baseUrl.replace(/\/+$/, '') + '/v1/chat/completions'
1556
+ }
1557
+ directUrl = baseUrl
1558
+ directModelId = modelId
1559
+ }
1560
+ }
1561
+ }
1562
+
1563
+ // 📖 Decide routing: direct to provider (non-fcm, has key) or via daemon.
1564
+ const useDirectRoute = !isFcm && directUrl && directApiKey
1565
+
1566
+ // 📖 If user picked a specific model but has no API key for it,
1567
+ // 📖 tell them instead of falling through to the daemon 503.
1568
+ if (!isFcm && directUrl && !directApiKey) {
1569
+ const providerKey = requestedModel.slice(0, requestedModel.indexOf('/'))
1570
+ sendJson(res, 401, { ok: false, error: `No API key configured for ${sources[providerKey]?.name || providerKey}. Add one in Settings to chat directly with this model.` })
1571
+ return
1572
+ }
1573
+
1574
+ // 📖 If user picked a specific model but we couldn't resolve it,
1575
+ // 📖 tell them instead of falling through to the daemon 503.
1576
+ if (!isFcm && !directUrl) {
1577
+ sendJson(res, 404, { ok: false, error: `Could not resolve provider for model "${requestedModel}". Try selecting a different model or use the auto-router (fcm).` })
1578
+ return
1579
+ }
1580
+ const upstreamUrl = useDirectRoute
1581
+ ? directUrl
1582
+ : `http://127.0.0.1:${await readDaemonPort()}/v1/chat/completions`
1583
+
1584
+ if (!useDirectRoute && !await readDaemonPort()) {
1371
1585
  sendJson(res, 503, { ok: false, error: 'Router daemon is not running. Start it from the Router card or with `free-coding-models --daemon-bg`.' })
1372
1586
  return
1373
1587
  }
1374
- const body = await readJsonBody(req)
1375
- const wantsStream = body?.stream === true
1376
- const upstreamUrl = `http://127.0.0.1:${port}/v1/chat/completions`
1588
+
1589
+ // 📖 Build the upstream payload. For direct routing, extract the
1590
+ // 📖 actual model ID from the providerKey/modelId format.
1591
+ const upstreamPayload = {
1592
+ ...body,
1593
+ stream: wantsStream,
1594
+ model: useDirectRoute ? directModelId : requestedModel,
1595
+ }
1596
+
1597
+ // 📖 Build headers: direct routing needs auth + any provider-specific
1598
+ // 📖 headers. Daemon routing is just JSON content-type.
1599
+ const upstreamHeaders = { 'Content-Type': 'application/json' }
1600
+ if (useDirectRoute) {
1601
+ // 📖 Most providers use standard Bearer auth. A few need extra
1602
+ // 📖 headers (OpenRouter wants Referer + X-Title for ex).
1603
+ upstreamHeaders['Authorization'] = `Bearer ${directApiKey}`
1604
+ const providerKey = requestedModel.slice(0, requestedModel.indexOf('/'))
1605
+ if (providerKey === 'openrouter') {
1606
+ upstreamHeaders['HTTP-Referer'] = 'https://github.com/vava-nessa/free-coding-models'
1607
+ upstreamHeaders['X-Title'] = 'free-coding-models'
1608
+ }
1609
+ }
1610
+
1377
1611
  const controller = new AbortController()
1378
1612
  const timeout = setTimeout(() => controller.abort(), 120000)
1379
1613
  req.on('close', () => controller.abort())
1380
1614
  try {
1381
1615
  const upstreamResp = await fetch(upstreamUrl, {
1382
1616
  method: 'POST',
1383
- headers: { 'Content-Type': 'application/json' },
1384
- body: JSON.stringify({ ...body, stream: wantsStream, model: body?.model || 'fcm' }),
1617
+ headers: upstreamHeaders,
1618
+ body: JSON.stringify(upstreamPayload),
1385
1619
  signal: controller.signal,
1386
1620
  })
1387
1621
  if (wantsStream) {
1388
- // 📖 Pipe SSE events straight from the daemon to the browser.
1622
+ // 📖 Pipe SSE events straight from the upstream to the browser.
1389
1623
  res.writeHead(upstreamResp.status, {
1390
1624
  'Content-Type': 'text/event-stream',
1391
1625
  'Cache-Control': 'no-cache',
@@ -1407,7 +1641,7 @@ async function handleRequest(req, res) {
1407
1641
  return
1408
1642
  }
1409
1643
  const json = await upstreamResp.json().catch(() => null)
1410
- sendJson(res, upstreamResp.status, json || { ok: false, error: 'Empty response from router' })
1644
+ sendJson(res, upstreamResp.status, json || { ok: false, error: 'Empty response from upstream' })
1411
1645
  return
1412
1646
  } catch (err) {
1413
1647
  sendJson(res, 502, { ok: false, error: `Playground proxy failed: ${err.message || String(err)}` })
package/web/src/App.jsx CHANGED
@@ -77,6 +77,31 @@ export default function App() {
77
77
  const [toasts, setToasts] = useState([])
78
78
  const lastActivityRef = useRef(Date.now())
79
79
 
80
+ // 📖 PostHog: track app_web_start on mount
81
+ useEffect(() => {
82
+ try {
83
+ if (typeof window !== 'undefined' && window.posthog?.capture) {
84
+ window.posthog.capture('app_web_start', {
85
+ version: __APP_VERSION__ || 'unknown',
86
+ timestamp: new Date().toISOString(),
87
+ })
88
+ }
89
+ } catch {}
90
+ }, [])
91
+
92
+ // 📖 PostHog: track app_router_start when router opens
93
+ const handleRouterOpen = useCallback(() => {
94
+ setRouterOpen(true)
95
+ try {
96
+ if (typeof window !== 'undefined' && window.posthog?.capture) {
97
+ window.posthog.capture('app_router_start', {
98
+ version: __APP_VERSION__ || 'unknown',
99
+ timestamp: new Date().toISOString(),
100
+ })
101
+ }
102
+ } catch {}
103
+ }, [])
104
+
80
105
  // ── Toast helpers ────────────────────────────────────────────────────────
81
106
  const addToast = useCallback((message, type = 'info') => {
82
107
  const id = ++toastIdCounter
@@ -171,11 +196,25 @@ export default function App() {
171
196
  } = useUpdateChecker({ onToast: addToast })
172
197
 
173
198
  // 📖 Build the provider list for the FilterBar dropdown.
199
+ // 📖 Build the provider list for the FilterBar dropdown with aggregated health.
200
+ // 📖 `hasKey` = at least one model has an API key configured.
201
+ // 📖 `anyUp` = at least one model with status 'up' (key works).
202
+ // 📖 These drive the colored indicator dot in the custom provider dropdown.
174
203
  const providers = useMemo(() => {
175
204
  const map = {}
176
205
  models.forEach((m) => {
177
- if (!map[m.providerKey]) map[m.providerKey] = { key: m.providerKey, name: m.origin, count: 0 }
206
+ if (!map[m.providerKey]) {
207
+ map[m.providerKey] = {
208
+ key: m.providerKey,
209
+ name: m.origin,
210
+ count: 0,
211
+ hasKey: false,
212
+ anyUp: false,
213
+ }
214
+ }
178
215
  map[m.providerKey].count++
216
+ if (m.hasApiKey) map[m.providerKey].hasKey = true
217
+ if (m.status === 'up') map[m.providerKey].anyUp = true
179
218
  })
180
219
  return Object.values(map).sort((a, b) => a.name.localeCompare(b.name))
181
220
  }, [models])
@@ -234,7 +273,7 @@ export default function App() {
234
273
  if (viewId === 'help') { setHelpOpen(true); return }
235
274
  if (viewId === 'changelog') { setChangelogOpen(true); setChangelogDefaultVersion(null); return }
236
275
  if (viewId === 'recommend') { setRecommendOpen(true); return }
237
- if (viewId === 'router') { setRouterOpen(true); return }
276
+ if (viewId === 'router') { handleRouterOpen(); return }
238
277
  if (viewId === 'playground') { setPlaygroundOpen(true); return }
239
278
  if (viewId === 'install-endpoints') { setInstallEndpointsOpen(true); return }
240
279
  if (viewId === 'installed-models') { setInstalledModelsOpen(true); return }
@@ -365,6 +404,10 @@ export default function App() {
365
404
  sortDirection={sortDirection}
366
405
  onSort={toggleSort}
367
406
  toolMode={toolMode}
407
+ onToast={addToast}
408
+ onSetToolMode={setToolMode}
409
+ onCycleToolMode={cycleToolMode}
410
+ onOpenFallback={(model) => setIncompatibleRequest({ model, toolMode })}
368
411
  />
369
412
  </main>
370
413
  )}
@@ -389,19 +432,7 @@ export default function App() {
389
432
  </div>
390
433
  </div>
391
434
 
392
- <DetailPanel
393
- model={selectedModel}
394
- onClose={handleCloseDetail}
395
- favorites={favorites}
396
- onBenchmark={handleBenchmarkRow}
397
- onLaunch={handleInstallEndpoint}
398
- toolMode={toolMode}
399
- onSetToolMode={setToolMode}
400
- onCycleToolMode={cycleToolMode}
401
- models={models}
402
- onOpenFallback={(model) => setIncompatibleRequest({ model, toolMode })}
403
- onToast={addToast}
404
- />
435
+ {/* 📖 DetailPanel side panel replaced by expand row in ModelTable */}
405
436
 
406
437
  {exportOpen && (
407
438
  <ExportModal
@@ -87,7 +87,7 @@ export default function DetailPanel({
87
87
  const compatible = isModelCompatibleWithTool(model.providerKey, toolMode)
88
88
 
89
89
  return (
90
- <div className={styles.panel}>
90
+ <div className={`${styles.panel} ${styles.panelOpen}`}>
91
91
  <div className={styles.header}>
92
92
  <h3 className={styles.title}>{model.label}</h3>
93
93
  <button className={styles.closeBtn} onClick={onClose}>&times;</button>
@@ -13,6 +13,11 @@
13
13
  overflow-y: auto;
14
14
  }
15
15
 
16
+ /* 📖 Slide-in when model is selected (parent passes a non-null model) */
17
+ .panelOpen {
18
+ transform: translateX(0);
19
+ }
20
+
16
21
  .header {
17
22
  display: flex;
18
23
  align-items: center;