@virtengine/openfleet 0.25.0

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