@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
package/ui/app.js ADDED
@@ -0,0 +1,867 @@
1
+ /* ─────────────────────────────────────────────────────────────
2
+ * VirtEngine Control Center – Preact + HTM Entry Point
3
+ * Modular SPA for Telegram Mini App (no build step)
4
+ * ────────────────────────────────────────────────────────────── */
5
+
6
+ import { h, render as preactRender } from "preact";
7
+ import { useState, useEffect, useCallback, useRef } from "preact/hooks";
8
+ import { signal } from "@preact/signals";
9
+ import htm from "htm";
10
+
11
+ const html = htm.bind(h);
12
+
13
+ // Backend health tracking
14
+ const backendDown = signal(false);
15
+ const backendError = signal("");
16
+ const backendLastSeen = signal(null);
17
+ const backendRetryCount = signal(0);
18
+ const DESKTOP_MIN_WIDTH = 1200;
19
+
20
+ /* ── Module imports ── */
21
+ import { ICONS } from "./modules/icons.js";
22
+ import {
23
+ initTelegramApp,
24
+ onThemeChange,
25
+ getTg,
26
+ isTelegramContext,
27
+ showSettingsButton,
28
+ getTelegramUser,
29
+ colorScheme,
30
+ } from "./modules/telegram.js";
31
+ import {
32
+ apiFetch,
33
+ connectWebSocket,
34
+ disconnectWebSocket,
35
+ wsConnected,
36
+ } from "./modules/api.js";
37
+ import {
38
+ connected,
39
+ refreshTab,
40
+ toasts,
41
+ initWsInvalidationListener,
42
+ loadNotificationPrefs,
43
+ applyStoredDefaults,
44
+ } from "./modules/state.js";
45
+ import { activeTab, navigateTo, TAB_CONFIG } from "./modules/router.js";
46
+ import { formatRelative } from "./modules/utils.js";
47
+
48
+ function getNavHint() {
49
+ if (typeof globalThis === "undefined") return "";
50
+ const isCoarse = globalThis.matchMedia?.("(pointer: coarse)")?.matches;
51
+ if (isCoarse) return "Swipe left/right to switch tabs";
52
+ const isHover = globalThis.matchMedia?.("(hover: hover)")?.matches;
53
+ if (isHover) return "Press 1-8 to switch tabs";
54
+ return "";
55
+ }
56
+
57
+ /* ── Component imports ── */
58
+ import { ToastContainer } from "./components/shared.js";
59
+ import { PullToRefresh } from "./components/forms.js";
60
+ import {
61
+ SessionList,
62
+ loadSessions,
63
+ createSession,
64
+ selectedSessionId,
65
+ sessionsData,
66
+ } from "./components/session-list.js";
67
+ import { DiffViewer } from "./components/diff-viewer.js";
68
+ import {
69
+ CommandPalette,
70
+ useCommandPalette,
71
+ } from "./components/command-palette.js";
72
+
73
+ /* ── Tab imports ── */
74
+ import { DashboardTab } from "./tabs/dashboard.js";
75
+ import { TasksTab } from "./tabs/tasks.js";
76
+ import { ChatTab } from "./tabs/chat.js";
77
+ import { AgentsTab } from "./tabs/agents.js";
78
+ import { InfraTab } from "./tabs/infra.js";
79
+ import { ControlTab } from "./tabs/control.js";
80
+ import { LogsTab } from "./tabs/logs.js";
81
+ import { SettingsTab } from "./tabs/settings.js";
82
+
83
+ /* ── Placeholder signals for connection quality (may be provided by api.js) ── */
84
+ let wsLatency = signal(null);
85
+ let wsReconnectIn = signal(null);
86
+ let dataFreshness = signal(null);
87
+ try {
88
+ const apiMod = await import("./modules/api.js");
89
+ if (apiMod.wsLatency) wsLatency = apiMod.wsLatency;
90
+ if (apiMod.wsReconnectIn) wsReconnectIn = apiMod.wsReconnectIn;
91
+ } catch { /* use placeholder signals */ }
92
+ try {
93
+ const stateMod = await import("./modules/state.js");
94
+ if (stateMod.dataFreshness) dataFreshness = stateMod.dataFreshness;
95
+ } catch { /* use placeholder signals */ }
96
+
97
+ /* ── Backend health helpers ── */
98
+
99
+ function formatTimeAgo(ts) {
100
+ return formatRelative(ts);
101
+ }
102
+
103
+ // Inject offline-banner CSS once
104
+ if (typeof document !== "undefined" && !document.getElementById("offline-banner-styles")) {
105
+ const style = document.createElement("style");
106
+ style.id = "offline-banner-styles";
107
+ style.textContent = `
108
+ .offline-banner {
109
+ display: flex;
110
+ align-items: center;
111
+ gap: 12px;
112
+ padding: 12px 16px;
113
+ margin: 8px 16px;
114
+ background: rgba(239, 68, 68, 0.15);
115
+ border: 1px solid rgba(239, 68, 68, 0.3);
116
+ border-radius: 12px;
117
+ backdrop-filter: blur(8px);
118
+ animation: slideDown 0.3s ease-out;
119
+ }
120
+ .offline-banner-icon { font-size: 24px; }
121
+ .offline-banner-content { flex: 1; }
122
+ .offline-banner-title { font-weight: 600; font-size: 14px; color: #ef4444; }
123
+ .offline-banner-meta { font-size: 12px; opacity: 0.7; margin-top: 2px; }
124
+ `;
125
+ document.head.appendChild(style);
126
+ }
127
+
128
+ function useBackendHealth() {
129
+ const intervalRef = useRef(null);
130
+
131
+ const checkHealth = useCallback(async () => {
132
+ try {
133
+ const controller = new AbortController();
134
+ const timeout = setTimeout(() => controller.abort(), 5000);
135
+ const res = await fetch("/api/health", { signal: controller.signal });
136
+ clearTimeout(timeout);
137
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
138
+ backendDown.value = false;
139
+ backendError.value = "";
140
+ backendLastSeen.value = Date.now();
141
+ backendRetryCount.value = 0;
142
+ } catch (err) {
143
+ backendDown.value = true;
144
+ backendError.value = err?.message || "Connection lost";
145
+ backendRetryCount.value = backendRetryCount.value + 1;
146
+ }
147
+ }, []);
148
+
149
+ useEffect(() => {
150
+ checkHealth();
151
+ intervalRef.current = setInterval(checkHealth, 10000);
152
+ return () => clearInterval(intervalRef.current);
153
+ }, [checkHealth]);
154
+
155
+ // If WS reconnects, consider backend up
156
+ useEffect(() => {
157
+ if (wsConnected.value && backendDown.value) {
158
+ backendDown.value = false;
159
+ backendError.value = "";
160
+ backendLastSeen.value = Date.now();
161
+ backendRetryCount.value = 0;
162
+ }
163
+ }, [wsConnected.value]);
164
+
165
+ return {
166
+ isDown: backendDown.value,
167
+ error: backendError.value,
168
+ lastSeen: backendLastSeen.value,
169
+ retryCount: backendRetryCount.value,
170
+ retry: checkHealth,
171
+ };
172
+ }
173
+
174
+ function OfflineBanner() {
175
+ const { retry: manualRetry } = useBackendHealth();
176
+ return html`
177
+ <div class="offline-banner">
178
+ <div class="offline-banner-icon">⚠️</div>
179
+ <div class="offline-banner-content">
180
+ <div class="offline-banner-title">Backend Unreachable</div>
181
+ <div class="offline-banner-meta">${backendError.value || "Connection lost"}</div>
182
+ ${backendLastSeen.value
183
+ ? html`<div class="offline-banner-meta">Last connected: ${formatTimeAgo(backendLastSeen.value)}</div>`
184
+ : null}
185
+ <div class="offline-banner-meta">Retry attempt #${backendRetryCount.value}</div>
186
+ </div>
187
+ <button class="btn btn-ghost btn-sm" onClick=${manualRetry}>Retry</button>
188
+ </div>
189
+ `;
190
+ }
191
+
192
+ /* ── Tab component map ── */
193
+ const TAB_COMPONENTS = {
194
+ dashboard: DashboardTab,
195
+ tasks: TasksTab,
196
+ chat: ChatTab,
197
+ agents: AgentsTab,
198
+ infra: InfraTab,
199
+ control: ControlTab,
200
+ logs: LogsTab,
201
+ settings: SettingsTab,
202
+ };
203
+
204
+ /* ═══════════════════════════════════════════════
205
+ * Header
206
+ * ═══════════════════════════════════════════════ */
207
+ function Header() {
208
+ const isConn = connected.value;
209
+ const wsConn = wsConnected.value;
210
+ const user = getTelegramUser();
211
+ const latency = wsLatency.value;
212
+ const reconnect = wsReconnectIn.value;
213
+ const freshnessRaw = dataFreshness.value;
214
+ const navHint = getNavHint();
215
+ let freshness = null;
216
+ if (typeof freshnessRaw === "number") {
217
+ freshness = Number.isFinite(freshnessRaw) ? freshnessRaw : null;
218
+ } else if (freshnessRaw && typeof freshnessRaw === "object") {
219
+ const vals = Object.values(freshnessRaw).filter((v) => Number.isFinite(v));
220
+ freshness = vals.length ? Math.max(...vals) : null;
221
+ }
222
+
223
+ // Connection quality label
224
+ let connLabel = "Offline";
225
+ let connClass = "disconnected";
226
+ if (isConn && latency != null) {
227
+ connLabel = `${latency}ms`;
228
+ connClass = "connected";
229
+ } else if (isConn) {
230
+ connLabel = "Live";
231
+ connClass = "connected";
232
+ } else if (reconnect != null && reconnect > 0) {
233
+ connLabel = `Reconnecting in ${reconnect}s…`;
234
+ connClass = "reconnecting";
235
+ }
236
+
237
+ // Freshness label
238
+ let freshnessLabel = "";
239
+ if (freshness != null && Number.isFinite(freshness)) {
240
+ const rel = formatRelative(freshness);
241
+ if (rel && rel !== "—" && !rel.includes("NaN")) {
242
+ freshnessLabel = rel === "just now" ? "Updated just now" : `Updated ${rel}`;
243
+ }
244
+ }
245
+
246
+ return html`
247
+ <header class="app-header">
248
+ <div class="app-header-left">
249
+ <div class="app-header-logo">${ICONS.zap}</div>
250
+ <div class="app-header-titles">
251
+ <div class="app-header-title">VirtEngine</div>
252
+ <div class="app-header-subtitle">
253
+ ${TAB_CONFIG.find((tab) => tab.id === activeTab.value)?.label || "Control Center"}
254
+ </div>
255
+ ${navHint
256
+ ? html`<div class="app-header-hint">${navHint}</div>`
257
+ : null}
258
+ </div>
259
+ </div>
260
+ <div class="header-actions">
261
+ <div class="connection-pill ${connClass}">
262
+ <span class="connection-dot"></span>
263
+ ${connLabel}
264
+ </div>
265
+ ${freshnessLabel
266
+ ? html`<div class="header-freshness" style="font-size:11px;opacity:0.55;margin-top:2px">${freshnessLabel}</div>`
267
+ : null}
268
+ ${user
269
+ ? html`<div class="app-header-user">@${user.username || user.first_name}</div>`
270
+ : null}
271
+ </div>
272
+ </header>
273
+ `;
274
+ }
275
+
276
+ /* ═══════════════════════════════════════════════
277
+ * Desktop Sidebar + Session Rail
278
+ * ═══════════════════════════════════════════════ */
279
+ function SidebarNav() {
280
+ const user = getTelegramUser();
281
+ const isConn = connected.value;
282
+ return html`
283
+ <aside class="sidebar">
284
+ <div class="sidebar-brand">
285
+ <div class="sidebar-logo">${ICONS.zap}</div>
286
+ <div>
287
+ <div class="sidebar-title">VirtEngine</div>
288
+ <div class="sidebar-subtitle">Control Center</div>
289
+ </div>
290
+ </div>
291
+ <div class="sidebar-actions">
292
+ <button class="btn btn-primary btn-block" onClick=${() => createSession({ type: "primary" })}>
293
+ New Session
294
+ </button>
295
+ <button class="btn btn-ghost btn-block" onClick=${() => navigateTo("tasks")}>
296
+ View Tasks
297
+ </button>
298
+ </div>
299
+ <nav class="sidebar-nav">
300
+ ${TAB_CONFIG.map((tab) => {
301
+ const isActive = activeTab.value === tab.id;
302
+ const isHome = tab.id === "dashboard";
303
+ return html`
304
+ <button
305
+ key=${tab.id}
306
+ class="sidebar-nav-item ${isActive ? "active" : ""}"
307
+ onClick=${() =>
308
+ navigateTo(tab.id, {
309
+ resetHistory: isHome,
310
+ forceRefresh: isHome && isActive,
311
+ })}
312
+ >
313
+ ${ICONS[tab.icon]}
314
+ <span>${tab.label}</span>
315
+ </button>
316
+ `;
317
+ })}
318
+ </nav>
319
+ <div class="sidebar-footer">
320
+ <div class="sidebar-status ${isConn ? "online" : "offline"}">
321
+ <span class="sidebar-status-dot"></span>
322
+ ${isConn ? "Connected" : "Offline"}
323
+ </div>
324
+ ${user
325
+ ? html`<div class="sidebar-user">@${user.username || user.first_name || "operator"}</div>`
326
+ : html`<div class="sidebar-user">Operator Console</div>`}
327
+ </div>
328
+ </aside>
329
+ `;
330
+ }
331
+
332
+ function SessionRail({ onResizeStart, onResizeReset, showResizer }) {
333
+ const [showArchived, setShowArchived] = useState(false);
334
+ const sessions = sessionsData.value || [];
335
+ const activeCount = sessions.filter(
336
+ (s) => s.status === "active" || s.status === "running",
337
+ ).length;
338
+
339
+ useEffect(() => {
340
+ let mounted = true;
341
+ loadSessions();
342
+ const interval = setInterval(() => {
343
+ if (mounted) loadSessions();
344
+ }, 5000);
345
+ return () => {
346
+ mounted = false;
347
+ clearInterval(interval);
348
+ };
349
+ }, []);
350
+
351
+ useEffect(() => {
352
+ if (selectedSessionId.value || sessions.length === 0) return;
353
+ const next =
354
+ sessions.find((s) => s.status === "active" || s.status === "running") ||
355
+ sessions[0];
356
+ if (next?.id) selectedSessionId.value = next.id;
357
+ }, [sessionsData.value, selectedSessionId.value]);
358
+
359
+ return html`
360
+ <aside class="session-rail">
361
+ <div class="rail-header">
362
+ <div class="rail-title">Sessions</div>
363
+ <div class="rail-meta">
364
+ ${activeCount} active · ${sessions.length} total
365
+ </div>
366
+ </div>
367
+ <${SessionList}
368
+ showArchived=${showArchived}
369
+ onToggleArchived=${setShowArchived}
370
+ defaultType="primary"
371
+ />
372
+ ${showResizer
373
+ ? html`
374
+ <div
375
+ class="rail-resizer"
376
+ role="separator"
377
+ aria-label="Resize sessions panel"
378
+ onPointerDown=${(e) => onResizeStart("rail", e)}
379
+ onDoubleClick=${() => onResizeReset("rail")}
380
+ ></div>
381
+ `
382
+ : null}
383
+ </aside>
384
+ `;
385
+ }
386
+
387
+ function InspectorPanel({ onResizeStart, onResizeReset, showResizer }) {
388
+ const sessionId = selectedSessionId.value;
389
+ const session = (sessionsData.value || []).find((s) => s.id === sessionId);
390
+ const isSessionTab = activeTab.value === "chat" || activeTab.value === "agents";
391
+ const status = session?.status || "idle";
392
+ const type = session?.type || "manual";
393
+ const lastActive = session?.updatedAt || session?.createdAt;
394
+ const preview = session?.lastMessage
395
+ ? session.lastMessage.slice(0, 160)
396
+ : "No messages yet.";
397
+ const [smartLogs, setSmartLogs] = useState([]);
398
+ const [logState, setLogState] = useState("idle");
399
+
400
+ useEffect(() => {
401
+ if (!isSessionTab) return;
402
+ let active = true;
403
+ const fetchLogs = async () => {
404
+ try {
405
+ setLogState("loading");
406
+ const res = await apiFetch("/api/logs?lines=120", { _silent: true });
407
+ if (!active) return;
408
+ const lines = res?.data?.lines || res?.lines || [];
409
+ const allLines = Array.isArray(lines) ? lines : [];
410
+ const tokens = [sessionId, session?.taskId, session?.branch]
411
+ .filter(Boolean)
412
+ .map((t) => String(t).toLowerCase());
413
+ const classified = allLines.map((line) => {
414
+ const lower = String(line || "").toLowerCase();
415
+ let level = "info";
416
+ if (
417
+ lower.includes("fatal") ||
418
+ lower.includes("panic") ||
419
+ lower.includes("exception") ||
420
+ lower.includes("unauthorized") ||
421
+ lower.includes("denied") ||
422
+ lower.includes("error")
423
+ ) {
424
+ level = "error";
425
+ } else if (lower.includes("warn")) {
426
+ level = "warn";
427
+ } else if (lower.includes("timeout")) {
428
+ level = "warn";
429
+ }
430
+ return { line: String(line || ""), level, lower };
431
+ });
432
+ const sessionHits = tokens.length
433
+ ? classified.filter((entry) =>
434
+ tokens.some((token) => entry.lower.includes(token)),
435
+ )
436
+ : [];
437
+ const severityHits = classified.filter((entry) => entry.level !== "info");
438
+ let selected = sessionHits.length ? sessionHits : severityHits;
439
+ if (!selected.length && (status === "active" || status === "running")) {
440
+ selected = classified.slice(-3);
441
+ }
442
+ const pruned = selected.slice(-6).map((entry) => ({
443
+ line: entry.line,
444
+ level: entry.level,
445
+ }));
446
+ setSmartLogs(pruned);
447
+ setLogState("ready");
448
+ } catch {
449
+ if (active) setLogState("error");
450
+ }
451
+ };
452
+
453
+ fetchLogs();
454
+ const interval = setInterval(fetchLogs, 12000);
455
+ return () => {
456
+ active = false;
457
+ clearInterval(interval);
458
+ };
459
+ }, [isSessionTab, sessionId, session?.taskId, session?.branch, status]);
460
+
461
+ return html`
462
+ <aside class="inspector">
463
+ <div class="inspector-section">
464
+ <div class="inspector-title">Focus</div>
465
+ ${session
466
+ ? html`
467
+ <div class="inspector-kv"><span>Session</span><strong>${session.title || session.taskId || session.id}</strong></div>
468
+ <div class="inspector-kv"><span>Status</span><strong>${status}</strong></div>
469
+ <div class="inspector-kv"><span>Type</span><strong>${type}</strong></div>
470
+ <div class="inspector-kv"><span>Last Active</span><strong>${lastActive ? formatRelative(lastActive) : "—"}</strong></div>
471
+ <div class="inspector-kv"><span>Preview</span><strong>${preview}</strong></div>
472
+ `
473
+ : html`<div class="inspector-empty">Select a session to see context.</div>`}
474
+ </div>
475
+
476
+ ${isSessionTab
477
+ ? html`
478
+ <div class="inspector-section inspector-scroll">
479
+ <div class="inspector-title">Latest Diff</div>
480
+ <${DiffViewer} sessionId=${sessionId} />
481
+ </div>
482
+ <div class="inspector-section">
483
+ <div class="inspector-title">Smart Logs</div>
484
+ ${logState === "error"
485
+ ? html`<div class="inspector-empty">Log stream unavailable.</div>`
486
+ : smartLogs.length === 0
487
+ ? html`<div class="inspector-empty">No noteworthy logs right now.</div>`
488
+ : html`
489
+ <div class="inspector-scroll">
490
+ ${smartLogs.map(
491
+ (entry, idx) => html`
492
+ <div key=${idx} class="inspector-log-line ${entry.level}">
493
+ ${entry.line.length > 220 ? entry.line.slice(-220) : entry.line}
494
+ </div>
495
+ `,
496
+ )}
497
+ </div>
498
+ `}
499
+ <button class="btn btn-ghost btn-sm" onClick=${() => navigateTo("logs")}>
500
+ Open Logs
501
+ </button>
502
+ </div>
503
+ `
504
+ : html`
505
+ <div class="inspector-section">
506
+ <div class="inspector-title">System Pulse</div>
507
+ <div class="inspector-kv"><span>API</span><strong>${connected.value ? "Connected" : "Offline"}</strong></div>
508
+ <div class="inspector-kv"><span>WebSocket</span><strong>${wsConnected.value ? "Live" : "Closed"}</strong></div>
509
+ <div class="inspector-kv"><span>Last Seen</span><strong>${backendLastSeen.value ? formatRelative(backendLastSeen.value) : "—"}</strong></div>
510
+ </div>
511
+ `}
512
+ ${showResizer
513
+ ? html`
514
+ <div
515
+ class="inspector-resizer"
516
+ role="separator"
517
+ aria-label="Resize inspector panel"
518
+ onPointerDown=${(e) => onResizeStart("inspector", e)}
519
+ onDoubleClick=${() => onResizeReset("inspector")}
520
+ ></div>
521
+ `
522
+ : null}
523
+ </aside>
524
+ `;
525
+ }
526
+
527
+ /* ═══════════════════════════════════════════════
528
+ * Bottom Navigation
529
+ * ═══════════════════════════════════════════════ */
530
+ function BottomNav() {
531
+ return html`
532
+ <nav class="bottom-nav">
533
+ ${TAB_CONFIG.filter((t) => t.id !== "settings").map(
534
+ (tab) => {
535
+ const isHome = tab.id === "dashboard";
536
+ const isActive = activeTab.value === tab.id;
537
+ return html`
538
+ <button
539
+ key=${tab.id}
540
+ class="nav-item ${activeTab.value === tab.id ? "active" : ""}"
541
+ onClick=${() =>
542
+ navigateTo(tab.id, {
543
+ resetHistory: isHome,
544
+ forceRefresh: isHome && isActive,
545
+ })}
546
+ >
547
+ ${ICONS[tab.icon]}
548
+ <span class="nav-label">${tab.label}</span>
549
+ </button>
550
+ `;
551
+ },
552
+ )}
553
+ </nav>
554
+ `;
555
+ }
556
+
557
+ /* ═══════════════════════════════════════════════
558
+ * App Root
559
+ * ═══════════════════════════════════════════════ */
560
+ function App() {
561
+ useBackendHealth();
562
+ const { open: paletteOpen, onClose: paletteClose } = useCommandPalette();
563
+ const mainRef = useRef(null);
564
+ const [showScrollTop, setShowScrollTop] = useState(false);
565
+ const scrollVisibilityRef = useRef(false);
566
+ const resizeRef = useRef(null);
567
+ const [isDesktop, setIsDesktop] = useState(() => {
568
+ if (typeof window === "undefined" || !window.matchMedia) return false;
569
+ return window.matchMedia(`(min-width: ${DESKTOP_MIN_WIDTH}px)`).matches;
570
+ });
571
+ const [railWidth, setRailWidth] = useState(() => {
572
+ if (typeof window === "undefined") return 320;
573
+ const stored = Number(localStorage.getItem("ve-rail-width"));
574
+ return Number.isFinite(stored) ? stored : 320;
575
+ });
576
+ const [inspectorWidth, setInspectorWidth] = useState(() => {
577
+ if (typeof window === "undefined") return 320;
578
+ const stored = Number(localStorage.getItem("ve-inspector-width"));
579
+ return Number.isFinite(stored) ? stored : 320;
580
+ });
581
+
582
+ const clamp = useCallback((value, min, max) => {
583
+ if (!Number.isFinite(value)) return min;
584
+ return Math.min(max, Math.max(min, value));
585
+ }, []);
586
+
587
+ const handleResizeMove = useCallback((event) => {
588
+ const state = resizeRef.current;
589
+ if (!state) return;
590
+ const delta = event.clientX - state.startX;
591
+ if (state.type === "rail") {
592
+ const next = clamp(state.startRail + delta, 240, 440);
593
+ setRailWidth(next);
594
+ } else {
595
+ const next = clamp(state.startInspector - delta, 260, 440);
596
+ setInspectorWidth(next);
597
+ }
598
+ }, [clamp]);
599
+
600
+ const handleResizeEnd = useCallback(() => {
601
+ resizeRef.current = null;
602
+ document.body.classList.remove("is-resizing");
603
+ window.removeEventListener("pointermove", handleResizeMove);
604
+ window.removeEventListener("pointerup", handleResizeEnd);
605
+ }, [handleResizeMove]);
606
+
607
+ const handleResizeStart = useCallback(
608
+ (type, event) => {
609
+ if (!isDesktop) return;
610
+ event.preventDefault();
611
+ event.stopPropagation();
612
+ resizeRef.current = {
613
+ type,
614
+ startX: event.clientX,
615
+ startRail: railWidth,
616
+ startInspector: inspectorWidth,
617
+ };
618
+ document.body.classList.add("is-resizing");
619
+ window.addEventListener("pointermove", handleResizeMove);
620
+ window.addEventListener("pointerup", handleResizeEnd);
621
+ },
622
+ [isDesktop, railWidth, inspectorWidth, handleResizeMove, handleResizeEnd],
623
+ );
624
+
625
+ const handleResizeReset = useCallback((type) => {
626
+ if (type === "rail") setRailWidth(320);
627
+ if (type === "inspector") setInspectorWidth(320);
628
+ }, []);
629
+
630
+ useEffect(() => {
631
+ if (typeof window === "undefined" || !window.matchMedia) return;
632
+ const query = window.matchMedia(`(min-width: ${DESKTOP_MIN_WIDTH}px)`);
633
+ const update = () => setIsDesktop(query.matches);
634
+ update();
635
+ if (query.addEventListener) query.addEventListener("change", update);
636
+ else query.addListener(update);
637
+ return () => {
638
+ if (query.removeEventListener) query.removeEventListener("change", update);
639
+ else query.removeListener(update);
640
+ };
641
+ }, []);
642
+
643
+ useEffect(() => {
644
+ if (!isDesktop || typeof window === "undefined") return;
645
+ localStorage.setItem("ve-rail-width", String(railWidth));
646
+ }, [railWidth, isDesktop]);
647
+
648
+ useEffect(() => {
649
+ if (!isDesktop || typeof window === "undefined") return;
650
+ localStorage.setItem("ve-inspector-width", String(inspectorWidth));
651
+ }, [inspectorWidth, isDesktop]);
652
+
653
+ useEffect(() => {
654
+ if (typeof document === "undefined") return;
655
+ if (isDesktop) {
656
+ document.documentElement.dataset.desktop = "true";
657
+ } else {
658
+ delete document.documentElement.dataset.desktop;
659
+ }
660
+ }, [isDesktop]);
661
+
662
+ useEffect(() => {
663
+ // Initialize Telegram Mini App SDK
664
+ initTelegramApp();
665
+
666
+ // Theme change monitoring
667
+ const unsub = onThemeChange(() => {
668
+ colorScheme.value = getTg()?.colorScheme || "dark";
669
+ });
670
+
671
+ // Show settings button in Telegram header
672
+ showSettingsButton(() => navigateTo("settings"));
673
+
674
+ // Connect WebSocket + invalidation auto-refresh
675
+ connectWebSocket();
676
+ initWsInvalidationListener();
677
+
678
+ // Load notification preferences early (non-blocking)
679
+ loadNotificationPrefs();
680
+
681
+ // Load initial data for the default tab, then apply stored executor defaults
682
+ refreshTab("dashboard").then(() => applyStoredDefaults());
683
+
684
+ // Global keyboard shortcuts (1-7 for tabs, Escape for modals)
685
+ function handleGlobalKeys(e) {
686
+ const tag = (document.activeElement?.tagName || "").toLowerCase();
687
+ if (tag === "input" || tag === "textarea" || tag === "select") return;
688
+ if (document.activeElement?.isContentEditable) return;
689
+
690
+ // Number keys 1-8 to switch tabs
691
+ const num = parseInt(e.key, 10);
692
+ if (num >= 1 && num <= 8 && !e.metaKey && !e.ctrlKey && !e.altKey) {
693
+ const tabCfg = TAB_CONFIG[num - 1];
694
+ if (tabCfg) {
695
+ e.preventDefault();
696
+ navigateTo(tabCfg.id);
697
+ }
698
+ return;
699
+ }
700
+
701
+ // Escape to close modals/palette
702
+ if (e.key === "Escape") {
703
+ globalThis.dispatchEvent(new CustomEvent("ve:close-modals"));
704
+ }
705
+ }
706
+ document.addEventListener("keydown", handleGlobalKeys);
707
+
708
+ return () => {
709
+ unsub();
710
+ disconnectWebSocket();
711
+ document.removeEventListener("keydown", handleGlobalKeys);
712
+ };
713
+ }, []);
714
+
715
+ useEffect(() => {
716
+ const el = mainRef.current;
717
+ if (!el) return;
718
+ const handleScroll = () => {
719
+ const shouldShow = el.scrollTop > 280;
720
+ if (shouldShow !== scrollVisibilityRef.current) {
721
+ scrollVisibilityRef.current = shouldShow;
722
+ setShowScrollTop(shouldShow);
723
+ }
724
+ };
725
+ handleScroll();
726
+ el.addEventListener("scroll", handleScroll, { passive: true });
727
+ return () => el.removeEventListener("scroll", handleScroll);
728
+ }, []);
729
+
730
+ useEffect(() => {
731
+ const el = mainRef.current;
732
+ if (!el) return;
733
+ const swipeTabs = TAB_CONFIG.filter((t) => t.id !== "settings");
734
+ let startX = 0;
735
+ let startY = 0;
736
+ let startTime = 0;
737
+ let tracking = false;
738
+ let blocked = false;
739
+
740
+ const shouldBlockSwipe = (target) => {
741
+ if (!target || typeof target.closest !== "function") return false;
742
+ return Boolean(
743
+ target.closest(".kanban-board") ||
744
+ target.closest(".kanban-cards") ||
745
+ target.closest(".chat-messages"),
746
+ );
747
+ };
748
+
749
+ const onTouchStart = (e) => {
750
+ if (e.touches.length !== 1) return;
751
+ const target = e.target;
752
+ blocked = shouldBlockSwipe(target);
753
+ if (blocked) return;
754
+ tracking = true;
755
+ startX = e.touches[0].clientX;
756
+ startY = e.touches[0].clientY;
757
+ startTime = Date.now();
758
+ };
759
+
760
+ const onTouchMove = (e) => {
761
+ if (!tracking || blocked) return;
762
+ const dx = e.touches[0].clientX - startX;
763
+ const dy = e.touches[0].clientY - startY;
764
+ if (Math.abs(dx) > 12 && Math.abs(dx) > Math.abs(dy)) {
765
+ e.preventDefault();
766
+ }
767
+ };
768
+
769
+ const onTouchEnd = (e) => {
770
+ if (!tracking || blocked) return;
771
+ tracking = false;
772
+ const touch = e.changedTouches[0];
773
+ if (!touch) return;
774
+ const dx = touch.clientX - startX;
775
+ const dy = touch.clientY - startY;
776
+ const dt = Date.now() - startTime;
777
+ if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) || dt > 700) return;
778
+
779
+ const currentIndex = swipeTabs.findIndex(
780
+ (tab) => tab.id === activeTab.value,
781
+ );
782
+ if (currentIndex < 0) return;
783
+ const direction = dx < 0 ? 1 : -1;
784
+ const nextIndex = currentIndex + direction;
785
+ if (nextIndex < 0 || nextIndex >= swipeTabs.length) return;
786
+ navigateTo(swipeTabs[nextIndex].id);
787
+ };
788
+
789
+ el.addEventListener("touchstart", onTouchStart, { passive: true });
790
+ el.addEventListener("touchmove", onTouchMove, { passive: false });
791
+ el.addEventListener("touchend", onTouchEnd, { passive: true });
792
+ el.addEventListener("touchcancel", onTouchEnd, { passive: true });
793
+
794
+ return () => {
795
+ el.removeEventListener("touchstart", onTouchStart);
796
+ el.removeEventListener("touchmove", onTouchMove);
797
+ el.removeEventListener("touchend", onTouchEnd);
798
+ el.removeEventListener("touchcancel", onTouchEnd);
799
+ };
800
+ }, []);
801
+
802
+ const CurrentTab = TAB_COMPONENTS[activeTab.value] || DashboardTab;
803
+ const showSessionRail = activeTab.value === "chat" || activeTab.value === "agents";
804
+ const showInspector = activeTab.value === "chat" || activeTab.value === "agents";
805
+
806
+ const shellStyle = isDesktop
807
+ ? {
808
+ "--rail-width": `${railWidth}px`,
809
+ "--inspector-width": `${inspectorWidth}px`,
810
+ }
811
+ : null;
812
+
813
+ return html`
814
+ <div
815
+ class="app-shell"
816
+ style=${shellStyle}
817
+ data-tab=${activeTab.value}
818
+ data-has-rail=${showSessionRail ? "true" : "false"}
819
+ data-has-inspector=${showInspector ? "true" : "false"}
820
+ >
821
+ <${SidebarNav} />
822
+ ${showSessionRail
823
+ ? html`<${SessionRail}
824
+ onResizeStart=${handleResizeStart}
825
+ onResizeReset=${handleResizeReset}
826
+ showResizer=${isDesktop}
827
+ />`
828
+ : null}
829
+ <div class="app-main">
830
+ <div class="main-panel">
831
+ <${Header} />
832
+ ${backendDown.value ? html`<${OfflineBanner} />` : null}
833
+ <${ToastContainer} />
834
+ <${CommandPalette} open=${paletteOpen} onClose=${paletteClose} />
835
+ <${PullToRefresh} onRefresh=${() => refreshTab(activeTab.value)}>
836
+ <main class="main-content" ref=${mainRef}>
837
+ <${CurrentTab} />
838
+ </main>
839
+ <//>
840
+ ${showScrollTop &&
841
+ html`
842
+ <button
843
+ class="scroll-top"
844
+ title="Back to top"
845
+ onClick=${() => {
846
+ mainRef.current?.scrollTo({ top: 0, behavior: "smooth" });
847
+ }}
848
+ >
849
+ Top
850
+ </button>
851
+ `}
852
+ </div>
853
+ </div>
854
+ ${showInspector
855
+ ? html`<${InspectorPanel}
856
+ onResizeStart=${handleResizeStart}
857
+ onResizeReset=${handleResizeReset}
858
+ showResizer=${isDesktop}
859
+ />`
860
+ : null}
861
+ </div>
862
+ <${BottomNav} />
863
+ `;
864
+ }
865
+
866
+ /* ─── Mount ─── */
867
+ preactRender(html`<${App} />`, document.getElementById("app"));