free-coding-models 0.5.4 β†’ 0.5.6

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 (33) hide show
  1. package/README.md +8 -5
  2. package/bin/free-coding-models.js +29 -8
  3. package/changelog/v0.5.5.md +16 -0
  4. package/changelog/v0.5.6.md +24 -0
  5. package/package.json +4 -4
  6. package/src/core/changelog-loader.js +5 -1
  7. package/src/core/router-daemon.js +11 -0
  8. package/src/core/updater.js +174 -10
  9. package/src/tui/app.js +11 -31
  10. package/src/tui/render-table.js +9 -3
  11. package/src/tui/tui-state.js +6 -0
  12. package/web/dist/assets/index-Blp9QJev.js +39 -0
  13. package/web/dist/assets/{index-BrpHevg4.css β†’ index-Cz_aCLTR.css} +1 -1
  14. package/web/dist/index.html +2 -2
  15. package/web/server.js +158 -2
  16. package/web/src/App.jsx +107 -58
  17. package/web/src/components/changelog/ChangelogView.jsx +135 -0
  18. package/web/src/components/changelog/ChangelogView.module.css +160 -0
  19. package/web/src/components/help/HelpView.jsx +188 -0
  20. package/web/src/components/help/HelpView.module.css +157 -0
  21. package/web/src/components/layout/Header.jsx +8 -3
  22. package/web/src/components/palette/CommandPalette.jsx +228 -74
  23. package/web/src/components/settings/SettingsView.jsx +281 -8
  24. package/web/src/components/settings/SettingsView.module.css +174 -0
  25. package/web/src/components/update/UpdateChip.jsx +104 -0
  26. package/web/src/components/update/UpdateChip.module.css +146 -0
  27. package/web/src/global.css +15 -0
  28. package/web/src/hooks/urlState.constants.js +25 -0
  29. package/web/src/hooks/useChangelog.js +51 -0
  30. package/web/src/hooks/useSocket.js +3 -0
  31. package/web/src/hooks/useUpdateChecker.js +91 -0
  32. package/web/src/hooks/useUrlState.js +122 -62
  33. package/web/dist/assets/index-BoWmUveV.js +0 -39
@@ -1,21 +1,40 @@
1
1
  /**
2
2
  * @file web/src/components/settings/SettingsView.jsx
3
- * @description Full settings page for managing API keys and provider configurations.
4
- * πŸ“– Fetches config from /api/config, renders expandable provider cards with
5
- * key display (masked/revealed), save/delete/toggle actions, and search filter.
3
+ * @description Full settings page β€” M2 parity with the TUI Settings overlay.
4
+ * πŸ“– M1: API key management (per-provider cards: enable/disable, masked key,
5
+ * πŸ“– reveal, copy, save, delete, search filter).
6
+ * πŸ“– M2: theme dropdown, favorites display mode toggle, startup AI speed scan
7
+ * πŸ“– toggle, shell-env export toggle, legacy proxy cleanup button, per-provider
8
+ * πŸ“– test key button (calls /api/key/:provider/test), open Changelog link,
9
+ * πŸ“– update status row.
6
10
  * @functions SettingsView β†’ main settings page component
7
11
  */
8
12
  import { useState, useEffect, useCallback } from 'react'
9
- import { IconSettings, IconPlug, IconCircleCheck, IconKey, IconEye, IconEyeOff, IconCopy, IconTrash } from '@tabler/icons-react'
13
+ import {
14
+ IconSettings, IconPlug, IconCircleCheck, IconKey, IconEye, IconEyeOff, IconCopy, IconTrash,
15
+ IconBolt, IconCircleCheckFilled, IconHistory, IconRefresh, IconDownload, IconSun, IconStar,
16
+ } from '@tabler/icons-react'
10
17
  import styles from './SettingsView.module.css'
11
18
  import { maskKey } from '../../utils/format.js'
12
19
 
13
- export default function SettingsView({ onToast }) {
20
+ const TEST_OUTCOME_META = {
21
+ ok: { label: 'OK', icon: IconCircleCheckFilled, className: 'testOk' },
22
+ auth_error: { label: 'Auth error', icon: IconKey, className: 'testErr' },
23
+ rate_limited: { label: 'Rate limited', icon: IconRefresh, className: 'testWarn' },
24
+ no_callable_model: { label: 'No callable model', icon: IconKey, className: 'testWarn' },
25
+ fail: { label: 'Failed', icon: IconKey, className: 'testErr' },
26
+ missing_key: { label: 'Missing key', icon: IconKey, className: 'testNeutral' },
27
+ }
28
+
29
+ export default function SettingsView({ onToast, onOpenChangelog, onCheckForUpdate }) {
14
30
  const [config, setConfig] = useState(null)
15
31
  const [searchQuery, setSearchQuery] = useState('')
16
32
  const [expandedCards, setExpandedCards] = useState(new Set())
17
33
  const [revealedKeys, setRevealedKeys] = useState(new Set())
18
34
  const [keyInputs, setKeyInputs] = useState({})
35
+ const [testResults, setTestResults] = useState({}) // { providerKey: { outcome, code?, detail? } }
36
+ const [testingKeys, setTestingKeys] = useState(new Set())
37
+ const [legacyCleanupMsg, setLegacyCleanupMsg] = useState(null)
19
38
 
20
39
  const loadConfig = useCallback(async () => {
21
40
  try {
@@ -142,6 +161,100 @@ export default function SettingsView({ onToast }) {
142
161
  }
143
162
  }
144
163
 
164
+ // πŸ“– M2: per-provider key test. Fires a parallel auth probe + chat ping
165
+ // πŸ“– through /api/key/:provider/test and stores the outcome for badge display.
166
+ const testKey = useCallback(async (key) => {
167
+ if (testingKeys.has(key)) return
168
+ setTestingKeys((prev) => new Set(prev).add(key))
169
+ setTestResults((prev) => ({ ...prev, [key]: { outcome: 'pending' } }))
170
+ try {
171
+ const resp = await fetch(`/api/key/${encodeURIComponent(key)}/test`, { method: 'POST' })
172
+ const data = await resp.json().catch(() => ({}))
173
+ if (resp.ok) {
174
+ setTestResults((prev) => ({ ...prev, [key]: data }))
175
+ const meta = TEST_OUTCOME_META[data.outcome] || TEST_OUTCOME_META.fail
176
+ onToast?.(`${key} test: ${meta.label}${data.code ? ` (HTTP ${data.code})` : ''}`, data.outcome === 'ok' ? 'success' : 'info')
177
+ } else {
178
+ setTestResults((prev) => ({ ...prev, [key]: { outcome: 'fail', detail: data.error || 'HTTP ' + resp.status } }))
179
+ onToast?.(`${key} test failed: ${data.error || resp.statusText}`, 'error')
180
+ }
181
+ } catch (err) {
182
+ setTestResults((prev) => ({ ...prev, [key]: { outcome: 'fail', detail: err.message } }))
183
+ onToast?.(`${key} test failed: ${err.message}`, 'error')
184
+ } finally {
185
+ setTestingKeys((prev) => {
186
+ const next = new Set(prev)
187
+ next.delete(key)
188
+ return next
189
+ })
190
+ }
191
+ }, [testingKeys, onToast])
192
+
193
+ // πŸ“– M2: feature toggles (theme / favorites mode / startup AI scan / shell env)
194
+ // πŸ“– go through /api/settings/feature which persists to the same config file
195
+ // πŸ“– the TUI uses. Theme is a tri-state string, not a boolean.
196
+ const toggleFeature = useCallback(async (feature, value) => {
197
+ try {
198
+ const body = value === undefined ? { feature } : { feature, value }
199
+ const resp = await fetch('/api/settings/feature', {
200
+ method: 'POST',
201
+ headers: { 'Content-Type': 'application/json' },
202
+ body: JSON.stringify(body),
203
+ })
204
+ const data = await resp.json()
205
+ if (data.success) {
206
+ await loadConfig()
207
+ onToast?.(`${feature} updated`, 'success')
208
+ } else {
209
+ onToast?.(data.error || 'Failed to update feature', 'error')
210
+ }
211
+ } catch {
212
+ onToast?.('Network error', 'error')
213
+ }
214
+ }, [onToast])
215
+
216
+ const setShellEnv = useCallback(async (enabled) => {
217
+ try {
218
+ const resp = await fetch('/api/shell-env/toggle', {
219
+ method: 'POST',
220
+ headers: { 'Content-Type': 'application/json' },
221
+ body: JSON.stringify({ enabled }),
222
+ })
223
+ const data = await resp.json()
224
+ if (data.success) {
225
+ await loadConfig()
226
+ onToast?.(`Shell env export ${data.enabled ? 'enabled' : 'disabled'} β€” restart your shell to apply.`, 'success')
227
+ } else {
228
+ onToast?.(data.error || 'Failed to toggle shell env', 'error')
229
+ }
230
+ } catch {
231
+ onToast?.('Network error', 'error')
232
+ }
233
+ }, [onToast])
234
+
235
+ const runLegacyCleanup = useCallback(async () => {
236
+ if (!confirm('Remove discontinued proxy config leftovers? This is safe to run.')) return
237
+ try {
238
+ const resp = await fetch('/api/legacy-cleanup', { method: 'POST' })
239
+ const data = await resp.json()
240
+ const cleaned = (data.removedFiles?.length || 0) + (data.updatedFiles?.length || 0)
241
+ if (data.changed) {
242
+ setLegacyCleanupMsg(`Cleaned ${cleaned} legacy file(s). ${data.errors.length} error(s).`)
243
+ onToast?.(`Legacy proxy cleanup: ${cleaned} file(s) cleaned.`, 'success')
244
+ } else {
245
+ setLegacyCleanupMsg('No discontinued proxy config was found. You are on the stable direct-provider setup.')
246
+ onToast?.('No legacy proxy config found β€” already on stable setup.', 'info')
247
+ }
248
+ await loadConfig()
249
+ } catch (err) {
250
+ onToast?.(`Legacy cleanup failed: ${err.message}`, 'error')
251
+ }
252
+ }, [onToast])
253
+
254
+ const onCheckUpdatesClick = useCallback(() => {
255
+ onCheckForUpdate?.()
256
+ }, [onCheckForUpdate])
257
+
145
258
  if (!config) {
146
259
  return (
147
260
  <div className={styles.page}>
@@ -163,14 +276,150 @@ export default function SettingsView({ onToast }) {
163
276
  <div className={styles.pageHeader}>
164
277
  <h1 className={styles.pageTitle}>
165
278
  <IconSettings size={24} stroke={1.5} style={{ marginRight: 8, verticalAlign: 'middle' }} />
166
- Provider Settings
279
+ Settings
167
280
  </h1>
168
281
  <p className={styles.pageSubtitle}>
169
- Manage your API keys and provider configurations. Keys are stored locally in{' '}
170
- <code>~/.free-coding-models.json</code>
282
+ API keys, theme, favorites mode, shell env, and update controls.
283
+ All settings are stored locally in <code>~/.free-coding-models.json</code>
284
+ and shared with the TUI.
171
285
  </p>
172
286
  </div>
173
287
 
288
+ {/* ── M2: global feature toggles ────────────────────────────────────── */}
289
+ {config && (
290
+ <section className={styles.featureSection}>
291
+ <h2 className={styles.sectionHeading}>βš™οΈ Global settings</h2>
292
+ <div className={styles.featureGrid}>
293
+ {/* Theme */}
294
+ <div className={styles.featureRow}>
295
+ <div className={styles.featureLabel}>
296
+ <IconSun size={16} stroke={1.5} />
297
+ <div>
298
+ <div className={styles.featureTitle}>Theme</div>
299
+ <div className={styles.featureDesc}>Tri-state cycle. Auto follows your OS.</div>
300
+ </div>
301
+ </div>
302
+ <select
303
+ className={styles.select}
304
+ value={config.settings?.theme || 'auto'}
305
+ onChange={(e) => toggleFeature('theme', e.target.value)}
306
+ >
307
+ <option value="auto">Auto (OS)</option>
308
+ <option value="dark">Dark</option>
309
+ <option value="light">Light</option>
310
+ </select>
311
+ </div>
312
+
313
+ {/* Favorites display mode */}
314
+ <div className={styles.featureRow}>
315
+ <div className={styles.featureLabel}>
316
+ <IconStar size={16} stroke={1.5} />
317
+ <div>
318
+ <div className={styles.featureTitle}>Favorites pinned + always visible</div>
319
+ <div className={styles.featureDesc}>Favorites bypass filters and stay on top (TUI: Y key).</div>
320
+ </div>
321
+ </div>
322
+ <label className={styles.toggleSwitch}>
323
+ <input
324
+ type="checkbox"
325
+ checked={Boolean(config.settings?.favoritesPinnedAndSticky)}
326
+ onChange={(e) => toggleFeature('favoritesPinnedAndSticky', e.target.checked)}
327
+ />
328
+ <span className={styles.toggleSlider} />
329
+ </label>
330
+ </div>
331
+
332
+ {/* Startup AI Speed Scan */}
333
+ <div className={styles.featureRow}>
334
+ <div className={styles.featureLabel}>
335
+ <IconBolt size={16} stroke={1.5} />
336
+ <div>
337
+ <div className={styles.featureTitle}>Run AI Speed Test on startup</div>
338
+ <div className={styles.featureDesc}>Auto-fire the global benchmark right after launch (TUI: U β†’ 'Enable').</div>
339
+ </div>
340
+ </div>
341
+ <label className={styles.toggleSwitch}>
342
+ <input
343
+ type="checkbox"
344
+ checked={Boolean(config.settings?.runAiSpeedTestOnStartup)}
345
+ onChange={(e) => toggleFeature('runAiSpeedTestOnStartup', e.target.checked)}
346
+ />
347
+ <span className={styles.toggleSlider} />
348
+ </label>
349
+ </div>
350
+
351
+ {/* Shell env export */}
352
+ <div className={styles.featureRow}>
353
+ <div className={styles.featureLabel}>
354
+ <IconCircleCheck size={16} stroke={1.5} />
355
+ <div>
356
+ <div className={styles.featureTitle}>Export API keys to shell rc</div>
357
+ <div className={styles.featureDesc}>Write NVIDIA_API_KEY / GROQ_API_KEY / … to your shell rc file.</div>
358
+ </div>
359
+ </div>
360
+ <label className={styles.toggleSwitch}>
361
+ <input
362
+ type="checkbox"
363
+ checked={Boolean(config.settings?.shellEnvEnabled)}
364
+ onChange={(e) => setShellEnv(e.target.checked)}
365
+ />
366
+ <span className={styles.toggleSlider} />
367
+ </label>
368
+ </div>
369
+
370
+ {/* Update row */}
371
+ <div className={styles.featureRow}>
372
+ <div className={styles.featureLabel}>
373
+ <IconDownload size={16} stroke={1.5} />
374
+ <div>
375
+ <div className={styles.featureTitle}>Check for updates</div>
376
+ <div className={styles.featureDesc}>
377
+ The header chip turns green when a newer npm version is available.
378
+ Use the 'Update now' button to install.
379
+ </div>
380
+ </div>
381
+ </div>
382
+ <div className={styles.featureActions}>
383
+ <button
384
+ className={styles.smallBtn}
385
+ onClick={onCheckUpdatesClick}
386
+ >
387
+ Check now
388
+ </button>
389
+ <button
390
+ className={styles.smallBtn}
391
+ onClick={() => onOpenChangelog?.(null)}
392
+ >
393
+ <IconHistory size={13} stroke={1.5} /> Changelog
394
+ </button>
395
+ </div>
396
+ </div>
397
+
398
+ {/* Legacy proxy cleanup */}
399
+ <div className={styles.featureRow}>
400
+ <div className={styles.featureLabel}>
401
+ <IconRefresh size={16} stroke={1.5} />
402
+ <div>
403
+ <div className={styles.featureTitle}>Cleanup discontinued proxy artifacts</div>
404
+ <div className={styles.featureDesc}>
405
+ Remove old config / env / service leftovers from the old multi-tool proxy.
406
+ </div>
407
+ </div>
408
+ </div>
409
+ <button
410
+ className={styles.smallBtn}
411
+ onClick={runLegacyCleanup}
412
+ >
413
+ Run cleanup
414
+ </button>
415
+ </div>
416
+ </div>
417
+ {legacyCleanupMsg && (
418
+ <div className={styles.notice}>{legacyCleanupMsg}</div>
419
+ )}
420
+ </section>
421
+ )}
422
+
174
423
  <div className={styles.toolbar}>
175
424
  <div className={styles.toolbarSearch}>
176
425
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
@@ -221,6 +470,20 @@ export default function SettingsView({ onToast }) {
221
470
 
222
471
  <div className={styles.cardBody}>
223
472
  <div className={styles.cardContent}>
473
+ {p.hasKey && testResults[key] && (
474
+ <div className={`${styles.testBadge} ${styles[`test_${testResults[key].outcome}`] || ''}`}>
475
+ {(() => {
476
+ const meta = TEST_OUTCOME_META[testResults[key].outcome] || TEST_OUTCOME_META.fail
477
+ const Icon = meta.icon
478
+ return (
479
+ <>
480
+ <Icon size={12} stroke={1.5} />
481
+ <span>Last test: {meta.label}{testResults[key].code ? ` (HTTP ${testResults[key].code})` : ''}</span>
482
+ </>
483
+ )
484
+ })()}
485
+ </div>
486
+ )}
224
487
  {p.hasKey && (
225
488
  <div className={styles.keyGroup}>
226
489
  <label className={styles.keyLabel}>Current API Key</label>
@@ -235,6 +498,16 @@ export default function SettingsView({ onToast }) {
235
498
  <button className={styles.actionBtn} onClick={() => copyKey(key)} title="Copy">
236
499
  <IconCopy size={14} stroke={1.5} />
237
500
  </button>
501
+ <button
502
+ className={styles.actionBtn}
503
+ onClick={() => testKey(key)}
504
+ disabled={testingKeys.has(key)}
505
+ title="Test this key against the provider (TUI: T key in Settings)"
506
+ aria-label={`Test key for ${key}`}
507
+ >
508
+ {testingKeys.has(key) ? <span className={styles.testSpinner} /> : <IconBolt size={14} stroke={1.5} />}
509
+ {testingKeys.has(key) ? 'Testing…' : 'Test'}
510
+ </button>
238
511
  <button className={`${styles.actionBtn} ${styles.actionBtnDanger}`} onClick={() => deleteKey(key)} title="Delete Key">
239
512
  <IconTrash size={14} stroke={1.5} />
240
513
  </button>
@@ -33,6 +33,180 @@
33
33
  color: var(--color-text-muted);
34
34
  }
35
35
 
36
+ /* ─── M2: feature toggles section ─── */
37
+ .featureSection {
38
+ margin-bottom: 28px;
39
+ padding: 18px 20px;
40
+ background: var(--color-bg-card);
41
+ border: 1px solid var(--color-border);
42
+ border-radius: 12px;
43
+ }
44
+ .sectionHeading {
45
+ font-size: 11px;
46
+ font-weight: 700;
47
+ text-transform: uppercase;
48
+ letter-spacing: 0.6px;
49
+ color: var(--color-text-muted);
50
+ margin: 0 0 14px;
51
+ }
52
+ .featureGrid {
53
+ display: flex;
54
+ flex-direction: column;
55
+ gap: 4px;
56
+ }
57
+ .featureRow {
58
+ display: flex;
59
+ align-items: center;
60
+ gap: 14px;
61
+ padding: 10px 12px;
62
+ border: 1px solid transparent;
63
+ border-radius: 8px;
64
+ transition: background 120ms;
65
+ }
66
+ .featureRow:hover { background: var(--color-surface); }
67
+ .featureLabel {
68
+ display: flex;
69
+ align-items: flex-start;
70
+ gap: 10px;
71
+ flex: 1;
72
+ min-width: 0;
73
+ }
74
+ .featureLabel > svg {
75
+ flex-shrink: 0;
76
+ color: var(--color-text-muted);
77
+ margin-top: 2px;
78
+ }
79
+ .featureTitle {
80
+ font-size: 13px;
81
+ font-weight: 600;
82
+ color: var(--color-text);
83
+ line-height: 1.4;
84
+ }
85
+ .featureDesc {
86
+ font-size: 11px;
87
+ color: var(--color-text-muted);
88
+ line-height: 1.5;
89
+ margin-top: 2px;
90
+ }
91
+ .featureActions {
92
+ display: flex;
93
+ gap: 6px;
94
+ flex-shrink: 0;
95
+ }
96
+ .select {
97
+ background: var(--color-surface);
98
+ color: var(--color-text);
99
+ border: 1px solid var(--color-border);
100
+ border-radius: 5px;
101
+ padding: 5px 8px;
102
+ font-size: 12px;
103
+ font-family: var(--font-sans);
104
+ cursor: pointer;
105
+ outline: none;
106
+ min-width: 130px;
107
+ }
108
+ .select:focus { border-color: var(--color-accent); }
109
+
110
+ .toggleSwitch {
111
+ position: relative;
112
+ display: inline-block;
113
+ width: 36px;
114
+ height: 20px;
115
+ flex-shrink: 0;
116
+ }
117
+ .toggleSwitch input {
118
+ opacity: 0;
119
+ width: 0;
120
+ height: 0;
121
+ }
122
+ .toggleSlider {
123
+ position: absolute;
124
+ inset: 0;
125
+ background: var(--color-border);
126
+ border-radius: 20px;
127
+ cursor: pointer;
128
+ transition: background 150ms;
129
+ }
130
+ .toggleSlider::before {
131
+ content: '';
132
+ position: absolute;
133
+ left: 2px;
134
+ top: 2px;
135
+ width: 16px;
136
+ height: 16px;
137
+ background: var(--color-text);
138
+ border-radius: 50%;
139
+ transition: transform 150ms;
140
+ }
141
+ .toggleSwitch input:checked + .toggleSlider {
142
+ background: var(--color-accent);
143
+ }
144
+ .toggleSwitch input:checked + .toggleSlider::before {
145
+ transform: translateX(16px);
146
+ }
147
+
148
+ .smallBtn {
149
+ display: inline-flex;
150
+ align-items: center;
151
+ gap: 4px;
152
+ padding: 5px 10px;
153
+ font-size: 11px;
154
+ font-weight: 600;
155
+ font-family: var(--font-sans);
156
+ background: var(--color-surface);
157
+ color: var(--color-text);
158
+ border: 1px solid var(--color-border);
159
+ border-radius: 5px;
160
+ cursor: pointer;
161
+ white-space: nowrap;
162
+ }
163
+ .smallBtn:hover { background: var(--color-bg-hover); border-color: var(--color-text-muted); }
164
+ .smallBtn:disabled { opacity: 0.5; cursor: not-allowed; }
165
+
166
+ .notice {
167
+ margin-top: 12px;
168
+ padding: 8px 12px;
169
+ background: var(--color-info-dim);
170
+ color: var(--color-info);
171
+ border: 1px solid var(--color-info);
172
+ border-radius: 6px;
173
+ font-size: 12px;
174
+ line-height: 1.5;
175
+ }
176
+
177
+ /* ─── M2: per-provider test badge ─── */
178
+ .testBadge {
179
+ display: inline-flex;
180
+ align-items: center;
181
+ gap: 5px;
182
+ padding: 4px 8px;
183
+ border-radius: 5px;
184
+ font-size: 10px;
185
+ font-weight: 700;
186
+ font-family: var(--font-mono);
187
+ margin-bottom: 8px;
188
+ align-self: flex-start;
189
+ width: fit-content;
190
+ }
191
+ .testBadge.test_ok { background: var(--color-success-dim); color: var(--color-success); border: 1px solid var(--color-success); }
192
+ .testBadge.test_auth_error { background: var(--color-danger-dim); color: var(--color-danger); border: 1px solid var(--color-danger); }
193
+ .testBadge.test_rate_limited { background: var(--color-warning-dim); color: var(--color-warning); border: 1px solid var(--color-warning); }
194
+ .testBadge.test_no_callable_model { background: var(--color-warning-dim); color: var(--color-warning); border: 1px solid var(--color-warning); }
195
+ .testBadge.test_fail { background: var(--color-danger-dim); color: var(--color-danger); border: 1px solid var(--color-danger); }
196
+ .testBadge.test_missing_key { background: var(--color-surface); color: var(--color-text-muted); border: 1px solid var(--color-border); }
197
+ .testBadge.test_pending { background: var(--color-surface); color: var(--color-text-muted); border: 1px solid var(--color-border); }
198
+
199
+ .testSpinner {
200
+ display: inline-block;
201
+ width: 10px;
202
+ height: 10px;
203
+ border: 2px solid rgba(180, 0, 255, 0.3);
204
+ border-top-color: #b400ff;
205
+ border-radius: 50%;
206
+ animation: spin 0.8s linear infinite;
207
+ }
208
+ @keyframes spin { to { transform: rotate(360deg); } }
209
+
36
210
  .toolbar {
37
211
  display: flex;
38
212
  align-items: center;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @file web/src/components/update/UpdateChip.jsx
3
+ * @description Header update chip + popover β€” M2 parity with TUI's auto-update banner.
4
+ * πŸ“– Sits in the right side of the header (next to AI Latency, theme, export).
5
+ * πŸ“– Hidden when no update is available; shows "⬆ vX.Y.Z" when one is.
6
+ * πŸ“– Click β†’ popover with "Update now" + "What's new" (which opens the
7
+ * πŸ“– Changelog modal pre-focused on the new version).
8
+ *
9
+ * @functions
10
+ * β†’ UpdateChip β€” small badge with popover
11
+ */
12
+ import { useState, useRef, useEffect } from 'react'
13
+ import { IconDownload, IconHistory, IconX, IconExternalLink } from '@tabler/icons-react'
14
+ import styles from './UpdateChip.module.css'
15
+
16
+ export default function UpdateChip({ updateAvailable, latestVersion, onRunUpdate, onOpenChangelog, checking }) {
17
+ const [open, setOpen] = useState(false)
18
+ const ref = useRef(null)
19
+
20
+ // πŸ“– Close on outside click / Esc β€” same pattern as the header kebab menu.
21
+ useEffect(() => {
22
+ if (!open) return
23
+ const onClick = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false) }
24
+ const onKey = (e) => { if (e.key === 'Escape') setOpen(false) }
25
+ document.addEventListener('mousedown', onClick)
26
+ document.addEventListener('keydown', onKey)
27
+ return () => {
28
+ document.removeEventListener('mousedown', onClick)
29
+ document.removeEventListener('keydown', onKey)
30
+ }
31
+ }, [open])
32
+
33
+ // πŸ“– No chip when no update. The M1 plan keeps the chip honest β€” a fresh
34
+ // πŸ“– install should look clean, not noisy.
35
+ if (!updateAvailable) {
36
+ if (checking) {
37
+ return (
38
+ <span className={styles.checking} title="Checking for updates…">
39
+ <span className={styles.dot} />
40
+ </span>
41
+ )
42
+ }
43
+ return null
44
+ }
45
+
46
+ return (
47
+ <div className={styles.wrap} ref={ref}>
48
+ <button
49
+ className={styles.chip}
50
+ onClick={() => setOpen((o) => !o)}
51
+ title={`Update available: v${latestVersion}. Click to install.`}
52
+ aria-expanded={open}
53
+ aria-haspopup="dialog"
54
+ >
55
+ <IconDownload size={13} stroke={1.5} />
56
+ <span>v{latestVersion}</span>
57
+ </button>
58
+
59
+ {open && (
60
+ <div className={styles.popover} role="dialog" aria-label="Update available">
61
+ <div className={styles.popoverHeader}>
62
+ <div className={styles.popoverTitle}>
63
+ <IconDownload size={14} stroke={1.5} />
64
+ <span>Update available</span>
65
+ </div>
66
+ <button
67
+ className={styles.popoverClose}
68
+ onClick={() => setOpen(false)}
69
+ aria-label="Close"
70
+ >
71
+ <IconX size={14} stroke={1.5} />
72
+ </button>
73
+ </div>
74
+ <p className={styles.popoverBody}>
75
+ A newer version (<strong>v{latestVersion}</strong>) is available on npm.
76
+ After updating, restart the dashboard to pick up the changes.
77
+ </p>
78
+ <div className={styles.popoverActions}>
79
+ <button
80
+ className={styles.primaryAction}
81
+ onClick={() => {
82
+ setOpen(false)
83
+ onRunUpdate?.()
84
+ }}
85
+ >
86
+ <IconDownload size={13} stroke={1.5} />
87
+ <span>Update now</span>
88
+ </button>
89
+ <button
90
+ className={styles.secondaryAction}
91
+ onClick={() => {
92
+ setOpen(false)
93
+ onOpenChangelog?.(latestVersion)
94
+ }}
95
+ >
96
+ <IconHistory size={13} stroke={1.5} />
97
+ <span>What's new</span>
98
+ </button>
99
+ </div>
100
+ </div>
101
+ )}
102
+ </div>
103
+ )
104
+ }