free-coding-models 0.5.6 → 0.5.8

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 (48) hide show
  1. package/README.md +14 -9
  2. package/changelog/v0.5.7.md +20 -0
  3. package/package.json +1 -1
  4. package/src/core/endpoint-installer.js +3 -3
  5. package/src/core/tool-launchers.js +2 -2
  6. package/web/dist/assets/index-DKmmiDip.css +1 -0
  7. package/web/dist/assets/index-DzVt42Nf.js +39 -0
  8. package/web/dist/index.html +2 -2
  9. package/web/server.js +386 -1
  10. package/web/src/App.jsx +145 -26
  11. package/web/src/components/analytics/AnalyticsView.jsx +4 -0
  12. package/web/src/components/analytics/TokenUsagePanel.jsx +105 -0
  13. package/web/src/components/analytics/TokenUsagePanel.module.css +126 -0
  14. package/web/src/components/atoms/TierBadge.module.css +3 -3
  15. package/web/src/components/dashboard/DetailPanel.jsx +29 -4
  16. package/web/src/components/dashboard/DetailPanel.module.css +26 -0
  17. package/web/src/components/dashboard/FilterBar.jsx +17 -2
  18. package/web/src/components/dashboard/FilterBar.module.css +17 -0
  19. package/web/src/components/dashboard/ModelTable.jsx +17 -5
  20. package/web/src/components/dashboard/ModelTable.module.css +14 -1
  21. package/web/src/components/help/HelpView.jsx +1 -1
  22. package/web/src/components/install/InstallEndpointsView.jsx +278 -0
  23. package/web/src/components/install/InstallEndpointsView.module.css +351 -0
  24. package/web/src/components/installed/InstalledModelsView.jsx +89 -0
  25. package/web/src/components/installed/InstalledModelsView.module.css +200 -0
  26. package/web/src/components/launch/IncompatibleFallbackModal.jsx +69 -0
  27. package/web/src/components/launch/LaunchButton.jsx +30 -0
  28. package/web/src/components/launch/LaunchButton.module.css +35 -0
  29. package/web/src/components/launch/LaunchModal.module.css +125 -0
  30. package/web/src/components/layout/Header.jsx +70 -11
  31. package/web/src/components/layout/Header.module.css +62 -2
  32. package/web/src/components/recommend/RecommendView.jsx +121 -0
  33. package/web/src/components/recommend/RecommendView.module.css +55 -0
  34. package/web/src/components/router/RouterView.jsx +286 -0
  35. package/web/src/components/router/RouterView.module.css +357 -0
  36. package/web/src/components/tools/ToolPicker.jsx +86 -0
  37. package/web/src/components/tools/ToolPicker.module.css +84 -0
  38. package/web/src/global.css +23 -0
  39. package/web/src/hooks/urlState.constants.js +3 -0
  40. package/web/src/hooks/useInstalledModels.js +42 -0
  41. package/web/src/hooks/useRecommend.js +67 -0
  42. package/web/src/hooks/useRouterDashboard.js +133 -0
  43. package/web/src/hooks/useTokenUsage.js +103 -0
  44. package/web/src/hooks/useToolMode.js +77 -0
  45. package/web/src/hooks/useUrlState.js +4 -3
  46. package/web/src/utils/m3.js +55 -0
  47. package/web/dist/assets/index-Blp9QJev.js +0 -39
  48. package/web/dist/assets/index-Cz_aCLTR.css +0 -1
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { useState, useEffect, useMemo } from 'react'
10
10
  import { IconRefresh, IconX, IconFilter, IconCircleCheck, IconAlertTriangle, IconActivity, IconEye, IconStar } from '@tabler/icons-react'
11
+ import { getToolMeta } from '../../../../src/core/tool-metadata.js'
11
12
  import styles from './FilterBar.module.css'
12
13
 
13
14
  // 📖 Chip sets match the TUI cycles 1:1 (see useFilter.js). Keep these in sync.
@@ -109,6 +110,7 @@ export default function FilterBar({
109
110
  globalBenchmarkRunning,
110
111
  globalBenchmarkTotal,
111
112
  globalBenchmarkCompleted,
113
+ toolMode = 'opencode',
112
114
  }) {
113
115
  const [countdown, setCountdown] = useState(null)
114
116
 
@@ -124,6 +126,7 @@ export default function FilterBar({
124
126
  }, [nextPingAt])
125
127
 
126
128
  const countdownDisplay = countdown !== null ? formatCountdown(countdown) : null
129
+ const activeTool = getToolMeta(toolMode)
127
130
 
128
131
  // 📖 Custom text filter chip — sticks to the right of the search bar, with
129
132
  // 📖 an "X" to clear (TUI's `X` key behavior).
@@ -170,12 +173,14 @@ export default function FilterBar({
170
173
  <ChipRow label="Health" items={HEALTHS} value={filterHealth} onChange={setFilterHealth} />
171
174
 
172
175
  <div className={styles.group}>
173
- <label className={styles.filterLabel}>Visibility</label>
176
+ <label className={styles.filterLabel} htmlFor="visibility-select">Visibility</label>
174
177
  <select
178
+ id="visibility-select"
175
179
  className={styles.select}
176
180
  value={visibilityMode}
177
181
  onChange={(e) => setVisibilityMode(e.target.value)}
178
182
  title={VISIBILITY_MODES.find((v) => v.key === visibilityMode)?.hint}
183
+ aria-label="Visibility mode"
179
184
  >
180
185
  {VISIBILITY_MODES.map((v) => (
181
186
  <option key={v.key} value={v.key}>{v.label}</option>
@@ -184,11 +189,13 @@ export default function FilterBar({
184
189
  </div>
185
190
 
186
191
  <div className={styles.group}>
187
- <label className={styles.filterLabel}>Provider</label>
192
+ <label className={styles.filterLabel} htmlFor="provider-select">Provider</label>
188
193
  <select
194
+ id="provider-select"
189
195
  className={styles.providerSelect}
190
196
  value={filterProvider}
191
197
  onChange={(e) => setFilterProvider(e.target.value)}
198
+ aria-label="Filter by provider"
192
199
  >
193
200
  <option value="all">All Providers</option>
194
201
  {providers.map((p) => (
@@ -197,6 +204,14 @@ export default function FilterBar({
197
204
  </select>
198
205
  </div>
199
206
 
207
+ <div className={styles.group}>
208
+ <label className={styles.filterLabel}>Endpoint target</label>
209
+ <div className={styles.toolStatus} title="Active endpoint install target from the Header picker">
210
+ <span>{activeTool.emoji}</span>
211
+ <strong>{activeTool.label}</strong>
212
+ </div>
213
+ </div>
214
+
200
215
  <div className={styles.spacer} />
201
216
 
202
217
  {/* ── Custom text filter chip (TUI's Ctrl+P "Apply text filter") ── */}
@@ -49,6 +49,23 @@
49
49
  }
50
50
  .select:focus, .providerSelect:focus { border-color: var(--color-accent); }
51
51
 
52
+ .toolStatus {
53
+ display: inline-flex;
54
+ align-items: center;
55
+ gap: 6px;
56
+ padding: 4px 8px;
57
+ border: 1px solid var(--color-border);
58
+ border-radius: 5px;
59
+ background: var(--color-surface);
60
+ color: var(--color-text);
61
+ font-size: 11px;
62
+ white-space: nowrap;
63
+ }
64
+ .toolStatus strong {
65
+ font-size: 11px;
66
+ font-weight: 800;
67
+ }
68
+
52
69
  /* ── Spacer + reset ── */
53
70
  .spacer { flex: 1; }
54
71
 
@@ -35,6 +35,7 @@ import AILatencyCell from '../atoms/AILatencyCell.jsx'
35
35
  import TPSCell from '../atoms/TPSCell.jsx'
36
36
  import NoKeyIcon from '../atoms/NoKeyIcon.jsx'
37
37
  import ProviderLogo from '../atoms/ProviderLogo.jsx'
38
+ import LaunchButton from '../launch/LaunchButton.jsx'
38
39
 
39
40
  import { pingClass } from '../../utils/format.js'
40
41
  import { sweClass } from '../../utils/ranks.js'
@@ -172,7 +173,7 @@ function TrendCellRenderer({ row }) {
172
173
  // 📖 Note: `favorites` / `onBenchmarkRow` are read by the cell renderers below
173
174
  // 📖 via closure. We rebuild the columns list whenever any of these change so
174
175
  // 📖 the cells always see fresh values.
175
- const buildColumns = ({ favorites, onBenchmarkRow, onSelectModel }) => [
176
+ const buildColumns = ({ favorites, onBenchmarkRow, onSelectModel, onLaunch, toolMode }) => [
176
177
  // 📖 Star column — leftmost, mirrors the TUI's `F` key.
177
178
  colHelper.display({
178
179
  id: 'fav',
@@ -303,13 +304,24 @@ const buildColumns = ({ favorites, onBenchmarkRow, onSelectModel }) => [
303
304
  cell: TrendCellRenderer,
304
305
  enableSorting: true,
305
306
  }),
307
+ colHelper.display({
308
+ id: 'launch',
309
+ header: '🔌',
310
+ size: 42,
311
+ enableSorting: false,
312
+ cell: ({ row }) => (
313
+ <div className={styles.launchCell}>
314
+ <LaunchButton model={row.original} toolMode={toolMode} onLaunch={onLaunch} variant="icon" />
315
+ </div>
316
+ ),
317
+ }),
306
318
  ]
307
319
 
308
320
  // 📖 DEFAULT_COLUMN_SIZING: extracted from the columns above. This is the single
309
321
  // 📖 source of truth for both the table layout and the useColumnSizing hook's reset target.
310
322
  // 📖 The 'Reset columns' button restores these exact widths.
311
323
  // 📖 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 })
324
+ const PLACEHOLDER_COLUMNS = buildColumns({ favorites: { isFavorite: () => false, toggle: () => {} }, onBenchmarkRow: null, onSelectModel: null, onLaunch: null, toolMode: 'opencode' })
313
325
  export const DEFAULT_COLUMN_SIZING = PLACEHOLDER_COLUMNS.reduce((acc, c) => {
314
326
  if (c.id) acc[c.id] = c.size ?? 80
315
327
  return acc
@@ -330,7 +342,7 @@ function SortIcon({ column }) {
330
342
 
331
343
  // ─── Main component ───────────────────────────────────────────────────────────
332
344
  export default function ModelTable({
333
- filtered, onSelectModel, onBenchmarkRow, favorites,
345
+ filtered, onSelectModel, onBenchmarkRow, onLaunch, favorites,
334
346
  sortColumn, sortDirection, onSort,
335
347
  toolMode = null,
336
348
  }) {
@@ -359,8 +371,8 @@ export default function ModelTable({
359
371
  // 📖 Build the columns with the current favorites/onBenchmarkRow handlers.
360
372
  // 📖 Re-derive when favorites reference changes so the star cells re-render.
361
373
  const columns = useMemo(
362
- () => buildColumns({ favorites, onBenchmarkRow, onSelectModel }),
363
- [favorites, onBenchmarkRow, onSelectModel]
374
+ () => buildColumns({ favorites, onBenchmarkRow, onSelectModel, onLaunch, toolMode }),
375
+ [favorites, onBenchmarkRow, onSelectModel, onLaunch, toolMode]
364
376
  )
365
377
 
366
378
  // 📖 Column sizing state with localStorage persistence (see useColumnSizing.js).
@@ -239,7 +239,7 @@
239
239
  .modelMeta { flex: 1; min-width: 0; }
240
240
  .modelHeader { display: flex; align-items: center; gap: 5px; }
241
241
  .modelName { font-weight: 600; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
242
- .modelId { font-family: var(--font-mono); font-size: 9px; color: var(--color-text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
242
+ .modelId { font-family: var(--font-mono); font-size: 9px; color: var(--color-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
243
243
 
244
244
  /* 📖 Provider cell now uses <ProviderLogo /> (icon + wordmark) — see
245
245
  📖 atoms/ProviderLogo.module.css for the layout. The old bordered pill
@@ -330,4 +330,17 @@
330
330
  :global([data-theme="light"]) .incompatible td {
331
331
  background: rgba(200, 20, 58, 0.06) !important;
332
332
  color: #888 !important;
333
+ }
334
+
335
+ /* ─── M3: per-row endpoint install plug ─── */
336
+ .launchCell {
337
+ display: flex;
338
+ justify-content: center;
339
+ opacity: 0;
340
+ transition: opacity 120ms;
341
+ }
342
+ .table tbody tr:hover .launchCell,
343
+ .incompatible .launchCell,
344
+ .benchRow .launchCell {
345
+ opacity: 1;
333
346
  }
@@ -74,7 +74,7 @@ const SECTIONS = [
74
74
  id: 'tools',
75
75
  title: '🧰 Tool mode (M3)',
76
76
  items: [
77
- { key: 'Coming in M3', desc: 'Tool mode picker, per-row Launch button, missing-tool install prompt, incompatible-fallback modal' },
77
+ { key: 'M3 shipped', desc: 'Endpoint target picker, per-row Install Endpoint button, and incompatible-target fallback modal' },
78
78
  ],
79
79
  },
80
80
  {
@@ -0,0 +1,278 @@
1
+ /**
2
+ * @file web/src/components/install/InstallEndpointsView.jsx
3
+ * @description Install Endpoints wizard — 4-step modal: provider → tool → models → install.
4
+ * 📖 M4: Full TUI parity for the install endpoints overlay.
5
+ * 📖 Uses existing /api/install-endpoints/providers, /api/install-endpoints/catalog,
6
+ * 📖 and /api/install-endpoints/wizard endpoints.
7
+ */
8
+ import { useState, useEffect, useCallback } from 'react'
9
+ import {
10
+ IconPlug, IconChevronRight, IconChevronLeft, IconCheck,
11
+ IconLoader,
12
+ } from '@tabler/icons-react'
13
+ import styles from './InstallEndpointsView.module.css'
14
+
15
+ const STEPS = ['Provider', 'Tool', 'Models', 'Install']
16
+
17
+ export default function InstallEndpointsView({ onClose, onToast }) {
18
+ const [step, setStep] = useState(0)
19
+ const [providers, setProviders] = useState([])
20
+ const [selectedProvider, setSelectedProvider] = useState(null)
21
+ const [catalogModels, setCatalogModels] = useState([])
22
+ const [selectedTool, setSelectedTool] = useState(null)
23
+ const [selectedModels, setSelectedModels] = useState([])
24
+ const [scope, setScope] = useState('all')
25
+ const [installing, setInstalling] = useState(false)
26
+ const [installResult, setInstallResult] = useState(null)
27
+ const [loading, setLoading] = useState(true)
28
+
29
+ // 📖 Available install targets from ToolPicker's mode list
30
+ const TOOLS = [
31
+ { id: 'opencode', label: 'OpenCode CLI', emoji: '💻' },
32
+ { id: 'opencode-desktop', label: 'OpenCode Desktop', emoji: '🖥️' },
33
+ { id: 'openclaw', label: 'OpenClaw', emoji: '🦞' },
34
+ { id: 'crush', label: 'Crush', emoji: '💘' },
35
+ { id: 'goose', label: 'Goose', emoji: '🪿' },
36
+ { id: 'pi', label: 'Pi', emoji: 'π' },
37
+ { id: 'aider', label: 'Aider', emoji: '🛠' },
38
+ { id: 'qwen', label: 'Qwen', emoji: '🐉' },
39
+ { id: 'openhands', label: 'OpenHands', emoji: '🤲' },
40
+ { id: 'amp', label: 'Amp', emoji: '⚡' },
41
+ { id: 'forgecode', label: 'ForgeCode', emoji: '🔥' },
42
+ { id: 'fcm_router', label: 'FCM Router', emoji: '🔄' },
43
+ ]
44
+
45
+ // Load providers on mount
46
+ useEffect(() => {
47
+ fetch('/api/install-endpoints/providers')
48
+ .then(r => r.json())
49
+ .then(data => {
50
+ setProviders(data.providers || [])
51
+ setLoading(false)
52
+ })
53
+ .catch(() => setLoading(false))
54
+ }, [])
55
+
56
+ // Load catalog when provider selected
57
+ useEffect(() => {
58
+ if (!selectedProvider) return
59
+ fetch(`/api/install-endpoints/catalog?provider=${selectedProvider.providerKey}`)
60
+ .then(r => r.json())
61
+ .then(data => setCatalogModels(data.models || []))
62
+ .catch(() => setCatalogModels([]))
63
+ }, [selectedProvider])
64
+
65
+ const toggleModel = useCallback((modelId) => {
66
+ setSelectedModels(prev =>
67
+ prev.includes(modelId)
68
+ ? prev.filter(id => id !== modelId)
69
+ : [...prev, modelId]
70
+ )
71
+ }, [])
72
+
73
+ const selectAll = useCallback(() => {
74
+ setSelectedModels(catalogModels.map(m => m.modelId))
75
+ setScope('all')
76
+ }, [catalogModels])
77
+
78
+ const selectNone = useCallback(() => {
79
+ setSelectedModels([])
80
+ setScope('selected')
81
+ }, [])
82
+
83
+ const canNext = () => {
84
+ if (step === 0) return !!selectedProvider
85
+ if (step === 1) return !!selectedTool
86
+ if (step === 2) return scope === 'all' || selectedModels.length > 0
87
+ return true
88
+ }
89
+
90
+ const handleInstall = async () => {
91
+ setInstalling(true)
92
+ try {
93
+ const resp = await fetch('/api/install-endpoints/wizard', {
94
+ method: 'POST',
95
+ headers: { 'Content-Type': 'application/json' },
96
+ body: JSON.stringify({
97
+ providerKey: selectedProvider.providerKey,
98
+ toolMode: selectedTool,
99
+ scope,
100
+ modelIds: scope === 'selected' ? selectedModels : [],
101
+ }),
102
+ })
103
+ const data = await resp.json()
104
+ if (data.success) {
105
+ setInstallResult(data)
106
+ onToast?.(`Installed ${data.modelCount} models for ${selectedProvider.label} into ${TOOLS.find(t => t.id === selectedTool)?.label || selectedTool}.`, 'success')
107
+ } else {
108
+ onToast?.(`Install failed: ${data.error || 'unknown'}`, 'error')
109
+ }
110
+ } catch (err) {
111
+ onToast?.(`Install error: ${err.message}`, 'error')
112
+ } finally {
113
+ setInstalling(false)
114
+ }
115
+ }
116
+
117
+ const handleNext = () => {
118
+ if (step === 2) {
119
+ handleInstall()
120
+ return
121
+ }
122
+ setStep(s => Math.min(s + 1, 3))
123
+ }
124
+
125
+ const handleBack = () => {
126
+ setStep(s => Math.max(s - 1, 0))
127
+ }
128
+
129
+ return (
130
+ <div className={styles.overlay} onClick={(e) => e.target === e.currentTarget && onClose()}>
131
+ <div className={styles.modal}>
132
+ <div className={styles.header}>
133
+ <h2 className={styles.title}>
134
+ <IconPlug size={20} stroke={1.5} />
135
+ Install Endpoints
136
+ </h2>
137
+ <button className={styles.closeBtn} onClick={onClose} aria-label="Close">✕</button>
138
+ </div>
139
+
140
+ {/* Step indicator */}
141
+ <div className={styles.steps}>
142
+ {STEPS.map((label, i) => (
143
+ <div key={i} className={`${styles.step} ${i === step ? styles.stepActive : i < step ? styles.stepDone : ''}`}>
144
+ <span className={styles.stepNum}>{i < step ? <IconCheck size={12} /> : i + 1}</span>
145
+ <span className={styles.stepLabel}>{label}</span>
146
+ </div>
147
+ ))}
148
+ </div>
149
+
150
+ <div className={styles.body}>
151
+ {/* Step 0: Select Provider */}
152
+ {step === 0 && (
153
+ <div className={styles.stepContent}>
154
+ <p className={styles.stepDesc}>Choose a provider with a configured API key.</p>
155
+ {loading && <div className={styles.loading}>Loading providers…</div>}
156
+ {!loading && providers.length === 0 && (
157
+ <div className={styles.empty}>No configured providers found. Add API keys in Settings first.</div>
158
+ )}
159
+ <div className={styles.grid}>
160
+ {providers.map((p) => (
161
+ <button
162
+ key={p.providerKey}
163
+ className={`${styles.pickCard} ${selectedProvider?.providerKey === p.providerKey ? styles.pickActive : ''}`}
164
+ onClick={() => { setSelectedProvider(p); setStep(1) }}
165
+ >
166
+ <span className={styles.pickLabel}>{p.label}</span>
167
+ <span className={styles.pickMeta}>{p.modelCount} models</span>
168
+ </button>
169
+ ))}
170
+ </div>
171
+ </div>
172
+ )}
173
+
174
+ {/* Step 1: Select Tool */}
175
+ {step === 1 && (
176
+ <div className={styles.stepContent}>
177
+ <p className={styles.stepDesc}>
178
+ Install <strong>{selectedProvider?.label}</strong> models into which tool?
179
+ </p>
180
+ <div className={styles.grid}>
181
+ {TOOLS.map((tool) => (
182
+ <button
183
+ key={tool.id}
184
+ className={`${styles.pickCard} ${selectedTool === tool.id ? styles.pickActive : ''}`}
185
+ onClick={() => { setSelectedTool(tool.id); setStep(2) }}
186
+ >
187
+ <span className={styles.pickEmoji}>{tool.emoji}</span>
188
+ <span className={styles.pickLabel}>{tool.label}</span>
189
+ </button>
190
+ ))}
191
+ </div>
192
+ </div>
193
+ )}
194
+
195
+ {/* Step 2: Select Models */}
196
+ {step === 2 && (
197
+ <div className={styles.stepContent}>
198
+ <p className={styles.stepDesc}>
199
+ Choose models to install from <strong>{selectedProvider?.label}</strong> into <strong>{TOOLS.find(t => t.id === selectedTool)?.label}</strong>.
200
+ </p>
201
+ <div className={styles.scopeToggle}>
202
+ <button className={`${styles.scopeBtn} ${scope === 'all' ? styles.scopeActive : ''}`} onClick={() => { setScope('all'); selectAll() }}>All Models</button>
203
+ <button className={`${styles.scopeBtn} ${scope === 'selected' ? styles.scopeActive : ''}`} onClick={() => setScope('selected')}>Select Models</button>
204
+ </div>
205
+ {scope === 'selected' && (
206
+ <div className={styles.modelGrid}>
207
+ {catalogModels.map((model) => (
208
+ <button
209
+ key={model.modelId}
210
+ className={`${styles.modelCard} ${selectedModels.includes(model.modelId) ? styles.modelSelected : ''}`}
211
+ onClick={() => toggleModel(model.modelId)}
212
+ >
213
+ <span className={styles.modelName}>{model.label}</span>
214
+ <span className={styles.modelTier}>{model.tier}</span>
215
+ </button>
216
+ ))}
217
+ </div>
218
+ )}
219
+ {scope === 'all' && (
220
+ <div className={styles.allNotice}>
221
+ All {catalogModels.length} models from {selectedProvider?.label} will be installed.
222
+ </div>
223
+ )}
224
+ </div>
225
+ )}
226
+
227
+ {/* Step 3: Installing / Done */}
228
+ {step === 3 && (
229
+ <div className={styles.stepContent}>
230
+ {installing && (
231
+ <div className={styles.installingState}>
232
+ <IconLoader size={24} className={styles.spinner} />
233
+ <span>Installing {scope === 'all' ? 'all' : selectedModels.length} models…</span>
234
+ </div>
235
+ )}
236
+ {installResult && (
237
+ <div className={styles.doneState}>
238
+ <IconCheck size={32} color="#00c864" />
239
+ <h3>Installation Complete</h3>
240
+ <p>{installResult.modelCount} models installed into {installResult.toolLabel}</p>
241
+ <code className={styles.installPath}>{installResult.path}</code>
242
+ </div>
243
+ )}
244
+ </div>
245
+ )}
246
+ </div>
247
+
248
+ {/* Footer nav */}
249
+ <div className={styles.footer}>
250
+ {step > 0 && step < 3 && (
251
+ <button className={styles.backBtn} onClick={handleBack}>
252
+ <IconChevronLeft size={14} />
253
+ Back
254
+ </button>
255
+ )}
256
+ {step < 3 && (
257
+ <button className={styles.nextBtn} onClick={handleNext} disabled={!canNext()}>
258
+ {step === 2 ? (
259
+ <>
260
+ <IconPlug size={14} />
261
+ Install
262
+ </>
263
+ ) : (
264
+ <>
265
+ Next
266
+ <IconChevronRight size={14} />
267
+ </>
268
+ )}
269
+ </button>
270
+ )}
271
+ {step === 3 && installResult && (
272
+ <button className={styles.doneBtn} onClick={onClose}>Done</button>
273
+ )}
274
+ </div>
275
+ </div>
276
+ </div>
277
+ )
278
+ }