bosun 0.26.3

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 (122) hide show
  1. package/.env.example +918 -0
  2. package/LICENSE +190 -0
  3. package/README.md +98 -0
  4. package/agent-endpoint.mjs +918 -0
  5. package/agent-hook-bridge.mjs +230 -0
  6. package/agent-hooks.mjs +1188 -0
  7. package/agent-pool.mjs +2403 -0
  8. package/agent-prompts.mjs +689 -0
  9. package/agent-sdk.mjs +141 -0
  10. package/anomaly-detector.mjs +1195 -0
  11. package/autofix.mjs +1294 -0
  12. package/bosun.config.example.json +115 -0
  13. package/bosun.schema.json +465 -0
  14. package/claude-shell.mjs +708 -0
  15. package/cli.mjs +1028 -0
  16. package/codex-config.mjs +1274 -0
  17. package/codex-model-profiles.mjs +135 -0
  18. package/codex-shell.mjs +762 -0
  19. package/compat.mjs +286 -0
  20. package/config-doctor.mjs +613 -0
  21. package/config.mjs +1724 -0
  22. package/conflict-resolver.mjs +248 -0
  23. package/container-runner.mjs +450 -0
  24. package/copilot-shell.mjs +827 -0
  25. package/daemon-restart-policy.mjs +56 -0
  26. package/diff-stats.mjs +282 -0
  27. package/error-detector.mjs +829 -0
  28. package/fetch-runtime.mjs +34 -0
  29. package/fleet-coordinator.mjs +838 -0
  30. package/get-telegram-chat-id.mjs +71 -0
  31. package/git-safety.mjs +170 -0
  32. package/github-reconciler.mjs +403 -0
  33. package/hook-profiles.mjs +651 -0
  34. package/kanban-adapter.mjs +4491 -0
  35. package/lib/logger.mjs +645 -0
  36. package/maintenance.mjs +828 -0
  37. package/merge-strategy.mjs +1171 -0
  38. package/monitor.mjs +12237 -0
  39. package/package.json +209 -0
  40. package/postinstall.mjs +187 -0
  41. package/pr-cleanup-daemon.mjs +978 -0
  42. package/preflight.mjs +408 -0
  43. package/prepublish-check.mjs +90 -0
  44. package/presence.mjs +328 -0
  45. package/primary-agent.mjs +290 -0
  46. package/publish.mjs +241 -0
  47. package/repo-root.mjs +29 -0
  48. package/restart-controller.mjs +100 -0
  49. package/review-agent.mjs +557 -0
  50. package/rotate-agent-logs.sh +133 -0
  51. package/sdk-conflict-resolver.mjs +973 -0
  52. package/session-tracker.mjs +880 -0
  53. package/setup.mjs +3946 -0
  54. package/shared-knowledge.mjs +410 -0
  55. package/shared-state-manager.mjs +841 -0
  56. package/shared-workspace-cli.mjs +199 -0
  57. package/shared-workspace-registry.mjs +537 -0
  58. package/shared-workspaces.json +18 -0
  59. package/startup-service.mjs +1070 -0
  60. package/sync-engine.mjs +1063 -0
  61. package/task-archiver.mjs +801 -0
  62. package/task-assessment.mjs +550 -0
  63. package/task-claims.mjs +924 -0
  64. package/task-complexity.mjs +581 -0
  65. package/task-executor.mjs +5111 -0
  66. package/task-store.mjs +753 -0
  67. package/telegram-bot.mjs +9683 -0
  68. package/telegram-sentinel.mjs +2010 -0
  69. package/ui/app.js +867 -0
  70. package/ui/app.legacy.js +1464 -0
  71. package/ui/app.monolith.js +2488 -0
  72. package/ui/components/charts.js +226 -0
  73. package/ui/components/chat-view.js +567 -0
  74. package/ui/components/command-palette.js +587 -0
  75. package/ui/components/diff-viewer.js +190 -0
  76. package/ui/components/forms.js +357 -0
  77. package/ui/components/kanban-board.js +451 -0
  78. package/ui/components/session-list.js +305 -0
  79. package/ui/components/shared.js +525 -0
  80. package/ui/demo.html +640 -0
  81. package/ui/index.html +70 -0
  82. package/ui/modules/api.js +297 -0
  83. package/ui/modules/icons.js +461 -0
  84. package/ui/modules/router.js +81 -0
  85. package/ui/modules/settings-schema.js +261 -0
  86. package/ui/modules/state.js +679 -0
  87. package/ui/modules/telegram.js +331 -0
  88. package/ui/modules/utils.js +270 -0
  89. package/ui/styles/animations.css +140 -0
  90. package/ui/styles/base.css +98 -0
  91. package/ui/styles/components.css +2032 -0
  92. package/ui/styles/kanban.css +286 -0
  93. package/ui/styles/layout.css +810 -0
  94. package/ui/styles/sessions.css +841 -0
  95. package/ui/styles/variables.css +188 -0
  96. package/ui/styles.css +141 -0
  97. package/ui/styles.monolith.css +1046 -0
  98. package/ui/tabs/agents.js +1417 -0
  99. package/ui/tabs/chat.js +75 -0
  100. package/ui/tabs/control.js +892 -0
  101. package/ui/tabs/dashboard.js +515 -0
  102. package/ui/tabs/infra.js +537 -0
  103. package/ui/tabs/logs.js +783 -0
  104. package/ui/tabs/settings.js +1509 -0
  105. package/ui/tabs/tasks.js +1385 -0
  106. package/ui-server.mjs +4084 -0
  107. package/update-check.mjs +471 -0
  108. package/utils.mjs +172 -0
  109. package/ve-kanban.mjs +654 -0
  110. package/ve-kanban.ps1 +1365 -0
  111. package/ve-kanban.sh +18 -0
  112. package/ve-orchestrator.mjs +340 -0
  113. package/ve-orchestrator.ps1 +6546 -0
  114. package/ve-orchestrator.sh +18 -0
  115. package/vibe-kanban-wrapper.mjs +41 -0
  116. package/vk-error-resolver.mjs +470 -0
  117. package/vk-log-stream.mjs +914 -0
  118. package/whatsapp-channel.mjs +520 -0
  119. package/workspace-monitor.mjs +581 -0
  120. package/workspace-reaper.mjs +405 -0
  121. package/workspace-registry.mjs +238 -0
  122. package/worktree-manager.mjs +1266 -0
@@ -0,0 +1,1509 @@
1
+ /* ─────────────────────────────────────────────────────────────
2
+ * Tab: Settings — Two-mode settings UI
3
+ * Mode 1: App Preferences (client-side, CloudStorage/localStorage)
4
+ * Mode 2: Server Config (.env management via settings API)
5
+ * ────────────────────────────────────────────────────────────── */
6
+ import { h } from "preact";
7
+ import {
8
+ useState,
9
+ useEffect,
10
+ useCallback,
11
+ useRef,
12
+ useMemo,
13
+ } from "preact/hooks";
14
+ import htm from "htm";
15
+
16
+ const html = htm.bind(h);
17
+
18
+ import { haptic, showConfirm, showAlert } from "../modules/telegram.js";
19
+ import { apiFetch, wsConnected } from "../modules/api.js";
20
+ import {
21
+ connected,
22
+ statusData,
23
+ executorData,
24
+ configData,
25
+ showToast,
26
+ } from "../modules/state.js";
27
+ import {
28
+ Card,
29
+ Badge,
30
+ ListItem,
31
+ SkeletonCard,
32
+ Modal,
33
+ ConfirmDialog,
34
+ Spinner,
35
+ } from "../components/shared.js";
36
+ import {
37
+ SegmentedControl,
38
+ Collapsible,
39
+ Toggle,
40
+ SearchInput,
41
+ } from "../components/forms.js";
42
+ import {
43
+ CATEGORIES,
44
+ SETTINGS_SCHEMA,
45
+ getGroupedSettings,
46
+ validateSetting,
47
+ SENSITIVE_KEYS,
48
+ } from "../modules/settings-schema.js";
49
+
50
+ /* ─── Scoped Styles ─── */
51
+ const SETTINGS_STYLES = `
52
+ /* Category pill tabs — horizontal scrollable row */
53
+ .settings-category-tabs {
54
+ display: flex;
55
+ overflow-x: auto;
56
+ gap: 8px;
57
+ padding: 8px 0;
58
+ -webkit-overflow-scrolling: touch;
59
+ scrollbar-width: none;
60
+ }
61
+ .settings-category-tabs::-webkit-scrollbar { display: none; }
62
+ .settings-category-tab {
63
+ display: flex;
64
+ align-items: center;
65
+ gap: 6px;
66
+ padding: 6px 14px;
67
+ border-radius: 20px;
68
+ border: 1px solid var(--border, rgba(255,255,255,0.08));
69
+ background: var(--card-bg, rgba(255,255,255,0.04));
70
+ color: var(--text-secondary, #999);
71
+ font-size: 13px;
72
+ white-space: nowrap;
73
+ cursor: pointer;
74
+ transition: all 0.2s ease;
75
+ flex-shrink: 0;
76
+ }
77
+ .settings-category-tab:hover {
78
+ border-color: var(--accent, #5b6eae);
79
+ color: var(--text-primary, #fff);
80
+ }
81
+ .settings-category-tab.active {
82
+ background: var(--accent, #5b6eae);
83
+ border-color: var(--accent, #5b6eae);
84
+ color: var(--accent-text, #fff);
85
+ font-weight: 600;
86
+ }
87
+ .settings-category-tab-icon { font-size: 15px; }
88
+ /* Search wrapper */
89
+ .settings-search { margin-bottom: 8px; }
90
+ /* Floating save bar */
91
+ .settings-save-bar {
92
+ position: fixed;
93
+ bottom: 0;
94
+ left: 0; right: 0;
95
+ z-index: 1000;
96
+ display: flex;
97
+ align-items: center;
98
+ justify-content: space-between;
99
+ gap: 12px;
100
+ padding: 12px 16px;
101
+ padding-bottom: max(12px, env(safe-area-inset-bottom, 0px));
102
+ background: var(--glass-bg, rgba(30,30,46,0.95));
103
+ backdrop-filter: blur(20px);
104
+ -webkit-backdrop-filter: blur(20px);
105
+ border-top: 1px solid var(--border, rgba(255,255,255,0.08));
106
+ animation: slideUp 0.25s ease;
107
+ }
108
+ @keyframes slideUp {
109
+ from { transform: translateY(100%); opacity: 0; }
110
+ to { transform: translateY(0); opacity: 1; }
111
+ }
112
+ .settings-save-bar .save-bar-info {
113
+ display: flex;
114
+ align-items: center;
115
+ gap: 8px;
116
+ font-size: 13px;
117
+ color: var(--text-secondary, #999);
118
+ }
119
+ .settings-save-bar .save-bar-actions {
120
+ display: flex;
121
+ gap: 8px;
122
+ flex-shrink: 0;
123
+ }
124
+ /* Individual setting row */
125
+ .setting-row {
126
+ padding: 12px 0;
127
+ border-bottom: 1px solid var(--border, rgba(255,255,255,0.05));
128
+ overflow: hidden;
129
+ max-width: 100%;
130
+ }
131
+ .setting-row:last-child { border-bottom: none; }
132
+ .setting-row-header {
133
+ display: flex;
134
+ align-items: center;
135
+ gap: 8px;
136
+ margin-bottom: 8px;
137
+ }
138
+ .setting-row-label {
139
+ font-size: 14px;
140
+ font-weight: 600;
141
+ color: var(--text-primary, #fff);
142
+ }
143
+ .setting-row-key {
144
+ font-size: 11px;
145
+ font-family: monospace;
146
+ color: var(--text-tertiary, #666);
147
+ opacity: 0.7;
148
+ }
149
+ /* Help tooltip */
150
+ .setting-help-btn {
151
+ display: inline-flex;
152
+ align-items: center;
153
+ justify-content: center;
154
+ width: 20px; height: 20px;
155
+ border-radius: 50%;
156
+ border: 1px solid var(--border, rgba(255,255,255,0.15));
157
+ background: transparent;
158
+ color: var(--text-secondary, #999);
159
+ font-size: 12px;
160
+ font-weight: 700;
161
+ cursor: pointer;
162
+ padding: 0;
163
+ flex-shrink: 0;
164
+ position: relative;
165
+ }
166
+ .setting-help-tooltip {
167
+ position: absolute;
168
+ bottom: calc(100% + 8px);
169
+ left: 50%;
170
+ transform: translateX(-50%);
171
+ background: var(--glass-bg, rgba(30,30,46,0.95));
172
+ backdrop-filter: blur(12px);
173
+ border: 1px solid var(--border, rgba(255,255,255,0.12));
174
+ border-radius: 8px;
175
+ padding: 10px 14px;
176
+ font-size: 12px;
177
+ font-weight: 400;
178
+ color: var(--text-secondary, #bbb);
179
+ min-width: 220px;
180
+ max-width: 320px;
181
+ z-index: 200;
182
+ white-space: normal;
183
+ line-height: 1.5;
184
+ box-shadow: 0 8px 24px rgba(0,0,0,0.3);
185
+ pointer-events: none;
186
+ }
187
+ /* Modified dot */
188
+ .setting-modified-dot {
189
+ width: 8px; height: 8px;
190
+ border-radius: 50%;
191
+ background: var(--warning, #f5a623);
192
+ flex-shrink: 0;
193
+ }
194
+ /* Default tag */
195
+ .setting-default-tag {
196
+ font-size: 11px;
197
+ color: var(--text-tertiary, #666);
198
+ font-style: italic;
199
+ margin-left: 4px;
200
+ }
201
+ /* Secret toggle eye button */
202
+ .setting-secret-toggle {
203
+ background: transparent;
204
+ border: 1px solid var(--border, rgba(255,255,255,0.12));
205
+ border-radius: 6px;
206
+ padding: 4px 8px;
207
+ cursor: pointer;
208
+ color: var(--text-secondary, #999);
209
+ font-size: 14px;
210
+ flex-shrink: 0;
211
+ }
212
+ /* Input wrappers */
213
+ .setting-input-wrap {
214
+ display: flex;
215
+ align-items: center;
216
+ gap: 8px;
217
+ overflow: visible;
218
+ max-width: 100%;
219
+ }
220
+ .setting-input-wrap input[type="text"],
221
+ .setting-input-wrap input[type="number"],
222
+ .setting-input-wrap input[type="password"],
223
+ .setting-input-wrap textarea,
224
+ .setting-input-wrap select {
225
+ flex: 1;
226
+ padding: 8px 12px;
227
+ border-radius: 8px;
228
+ border: 1px solid var(--border, rgba(255,255,255,0.1));
229
+ background: var(--input-bg, rgba(255,255,255,0.04));
230
+ color: var(--text-primary, #fff);
231
+ font-size: 13px;
232
+ outline: none;
233
+ transition: border-color 0.2s;
234
+ }
235
+ .setting-input-wrap input:focus,
236
+ .setting-input-wrap textarea:focus,
237
+ .setting-input-wrap select:focus {
238
+ border-color: var(--accent, #5b6eae);
239
+ }
240
+ .setting-input-wrap textarea {
241
+ min-height: 60px;
242
+ resize: vertical;
243
+ font-family: monospace;
244
+ }
245
+ .setting-input-wrap select {
246
+ appearance: auto;
247
+ }
248
+ .setting-unit {
249
+ font-size: 12px;
250
+ color: var(--text-tertiary, #666);
251
+ white-space: nowrap;
252
+ }
253
+ .setting-validation-error {
254
+ font-size: 12px;
255
+ color: var(--destructive, #e74c3c);
256
+ margin-top: 4px;
257
+ padding-left: 2px;
258
+ }
259
+ /* Banner styles */
260
+ .settings-banner {
261
+ padding: 12px 16px;
262
+ border-radius: 10px;
263
+ margin-bottom: 12px;
264
+ display: flex;
265
+ align-items: center;
266
+ gap: 10px;
267
+ font-size: 13px;
268
+ }
269
+ .settings-banner-error {
270
+ background: rgba(231,76,60,0.12);
271
+ border: 1px solid rgba(231,76,60,0.25);
272
+ color: var(--destructive, #e74c3c);
273
+ }
274
+ .settings-banner-warn {
275
+ background: rgba(245,166,35,0.12);
276
+ border: 1px solid rgba(245,166,35,0.25);
277
+ color: var(--warning, #f5a623);
278
+ }
279
+ .settings-banner-info {
280
+ background: rgba(90,124,255,0.12);
281
+ border: 1px solid rgba(90,124,255,0.25);
282
+ color: var(--accent, #5a7cff);
283
+ }
284
+ .settings-banner-text { flex: 1; }
285
+ /* Diff display for confirm dialog */
286
+ .settings-diff {
287
+ max-height: 300px;
288
+ overflow-y: auto;
289
+ font-family: monospace;
290
+ font-size: 12px;
291
+ background: var(--input-bg, rgba(255,255,255,0.03));
292
+ border-radius: 8px;
293
+ padding: 12px;
294
+ margin: 12px 0;
295
+ }
296
+ .settings-diff-row {
297
+ padding: 4px 0;
298
+ border-bottom: 1px solid var(--border, rgba(255,255,255,0.04));
299
+ }
300
+ .settings-diff-key {
301
+ font-weight: 600;
302
+ color: var(--text-primary, #fff);
303
+ margin-bottom: 2px;
304
+ }
305
+ .settings-diff-old { color: var(--destructive, #e74c3c); }
306
+ .settings-diff-new { color: var(--success, #2ecc71); }
307
+ /* Empty search state */
308
+ .settings-empty-search {
309
+ text-align: center;
310
+ padding: 32px 16px;
311
+ color: var(--text-secondary, #999);
312
+ }
313
+ .settings-empty-search-icon { font-size: 32px; margin-bottom: 8px; }
314
+ /* Category description text */
315
+ .settings-cat-desc {
316
+ font-size: 12px;
317
+ color: var(--text-tertiary, #666);
318
+ margin-bottom: 8px;
319
+ padding: 0 2px;
320
+ }
321
+ /* Settings tab needs extra bottom padding for save bar + nav */
322
+ .settings-content-scroll {
323
+ padding-bottom: 160px;
324
+ }
325
+ `;
326
+
327
+ /* ─── Inject styles once ─── */
328
+ let _stylesInjected = false;
329
+ function injectStyles() {
330
+ if (_stylesInjected) return;
331
+ _stylesInjected = true;
332
+ const el = document.createElement("style");
333
+ el.textContent = SETTINGS_STYLES;
334
+ document.head.appendChild(el);
335
+ }
336
+
337
+ /* ─── CloudStorage helpers (for App Preferences mode) ─── */
338
+ function cloudGet(key) {
339
+ return new Promise((resolve) => {
340
+ const tg = globalThis.Telegram?.WebApp;
341
+ if (tg?.CloudStorage) {
342
+ tg.CloudStorage.getItem(key, (err, val) => {
343
+ if (err || val == null) resolve(null);
344
+ else {
345
+ try {
346
+ resolve(JSON.parse(val));
347
+ } catch {
348
+ resolve(val);
349
+ }
350
+ }
351
+ });
352
+ } else {
353
+ try {
354
+ const v = localStorage.getItem("ve_settings_" + key);
355
+ resolve(v != null ? JSON.parse(v) : null);
356
+ } catch {
357
+ resolve(null);
358
+ }
359
+ }
360
+ });
361
+ }
362
+
363
+ function cloudSet(key, value) {
364
+ const tg = globalThis.Telegram?.WebApp;
365
+ const str = JSON.stringify(value);
366
+ if (tg?.CloudStorage) {
367
+ tg.CloudStorage.setItem(key, str, () => {});
368
+ } else {
369
+ try {
370
+ localStorage.setItem("ve_settings_" + key, str);
371
+ } catch {
372
+ /* noop */
373
+ }
374
+ }
375
+ }
376
+
377
+ function cloudRemove(key) {
378
+ const tg = globalThis.Telegram?.WebApp;
379
+ if (tg?.CloudStorage) {
380
+ tg.CloudStorage.removeItem(key, () => {});
381
+ } else {
382
+ try {
383
+ localStorage.removeItem("ve_settings_" + key);
384
+ } catch {
385
+ /* noop */
386
+ }
387
+ }
388
+ }
389
+
390
+ /* ─── Version info ─── */
391
+ const APP_VERSION = "1.0.0";
392
+ const APP_NAME = "VirtEngine Control Center";
393
+
394
+ /* ─── Fuzzy search helper ─── */
395
+ function fuzzyMatch(needle, haystack) {
396
+ if (!needle) return true;
397
+ const lower = haystack.toLowerCase();
398
+ const terms = needle.toLowerCase().split(/\s+/).filter(Boolean);
399
+ return terms.every((t) => lower.includes(t));
400
+ }
401
+
402
+ /* ─── Mask a sensitive value for display ─── */
403
+ function maskValue(val) {
404
+ if (!val || val === "") return "";
405
+ const s = String(val);
406
+ if (s.length <= 4) return "••••";
407
+ return "••••••" + s.slice(-4);
408
+ }
409
+
410
+ /* ═══════════════════════════════════════════════════════════════
411
+ * ServerConfigMode — .env management UI
412
+ * ═══════════════════════════════════════════════════════════════ */
413
+ function ServerConfigMode() {
414
+ /* Data loading state */
415
+ const [serverData, setServerData] = useState(null); // { KEY: "value" } from API
416
+ const [serverMeta, setServerMeta] = useState(null); // { envPath, configPath, configDir }
417
+ const [configSync, setConfigSync] = useState(null); // { total, updated, skipped, configPath }
418
+ const [loadError, setLoadError] = useState(null);
419
+ const [loading, setLoading] = useState(true);
420
+
421
+ /* Local edits: Map of key → edited value (string) */
422
+ const [edits, setEdits] = useState({});
423
+ /* Validation errors: Map of key → error string */
424
+ const [errors, setErrors] = useState({});
425
+ /* Secret visibility: Set of keys currently unmasked */
426
+ const [visibleSecrets, setVisibleSecrets] = useState({});
427
+ /* Help tooltips: key of currently shown tooltip */
428
+ const [activeTooltip, setActiveTooltip] = useState(null);
429
+
430
+ /* Active category tab */
431
+ const [activeCategory, setActiveCategory] = useState(CATEGORIES[0].id);
432
+ /* Search query */
433
+ const [searchQuery, setSearchQuery] = useState("");
434
+ /* Show advanced settings */
435
+ const [showAdvanced, setShowAdvanced] = useState(false);
436
+ /* Save flow */
437
+ const [saving, setSaving] = useState(false);
438
+ const [confirmOpen, setConfirmOpen] = useState(false);
439
+
440
+ const tooltipTimer = useRef(null);
441
+
442
+ /* ─── Load server settings on mount ─── */
443
+ const fetchSettings = useCallback(async () => {
444
+ setLoading(true);
445
+ setLoadError(null);
446
+ try {
447
+ const res = await apiFetch("/api/settings");
448
+ if (res?.ok && res.data) {
449
+ setServerData(res.data);
450
+ setServerMeta(res.meta || null);
451
+ setConfigSync(null);
452
+ } else {
453
+ throw new Error(res?.error || "Unexpected response format");
454
+ }
455
+ } catch (err) {
456
+ setLoadError(err.message || "Failed to load settings");
457
+ setServerData(null);
458
+ setServerMeta(null);
459
+ setConfigSync(null);
460
+ } finally {
461
+ setLoading(false);
462
+ }
463
+ }, []);
464
+
465
+ useEffect(() => { fetchSettings(); }, [fetchSettings]);
466
+
467
+ /* ─── Grouped settings with search + advanced filter ─── */
468
+ const grouped = useMemo(() => getGroupedSettings(showAdvanced), [showAdvanced]);
469
+
470
+ /* Filtered settings when searching */
471
+ const filteredSettings = useMemo(() => {
472
+ if (!searchQuery.trim()) return null; // null = not searching
473
+ const results = [];
474
+ for (const def of SETTINGS_SCHEMA) {
475
+ if (!showAdvanced && def.advanced) continue;
476
+ const haystack = `${def.key} ${def.label} ${def.description || ""}`;
477
+ if (fuzzyMatch(searchQuery, haystack)) results.push(def);
478
+ }
479
+ return results;
480
+ }, [searchQuery, showAdvanced]);
481
+
482
+ /* ─── Value resolution: edited value → server value → empty ─── */
483
+ const getValue = useCallback(
484
+ (key) => {
485
+ if (key in edits) return edits[key];
486
+ if (serverData && key in serverData) return String(serverData[key] ?? "");
487
+ return "";
488
+ },
489
+ [edits, serverData],
490
+ );
491
+
492
+ /* ─── Determine if a value matches its default ─── */
493
+ const isDefault = useCallback(
494
+ (def) => {
495
+ if (def.defaultVal == null) return false;
496
+ const current = getValue(def.key);
497
+ return current === "" || current === String(def.defaultVal);
498
+ },
499
+ [getValue],
500
+ );
501
+
502
+ /* ─── Determine if a value was modified from loaded state ─── */
503
+ const isModified = useCallback(
504
+ (key) => key in edits,
505
+ [edits],
506
+ );
507
+
508
+ /* Count of unsaved changes */
509
+ const changeCount = useMemo(() => Object.keys(edits).length, [edits]);
510
+
511
+ /* Any setting with restart: true in the changes? */
512
+ const hasRestartSetting = useMemo(() => {
513
+ return Object.keys(edits).some((key) => {
514
+ const def = SETTINGS_SCHEMA.find((s) => s.key === key);
515
+ return def?.restart;
516
+ });
517
+ }, [edits]);
518
+
519
+ /* ─── Handlers ─── */
520
+ const handleChange = useCallback(
521
+ (key, value) => {
522
+ haptic("light");
523
+ setEdits((prev) => {
524
+ const original = serverData?.[key] != null ? String(serverData[key]) : "";
525
+ // If the new value matches the original, remove the edit
526
+ if (value === original) {
527
+ const next = { ...prev };
528
+ delete next[key];
529
+ return next;
530
+ }
531
+ return { ...prev, [key]: value };
532
+ });
533
+ // Validate inline
534
+ const def = SETTINGS_SCHEMA.find((s) => s.key === key);
535
+ if (def) {
536
+ const result = validateSetting(def, value);
537
+ setErrors((prev) => {
538
+ if (result.valid) {
539
+ const next = { ...prev };
540
+ delete next[key];
541
+ return next;
542
+ }
543
+ return { ...prev, [key]: result.error };
544
+ });
545
+ }
546
+ },
547
+ [serverData],
548
+ );
549
+
550
+ const handleDiscard = useCallback(() => {
551
+ haptic("medium");
552
+ setEdits({});
553
+ setErrors({});
554
+ showToast("Changes discarded", "info");
555
+ }, []);
556
+
557
+ /* ─── Save flow ─── */
558
+ const handleSaveClick = useCallback(() => {
559
+ // Validate all changed settings
560
+ const newErrors = {};
561
+ let hasError = false;
562
+ for (const [key, value] of Object.entries(edits)) {
563
+ const def = SETTINGS_SCHEMA.find((s) => s.key === key);
564
+ if (!def) continue;
565
+ const result = validateSetting(def, value);
566
+ if (!result.valid) {
567
+ newErrors[key] = result.error;
568
+ hasError = true;
569
+ }
570
+ }
571
+ setErrors((prev) => ({ ...prev, ...newErrors }));
572
+ if (hasError) {
573
+ showToast("Fix validation errors before saving", "error");
574
+ haptic("heavy");
575
+ return;
576
+ }
577
+ haptic("medium");
578
+ setConfirmOpen(true);
579
+ }, [edits]);
580
+
581
+ const handleConfirmSave = useCallback(async () => {
582
+ setConfirmOpen(false);
583
+ setSaving(true);
584
+ try {
585
+ const changes = {};
586
+ for (const [key, value] of Object.entries(edits)) {
587
+ changes[key] = value;
588
+ }
589
+ const res = await apiFetch("/api/settings/update", {
590
+ method: "POST",
591
+ body: JSON.stringify({ changes }),
592
+ });
593
+ if (res?.ok) {
594
+ showToast("Settings saved successfully", "success");
595
+ haptic("medium");
596
+ const updatedConfig = Array.isArray(res.updatedConfig) ? res.updatedConfig : [];
597
+ const changeKeys = Object.keys(changes);
598
+ const skipped = changeKeys.filter((key) => !updatedConfig.includes(key));
599
+ setConfigSync({
600
+ total: changeKeys.length,
601
+ updated: updatedConfig.length,
602
+ skipped,
603
+ configPath: res.configPath || serverMeta?.configPath || null,
604
+ });
605
+ if (res.configPath && (!serverMeta || serverMeta.configPath !== res.configPath)) {
606
+ setServerMeta((prev) => ({
607
+ ...(prev || {}),
608
+ configPath: res.configPath,
609
+ configDir: res.configDir || prev?.configDir,
610
+ }));
611
+ }
612
+ // Merge changes into serverData so they appear as the new baseline
613
+ setServerData((prev) => ({ ...prev, ...changes }));
614
+ setEdits({});
615
+ if (hasRestartSetting) {
616
+ showToast("Settings take effect after auto-reload (~2 seconds)", "info");
617
+ }
618
+ } else {
619
+ throw new Error(res?.error || "Save failed");
620
+ }
621
+ } catch (err) {
622
+ let parsed = null;
623
+ try {
624
+ parsed = JSON.parse(err.message);
625
+ } catch {
626
+ parsed = null;
627
+ }
628
+ if (parsed?.fieldErrors && typeof parsed.fieldErrors === "object") {
629
+ setErrors((prev) => ({ ...prev, ...parsed.fieldErrors }));
630
+ }
631
+ const message = parsed?.error || err.message;
632
+ showToast(`Save failed: ${message}`, "error");
633
+ haptic("heavy");
634
+ } finally {
635
+ setSaving(false);
636
+ }
637
+ }, [edits, hasRestartSetting, serverMeta]);
638
+
639
+ const handleCancelSave = useCallback(() => {
640
+ setConfirmOpen(false);
641
+ }, []);
642
+
643
+ /* ─── Tooltip management ─── */
644
+ const showTooltipFor = useCallback((key) => {
645
+ clearTimeout(tooltipTimer.current);
646
+ setActiveTooltip(key);
647
+ tooltipTimer.current = setTimeout(() => setActiveTooltip(null), 4000);
648
+ }, []);
649
+
650
+ /* ─── Secret visibility toggle ─── */
651
+ const toggleSecret = useCallback((key) => {
652
+ haptic("light");
653
+ setVisibleSecrets((prev) => {
654
+ const next = { ...prev };
655
+ next[key] = !next[key];
656
+ return next;
657
+ });
658
+ }, []);
659
+
660
+ /* ─── Build the diff for the confirm dialog ─── */
661
+ const diffEntries = useMemo(() => {
662
+ return Object.entries(edits).map(([key, newVal]) => {
663
+ const def = SETTINGS_SCHEMA.find((s) => s.key === key);
664
+ const oldVal = serverData?.[key] != null ? String(serverData[key]) : "(unset)";
665
+ const displayOld = def?.sensitive ? maskValue(oldVal) : oldVal || "(unset)";
666
+ const displayNew = def?.sensitive ? maskValue(newVal) : newVal || "(unset)";
667
+ return { key, label: def?.label || key, oldVal: displayOld, newVal: displayNew };
668
+ });
669
+ }, [edits, serverData]);
670
+
671
+ /* ═══════════════════════════════════════════════
672
+ * Render a single setting control
673
+ * ═══════════════════════════════════════════════ */
674
+ const renderSetting = useCallback(
675
+ (def) => {
676
+ const value = getValue(def.key);
677
+ const modified = isModified(def.key);
678
+ const defaultMatch = isDefault(def);
679
+ const error = errors[def.key];
680
+ const isSensitive = def.sensitive;
681
+ const secretVisible = visibleSecrets[def.key];
682
+
683
+ /* Choose input control based on type */
684
+ let control = null;
685
+
686
+ switch (def.type) {
687
+ case "boolean": {
688
+ const checked =
689
+ value === "true" || value === "1" || value === true;
690
+ control = html`
691
+ <${Toggle}
692
+ checked=${checked}
693
+ onChange=${(v) => handleChange(def.key, v ? "true" : "false")}
694
+ />
695
+ `;
696
+ break;
697
+ }
698
+
699
+ case "select": {
700
+ const opts = def.options || [];
701
+ if (opts.length <= 4) {
702
+ // SegmentedControl for ≤4 options
703
+ control = html`
704
+ <${SegmentedControl}
705
+ options=${opts.map((o) => ({ value: o, label: o }))}
706
+ value=${value || (def.defaultVal != null ? String(def.defaultVal) : "")}
707
+ onChange=${(v) => handleChange(def.key, v)}
708
+ />
709
+ `;
710
+ } else {
711
+ // Dropdown for >4 options
712
+ control = html`
713
+ <div class="setting-input-wrap">
714
+ <select
715
+ value=${value || (def.defaultVal != null ? String(def.defaultVal) : "")}
716
+ onChange=${(e) => handleChange(def.key, e.target.value)}
717
+ >
718
+ ${opts.map(
719
+ (o) => html`<option key=${o} value=${o}>${o}</option>`,
720
+ )}
721
+ </select>
722
+ </div>
723
+ `;
724
+ }
725
+ break;
726
+ }
727
+
728
+ case "secret": {
729
+ control = html`
730
+ <div class="setting-input-wrap">
731
+ <input
732
+ type=${secretVisible ? "text" : "password"}
733
+ value=${value}
734
+ placeholder="Enter value…"
735
+ onInput=${(e) => handleChange(def.key, e.target.value)}
736
+ />
737
+ <button
738
+ class="setting-secret-toggle"
739
+ onClick=${(e) => {
740
+ e.preventDefault();
741
+ e.stopPropagation();
742
+ toggleSecret(def.key);
743
+ }}
744
+ type="button"
745
+ title=${secretVisible ? "Hide" : "Show"}
746
+ >
747
+ ${secretVisible ? "🙈" : "👁️"}
748
+ </button>
749
+ </div>
750
+ `;
751
+ break;
752
+ }
753
+
754
+ case "number": {
755
+ control = html`
756
+ <div class="setting-input-wrap">
757
+ <input
758
+ type="number"
759
+ value=${value}
760
+ placeholder=${def.defaultVal != null ? String(def.defaultVal) : ""}
761
+ min=${def.min}
762
+ max=${def.max}
763
+ onInput=${(e) => handleChange(def.key, e.target.value)}
764
+ />
765
+ ${def.unit && html`<span class="setting-unit">${def.unit}</span>`}
766
+ </div>
767
+ `;
768
+ break;
769
+ }
770
+
771
+ case "text": {
772
+ control = html`
773
+ <div class="setting-input-wrap">
774
+ <textarea
775
+ value=${value}
776
+ placeholder=${def.defaultVal != null ? String(def.defaultVal) : "Enter value…"}
777
+ onInput=${(e) => handleChange(def.key, e.target.value)}
778
+ rows="3"
779
+ />
780
+ </div>
781
+ `;
782
+ break;
783
+ }
784
+
785
+ default: {
786
+ // string type
787
+ control = html`
788
+ <div class="setting-input-wrap">
789
+ <input
790
+ type="text"
791
+ value=${value}
792
+ placeholder=${def.defaultVal != null ? String(def.defaultVal) : "Enter value…"}
793
+ onInput=${(e) => handleChange(def.key, e.target.value)}
794
+ />
795
+ </div>
796
+ `;
797
+ break;
798
+ }
799
+ }
800
+
801
+ return html`
802
+ <div class="setting-row" key=${def.key}>
803
+ <div class="setting-row-header">
804
+ ${modified && html`<span class="setting-modified-dot" title="Unsaved change"></span>`}
805
+ <span class="setting-row-label">${def.label}</span>
806
+ ${defaultMatch && !modified && html`<span class="setting-default-tag">(default)</span>`}
807
+ ${def.restart && html`<${Badge} status="warning" text="restart" className="badge-sm" />`}
808
+ <button
809
+ class="setting-help-btn"
810
+ onClick=${(e) => {
811
+ e.stopPropagation();
812
+ showTooltipFor(def.key);
813
+ }}
814
+ title=${def.description}
815
+ >
816
+ ?
817
+ ${activeTooltip === def.key &&
818
+ html`<div class="setting-help-tooltip">${def.description}</div>`}
819
+ </button>
820
+ </div>
821
+ <div class="setting-row-key">${def.key}</div>
822
+ ${control}
823
+ ${error && html`<div class="setting-validation-error">⚠ ${error}</div>`}
824
+ </div>
825
+ `;
826
+ },
827
+ [getValue, isModified, isDefault, errors, visibleSecrets, activeTooltip, handleChange, toggleSecret, showTooltipFor],
828
+ );
829
+
830
+ /* ═══════════════════════════════════════════════
831
+ * Render
832
+ * ═══════════════════════════════════════════════ */
833
+
834
+ /* Backend health banner */
835
+ const wsOk = wsConnected.value;
836
+
837
+ return html`
838
+ <!-- Health banners -->
839
+ ${loadError &&
840
+ html`
841
+ <div class="settings-banner settings-banner-error">
842
+ <span>⚠️</span>
843
+ <span class="settings-banner-text">
844
+ <strong>Backend Unreachable</strong> — ${loadError}
845
+ </span>
846
+ <button class="btn btn-ghost btn-sm" onClick=${fetchSettings}>Retry</button>
847
+ </div>
848
+ `}
849
+
850
+ ${!wsOk &&
851
+ !loadError &&
852
+ html`
853
+ <div class="settings-banner settings-banner-warn">
854
+ <span>⚡</span>
855
+ <span class="settings-banner-text">Connection lost — reconnecting…</span>
856
+ </div>
857
+ `}
858
+
859
+ ${configSync &&
860
+ html`
861
+ <div class="settings-banner ${configSync.skipped?.length ? "settings-banner-warn" : "settings-banner-info"}">
862
+ <span>💾</span>
863
+ <span class="settings-banner-text">
864
+ ${configSync.skipped?.length
865
+ ? `Saved ${configSync.total} settings; synced ${configSync.updated} to config file.`
866
+ : `Synced ${configSync.updated} settings to config file.`}
867
+ ${configSync.configPath &&
868
+ html`<div style="margin-top:4px;font-size:12px;color:var(--text-secondary, #bbb)">
869
+ Config: <code>${configSync.configPath}</code>
870
+ </div>`}
871
+ ${configSync.skipped?.length &&
872
+ html`<div style="margin-top:2px;font-size:12px;color:var(--text-secondary, #bbb)">
873
+ Not supported in config: ${configSync.skipped.slice(0, 4).join(", ")}${configSync.skipped.length > 4 ? ` +${configSync.skipped.length - 4} more` : ""}
874
+ </div>`}
875
+ </span>
876
+ </div>
877
+ `}
878
+
879
+ ${serverMeta?.configPath &&
880
+ !loadError &&
881
+ html`
882
+ <div class="settings-banner settings-banner-info">
883
+ <span>🧭</span>
884
+ <span class="settings-banner-text">
885
+ Settings are saved to <code>${serverMeta.envPath}</code> and synced to
886
+ <code>${serverMeta.configPath}</code> for supported keys.
887
+ </span>
888
+ </div>
889
+ `}
890
+
891
+ <!-- Search bar -->
892
+ <div class="settings-search">
893
+ <${SearchInput}
894
+ value=${searchQuery}
895
+ onInput=${(e) => setSearchQuery(e.target.value)}
896
+ onClear=${() => setSearchQuery("")}
897
+ placeholder="Search settings…"
898
+ />
899
+ </div>
900
+
901
+ <!-- Advanced toggle -->
902
+ <div style="display:flex;align-items:center;justify-content:flex-end;margin-bottom:8px">
903
+ <${Toggle}
904
+ checked=${showAdvanced}
905
+ onChange=${(v) => { setShowAdvanced(v); haptic("light"); }}
906
+ label="Show Advanced"
907
+ />
908
+ </div>
909
+
910
+ <!-- Loading state -->
911
+ ${loading &&
912
+ html`
913
+ <${SkeletonCard} height="40px" />
914
+ <${SkeletonCard} height="120px" />
915
+ <${SkeletonCard} height="120px" />
916
+ `}
917
+
918
+ <!-- Content: search results or category browsing -->
919
+ ${!loading &&
920
+ serverData &&
921
+ (() => {
922
+ /* ── Search mode ── */
923
+ if (filteredSettings) {
924
+ if (filteredSettings.length === 0) {
925
+ return html`
926
+ <div class="settings-empty-search">
927
+ <div class="settings-empty-search-icon">🔍</div>
928
+ <div>No settings match "<strong>${searchQuery}</strong>"</div>
929
+ <div class="meta-text mt-sm">Try a different search term</div>
930
+ </div>
931
+ `;
932
+ }
933
+ return html`
934
+ <${Card}>
935
+ <div class="card-subtitle mb-sm">
936
+ ${filteredSettings.length} result${filteredSettings.length !== 1 ? "s" : ""}
937
+ </div>
938
+ ${filteredSettings.map((def) => renderSetting(def))}
939
+ <//>
940
+ `;
941
+ }
942
+
943
+ /* ── Category browsing mode ── */
944
+ const catDefs = grouped.get(activeCategory) || [];
945
+ const activeCat = CATEGORIES.find((c) => c.id === activeCategory);
946
+
947
+ return html`
948
+ <!-- Category tabs -->
949
+ <div class="settings-category-tabs">
950
+ ${CATEGORIES.map(
951
+ (cat) => html`
952
+ <button
953
+ key=${cat.id}
954
+ class="settings-category-tab ${activeCategory === cat.id ? "active" : ""}"
955
+ onClick=${() => {
956
+ setActiveCategory(cat.id);
957
+ haptic("light");
958
+ }}
959
+ >
960
+ <span class="settings-category-tab-icon">${cat.icon}</span>
961
+ ${cat.label}
962
+ </button>
963
+ `,
964
+ )}
965
+ </div>
966
+
967
+ <!-- Category description -->
968
+ ${activeCat?.description &&
969
+ html`<div class="settings-cat-desc">${activeCat.description}</div>`}
970
+
971
+ <!-- Settings list for active category -->
972
+ ${catDefs.length === 0
973
+ ? html`
974
+ <${Card}>
975
+ <div class="meta-text" style="text-align:center;padding:24px 0">
976
+ No settings in this category${!showAdvanced ? " (try enabling Advanced)" : ""}
977
+ </div>
978
+ <//>
979
+ `
980
+ : html`
981
+ <${Card}>
982
+ ${catDefs.map((def) => renderSetting(def))}
983
+ <//>
984
+ `}
985
+ `;
986
+ })()}
987
+
988
+ <!-- Empty state when no data and no error -->
989
+ ${!loading &&
990
+ !serverData &&
991
+ !loadError &&
992
+ html`
993
+ <${Card}>
994
+ <div class="meta-text" style="text-align:center;padding:24px 0">
995
+ No settings data available.
996
+ </div>
997
+ <//>
998
+ `}
999
+
1000
+ <!-- Floating save bar -->
1001
+ ${changeCount > 0 &&
1002
+ html`
1003
+ <div class="settings-save-bar">
1004
+ <div class="save-bar-info">
1005
+ <span class="setting-modified-dot"></span>
1006
+ <span>${changeCount} unsaved change${changeCount !== 1 ? "s" : ""}</span>
1007
+ </div>
1008
+ <div class="save-bar-actions">
1009
+ <button class="btn btn-ghost btn-sm" onClick=${handleDiscard}>
1010
+ Discard
1011
+ </button>
1012
+ <button
1013
+ class=${`btn btn-primary btn-sm ${saving ? 'btn-loading' : ''}`}
1014
+ onClick=${handleSaveClick}
1015
+ disabled=${saving}
1016
+ >
1017
+ ${saving ? html`<${Spinner} size=${14} /> Saving…` : "Save Changes"}
1018
+ </button>
1019
+ </div>
1020
+ </div>
1021
+ `}
1022
+
1023
+ <!-- Confirm dialog with diff -->
1024
+ ${confirmOpen &&
1025
+ html`
1026
+ <${Modal} title="Confirm Changes" open=${true} onClose=${handleCancelSave}>
1027
+ <div style="padding:4px 0">
1028
+ <div class="meta-text mb-sm">
1029
+ Review ${diffEntries.length} change${diffEntries.length !== 1 ? "s" : ""} before saving:
1030
+ </div>
1031
+ <div class="settings-diff">
1032
+ ${diffEntries.map(
1033
+ (d) => html`
1034
+ <div class="settings-diff-row" key=${d.key}>
1035
+ <div class="settings-diff-key">${d.label}</div>
1036
+ <div class="settings-diff-old">− ${d.oldVal}</div>
1037
+ <div class="settings-diff-new">+ ${d.newVal}</div>
1038
+ </div>
1039
+ `,
1040
+ )}
1041
+ </div>
1042
+ ${hasRestartSetting &&
1043
+ html`
1044
+ <div class="settings-banner settings-banner-warn" style="margin-top:8px">
1045
+ <span>🔄</span>
1046
+ <span class="settings-banner-text">
1047
+ Some changes require a restart. The server will auto-reload (~2 seconds).
1048
+ </span>
1049
+ </div>
1050
+ `}
1051
+ <div class="btn-row mt-md" style="justify-content:flex-end;gap:8px">
1052
+ <button class="btn btn-ghost" onClick=${handleCancelSave}>Cancel</button>
1053
+ <button class="btn btn-primary" onClick=${handleConfirmSave} disabled=${saving}>
1054
+ ${saving ? html`<${Spinner} size=${14} /> Saving…` : "Confirm & Save"}
1055
+ </button>
1056
+ </div>
1057
+ </div>
1058
+ <//>
1059
+ `}
1060
+ `;
1061
+ }
1062
+
1063
+ /* ═══════════════════════════════════════════════════════════════
1064
+ * AppPreferencesMode — existing client-side preferences
1065
+ * ═══════════════════════════════════════════════════════════════ */
1066
+ function AppPreferencesMode() {
1067
+ const tg = globalThis.Telegram?.WebApp;
1068
+ const user = tg?.initDataUnsafe?.user;
1069
+
1070
+ /* Preferences (loaded from CloudStorage) */
1071
+ const [fontSize, setFontSize] = useState("medium");
1072
+ const [notifyUpdates, setNotifyUpdates] = useState(true);
1073
+ const [notifyErrors, setNotifyErrors] = useState(true);
1074
+ const [notifyComplete, setNotifyComplete] = useState(true);
1075
+ const [debugMode, setDebugMode] = useState(false);
1076
+ const [defaultMaxParallel, setDefaultMaxParallel] = useState(4);
1077
+ const [defaultSdk, setDefaultSdk] = useState("auto");
1078
+ const [defaultRegion, setDefaultRegion] = useState("auto");
1079
+ const [showRawJson, setShowRawJson] = useState(false);
1080
+ const [loaded, setLoaded] = useState(false);
1081
+
1082
+ /* Apply font size to the document */
1083
+ function applyFontSize(size) {
1084
+ if (!size) return;
1085
+ const map = { small: "13px", medium: "15px", large: "17px" };
1086
+ const px = map[size] || map.medium;
1087
+ document.documentElement.style.setProperty("--base-font-size", px);
1088
+ const numSize = parseInt(px, 10);
1089
+ if (numSize >= 10 && numSize <= 24) {
1090
+ document.documentElement.style.fontSize = `${numSize}px`;
1091
+ }
1092
+ }
1093
+
1094
+ /* Load prefs from CloudStorage on mount */
1095
+ useEffect(() => {
1096
+ (async () => {
1097
+ const [fs, nu, ne, nc, dm, dmp, ds, dr] = await Promise.all([
1098
+ cloudGet("fontSize"),
1099
+ cloudGet("notifyUpdates"),
1100
+ cloudGet("notifyErrors"),
1101
+ cloudGet("notifyComplete"),
1102
+ cloudGet("debugMode"),
1103
+ cloudGet("defaultMaxParallel"),
1104
+ cloudGet("defaultSdk"),
1105
+ cloudGet("defaultRegion"),
1106
+ ]);
1107
+ if (fs) {
1108
+ setFontSize(fs);
1109
+ applyFontSize(fs);
1110
+ }
1111
+ if (nu != null) setNotifyUpdates(nu);
1112
+ if (ne != null) setNotifyErrors(ne);
1113
+ if (nc != null) setNotifyComplete(nc);
1114
+ if (dm != null) setDebugMode(dm);
1115
+ if (dmp != null) setDefaultMaxParallel(dmp);
1116
+ if (ds) setDefaultSdk(ds);
1117
+ if (dr) setDefaultRegion(dr);
1118
+ setLoaded(true);
1119
+ })();
1120
+ }, []);
1121
+
1122
+ /* Persist helpers */
1123
+ const toggle = useCallback((key, getter, setter) => {
1124
+ const next = !getter;
1125
+ setter(next);
1126
+ cloudSet(key, next);
1127
+ haptic();
1128
+ }, []);
1129
+
1130
+ const handleFontSize = (v) => {
1131
+ setFontSize(v);
1132
+ cloudSet("fontSize", v);
1133
+ haptic();
1134
+ applyFontSize(v);
1135
+ };
1136
+
1137
+ const handleDefaultMaxParallel = (v) => {
1138
+ const val = Math.max(1, Math.min(20, Number(v)));
1139
+ setDefaultMaxParallel(val);
1140
+ cloudSet("defaultMaxParallel", val);
1141
+ haptic();
1142
+ };
1143
+
1144
+ const handleDefaultSdk = (v) => {
1145
+ setDefaultSdk(v);
1146
+ cloudSet("defaultSdk", v);
1147
+ haptic();
1148
+ };
1149
+
1150
+ const handleDefaultRegion = (v) => {
1151
+ setDefaultRegion(v);
1152
+ cloudSet("defaultRegion", v);
1153
+ haptic();
1154
+ };
1155
+
1156
+ /* Clear cache */
1157
+ const handleClearCache = async () => {
1158
+ const ok = await showConfirm("Clear all cached data and preferences?");
1159
+ if (!ok) return;
1160
+ haptic("medium");
1161
+ const keys = [
1162
+ "fontSize",
1163
+ "notifyUpdates",
1164
+ "notifyErrors",
1165
+ "notifyComplete",
1166
+ "debugMode",
1167
+ "defaultMaxParallel",
1168
+ "defaultSdk",
1169
+ "defaultRegion",
1170
+ ];
1171
+ for (const k of keys) cloudRemove(k);
1172
+ showToast("Cache cleared — reload to apply", "success");
1173
+ };
1174
+
1175
+ /* Reset all settings */
1176
+ const handleReset = async () => {
1177
+ const ok = await showConfirm("Reset ALL settings to defaults?");
1178
+ if (!ok) return;
1179
+ haptic("heavy");
1180
+ const keys = [
1181
+ "fontSize",
1182
+ "notifyUpdates",
1183
+ "notifyErrors",
1184
+ "notifyComplete",
1185
+ "debugMode",
1186
+ "defaultMaxParallel",
1187
+ "defaultSdk",
1188
+ "defaultRegion",
1189
+ ];
1190
+ for (const k of keys) cloudRemove(k);
1191
+ setFontSize("medium");
1192
+ setNotifyUpdates(true);
1193
+ setNotifyErrors(true);
1194
+ setNotifyComplete(true);
1195
+ setDebugMode(false);
1196
+ setDefaultMaxParallel(4);
1197
+ setDefaultSdk("auto");
1198
+ setDefaultRegion("auto");
1199
+ document.documentElement.style.removeProperty("--base-font-size");
1200
+ document.documentElement.style.removeProperty("font-size");
1201
+ showToast("Settings reset", "success");
1202
+ };
1203
+
1204
+ /* Raw status JSON */
1205
+ const rawJson =
1206
+ debugMode && showRawJson
1207
+ ? JSON.stringify(
1208
+ { status: statusData?.value, executor: executorData?.value },
1209
+ null,
1210
+ 2,
1211
+ )
1212
+ : "";
1213
+
1214
+ return html`
1215
+ ${!loaded && html`<${Card} title="Loading Settings…"><${SkeletonCard} /><//>`}
1216
+
1217
+ <!-- ─── Account ─── -->
1218
+ <${Collapsible} title="👤 Account" defaultOpen=${true}>
1219
+ <${Card}>
1220
+ <div class="settings-row">
1221
+ ${user?.photo_url &&
1222
+ html`
1223
+ <img
1224
+ src=${user.photo_url}
1225
+ alt="avatar"
1226
+ class="settings-avatar"
1227
+ style="width:48px;height:48px;border-radius:50%;margin-right:12px"
1228
+ />
1229
+ `}
1230
+ <div>
1231
+ <div style="font-weight:600;font-size:15px">
1232
+ ${user?.first_name || "Unknown"} ${user?.last_name || ""}
1233
+ </div>
1234
+ ${user?.username &&
1235
+ html`<div class="meta-text">@${user.username}</div>`}
1236
+ </div>
1237
+ </div>
1238
+ <div class="meta-text mt-sm">App version: ${APP_VERSION}</div>
1239
+ <//>
1240
+ <//>
1241
+
1242
+ <!-- ─── Appearance ─── -->
1243
+ <${Collapsible} title="🎨 Appearance" defaultOpen=${false}>
1244
+ <${Card}>
1245
+ <div class="card-subtitle mb-sm">Color Scheme</div>
1246
+ <div class="meta-text mb-md">
1247
+ Follows your Telegram theme automatically.
1248
+ ${tg?.colorScheme
1249
+ ? html` Current: <strong>${tg.colorScheme}</strong>`
1250
+ : ""}
1251
+ </div>
1252
+ <div class="card-subtitle mb-sm">Font Size</div>
1253
+ <${SegmentedControl}
1254
+ options=${[
1255
+ { value: "small", label: "Small" },
1256
+ { value: "medium", label: "Medium" },
1257
+ { value: "large", label: "Large" },
1258
+ ]}
1259
+ value=${fontSize}
1260
+ onChange=${handleFontSize}
1261
+ />
1262
+ <//>
1263
+ <//>
1264
+
1265
+ <!-- ─── Notifications ─── -->
1266
+ <${Collapsible} title="🔔 Notifications" defaultOpen=${false}>
1267
+ <${Card}>
1268
+ <${ListItem}
1269
+ title="Real-time Updates"
1270
+ subtitle="Show live data refresh indicators"
1271
+ trailing=${html`
1272
+ <${Toggle}
1273
+ checked=${notifyUpdates}
1274
+ onChange=${() =>
1275
+ toggle("notifyUpdates", notifyUpdates, setNotifyUpdates)}
1276
+ />
1277
+ `}
1278
+ />
1279
+ <${ListItem}
1280
+ title="Error Alerts"
1281
+ subtitle="Toast notifications for errors"
1282
+ trailing=${html`
1283
+ <${Toggle}
1284
+ checked=${notifyErrors}
1285
+ onChange=${() =>
1286
+ toggle("notifyErrors", notifyErrors, setNotifyErrors)}
1287
+ />
1288
+ `}
1289
+ />
1290
+ <${ListItem}
1291
+ title="Task Completion"
1292
+ subtitle="Notify when tasks finish"
1293
+ trailing=${html`
1294
+ <${Toggle}
1295
+ checked=${notifyComplete}
1296
+ onChange=${() =>
1297
+ toggle("notifyComplete", notifyComplete, setNotifyComplete)}
1298
+ />
1299
+ `}
1300
+ />
1301
+ <//>
1302
+ <//>
1303
+
1304
+ <!-- ─── Data & Storage ─── -->
1305
+ <${Collapsible} title="💾 Data & Storage" defaultOpen=${false}>
1306
+ <${Card}>
1307
+ <${ListItem}
1308
+ title="WebSocket"
1309
+ subtitle="Live connection status"
1310
+ trailing=${html`
1311
+ <${Badge}
1312
+ status=${connected?.value ? "done" : "error"}
1313
+ text=${connected?.value ? "Connected" : "Offline"}
1314
+ />
1315
+ `}
1316
+ />
1317
+ <${ListItem}
1318
+ title="API Endpoint"
1319
+ subtitle=${globalThis.location?.origin || "unknown"}
1320
+ />
1321
+ <${ListItem}
1322
+ title="Clear Cache"
1323
+ subtitle="Remove all stored preferences"
1324
+ trailing=${html`
1325
+ <button class="btn btn-ghost btn-sm" onClick=${handleClearCache}>
1326
+ 🗑 Clear
1327
+ </button>
1328
+ `}
1329
+ />
1330
+ <//>
1331
+ <//>
1332
+
1333
+ <!-- ─── Executor Defaults ─── -->
1334
+ <${Collapsible} title="⚙️ Executor Defaults" defaultOpen=${false}>
1335
+ <${Card}>
1336
+ <div class="card-subtitle mb-sm">Default Max Parallel</div>
1337
+ <div class="range-row mb-md">
1338
+ <input
1339
+ type="range"
1340
+ min="1"
1341
+ max="20"
1342
+ step="1"
1343
+ value=${defaultMaxParallel}
1344
+ onInput=${(e) => setDefaultMaxParallel(Number(e.target.value))}
1345
+ onChange=${(e) => handleDefaultMaxParallel(Number(e.target.value))}
1346
+ />
1347
+ <span class="pill">${defaultMaxParallel}</span>
1348
+ </div>
1349
+
1350
+ <div class="card-subtitle mb-sm">Default SDK</div>
1351
+ <${SegmentedControl}
1352
+ options=${[
1353
+ { value: "codex", label: "Codex" },
1354
+ { value: "copilot", label: "Copilot" },
1355
+ { value: "claude", label: "Claude" },
1356
+ { value: "auto", label: "Auto" },
1357
+ ]}
1358
+ value=${defaultSdk}
1359
+ onChange=${handleDefaultSdk}
1360
+ />
1361
+
1362
+ <div class="card-subtitle mt-md mb-sm">Default Region</div>
1363
+ ${(() => {
1364
+ const regions = configData.value?.regions || ["auto"];
1365
+ const regionOptions = regions.map((r) => ({
1366
+ value: r,
1367
+ label: r.charAt(0).toUpperCase() + r.slice(1),
1368
+ }));
1369
+ return regions.length > 1
1370
+ ? html`<${SegmentedControl}
1371
+ options=${regionOptions}
1372
+ value=${defaultRegion}
1373
+ onChange=${handleDefaultRegion}
1374
+ />`
1375
+ : html`<div class="meta-text">Region: ${regions[0]}</div>`;
1376
+ })()}
1377
+ <//>
1378
+ <//>
1379
+
1380
+ <!-- ─── Advanced ─── -->
1381
+ <${Collapsible} title="🔧 Advanced" defaultOpen=${false}>
1382
+ <${Card}>
1383
+ <${ListItem}
1384
+ title="Debug Mode"
1385
+ subtitle="Show raw data and extra diagnostics"
1386
+ trailing=${html`
1387
+ <${Toggle}
1388
+ checked=${debugMode}
1389
+ onChange=${() => toggle("debugMode", debugMode, setDebugMode)}
1390
+ />
1391
+ `}
1392
+ />
1393
+
1394
+ ${debugMode &&
1395
+ html`
1396
+ <${ListItem}
1397
+ title="Raw Status JSON"
1398
+ subtitle="View raw API response data"
1399
+ trailing=${html`
1400
+ <button
1401
+ class="btn btn-ghost btn-sm"
1402
+ onClick=${() => {
1403
+ setShowRawJson(!showRawJson);
1404
+ haptic();
1405
+ }}
1406
+ >
1407
+ ${showRawJson ? "Hide" : "Show"}
1408
+ </button>
1409
+ `}
1410
+ />
1411
+ ${showRawJson &&
1412
+ html`
1413
+ <div class="log-box mt-sm" style="max-height:300px;font-size:11px">
1414
+ ${rawJson}
1415
+ </div>
1416
+ `}
1417
+ `}
1418
+
1419
+ <${ListItem}
1420
+ title="Reset All Settings"
1421
+ subtitle="Restore defaults"
1422
+ trailing=${html`
1423
+ <button class="btn btn-danger btn-sm" onClick=${handleReset}>
1424
+ Reset
1425
+ </button>
1426
+ `}
1427
+ />
1428
+ <//>
1429
+ <//>
1430
+
1431
+ <!-- ─── About ─── -->
1432
+ <${Collapsible} title="ℹ️ About" defaultOpen=${false}>
1433
+ <${Card}>
1434
+ <div style="text-align:center;padding:12px 0">
1435
+ <div style="font-size:18px;font-weight:700;margin-bottom:4px">
1436
+ ${APP_NAME}
1437
+ </div>
1438
+ <div class="meta-text">Version ${APP_VERSION}</div>
1439
+ <div class="meta-text mt-sm">
1440
+ Telegram Mini App for VirtEngine orchestrator management
1441
+ </div>
1442
+ <div class="meta-text mt-sm">
1443
+ Built with Preact + HTM · No build step
1444
+ </div>
1445
+ <div class="btn-row mt-md" style="justify-content:center">
1446
+ <button
1447
+ class="btn btn-ghost btn-sm"
1448
+ onClick=${() => {
1449
+ haptic();
1450
+ const tg = globalThis.Telegram?.WebApp;
1451
+ if (tg?.openLink)
1452
+ tg.openLink("https://github.com/virtengine/virtengine");
1453
+ else
1454
+ globalThis.open(
1455
+ "https://github.com/virtengine/virtengine",
1456
+ "_blank",
1457
+ );
1458
+ }}
1459
+ >
1460
+ GitHub
1461
+ </button>
1462
+ <button
1463
+ class="btn btn-ghost btn-sm"
1464
+ onClick=${() => {
1465
+ haptic();
1466
+ const tg = globalThis.Telegram?.WebApp;
1467
+ if (tg?.openLink) tg.openLink("https://docs.virtengine.com");
1468
+ else globalThis.open("https://docs.virtengine.com", "_blank");
1469
+ }}
1470
+ >
1471
+ Docs
1472
+ </button>
1473
+ </div>
1474
+ </div>
1475
+ <//>
1476
+ <//>
1477
+ `;
1478
+ }
1479
+
1480
+ /* ═══════════════════════════════════════════════════════════════
1481
+ * SettingsTab — Top-level with two-mode segmented control
1482
+ * ═══════════════════════════════════════════════════════════════ */
1483
+ export function SettingsTab() {
1484
+ const [mode, setMode] = useState("preferences");
1485
+
1486
+ /* Inject scoped CSS on first render */
1487
+ useEffect(() => { injectStyles(); }, []);
1488
+
1489
+ return html`
1490
+ <!-- Top-level mode switcher -->
1491
+ <div style="margin-bottom:12px">
1492
+ <${SegmentedControl}
1493
+ options=${[
1494
+ { value: "preferences", label: "App Preferences" },
1495
+ { value: "server", label: "Server Config" },
1496
+ ]}
1497
+ value=${mode}
1498
+ onChange=${(v) => {
1499
+ setMode(v);
1500
+ haptic("light");
1501
+ }}
1502
+ />
1503
+ </div>
1504
+
1505
+ ${mode === "preferences"
1506
+ ? html`<${AppPreferencesMode} />`
1507
+ : html`<${ServerConfigMode} />`}
1508
+ `;
1509
+ }