@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,2488 @@
1
+ /* ─────────────────────────────────────────────────────────────
2
+ * VirtEngine Control Center – Preact + HTM SPA
3
+ * Single-file Telegram Mini App (no build step)
4
+ * ────────────────────────────────────────────────────────────── */
5
+
6
+ import { h, render as preactRender } from "https://esm.sh/preact@10.25.4";
7
+ import {
8
+ useState,
9
+ useEffect,
10
+ useRef,
11
+ useCallback,
12
+ useMemo,
13
+ } from "https://esm.sh/preact@10.25.4/hooks";
14
+ import { signal, computed, effect } from "https://esm.sh/@preact/signals@1.3.1";
15
+ import htm from "https://esm.sh/htm@3.1.1";
16
+
17
+ const html = htm.bind(h);
18
+
19
+ /* ─── SVG Icons (inline) ─── */
20
+ const ICONS = {
21
+ grid: html`<svg
22
+ viewBox="0 0 24 24"
23
+ fill="none"
24
+ stroke="currentColor"
25
+ stroke-width="2"
26
+ stroke-linecap="round"
27
+ stroke-linejoin="round"
28
+ >
29
+ <rect x="3" y="3" width="7" height="7" />
30
+ <rect x="14" y="3" width="7" height="7" />
31
+ <rect x="3" y="14" width="7" height="7" />
32
+ <rect x="14" y="14" width="7" height="7" />
33
+ </svg>`,
34
+ check: html`<svg
35
+ viewBox="0 0 24 24"
36
+ fill="none"
37
+ stroke="currentColor"
38
+ stroke-width="2"
39
+ stroke-linecap="round"
40
+ stroke-linejoin="round"
41
+ >
42
+ <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
43
+ <polyline points="22 4 12 14.01 9 11.01" />
44
+ </svg>`,
45
+ cpu: html`<svg
46
+ viewBox="0 0 24 24"
47
+ fill="none"
48
+ stroke="currentColor"
49
+ stroke-width="2"
50
+ stroke-linecap="round"
51
+ stroke-linejoin="round"
52
+ >
53
+ <rect x="4" y="4" width="16" height="16" rx="2" ry="2" />
54
+ <rect x="9" y="9" width="6" height="6" />
55
+ <line x1="9" y1="1" x2="9" y2="4" />
56
+ <line x1="15" y1="1" x2="15" y2="4" />
57
+ <line x1="9" y1="20" x2="9" y2="23" />
58
+ <line x1="15" y1="20" x2="15" y2="23" />
59
+ <line x1="20" y1="9" x2="23" y2="9" />
60
+ <line x1="20" y1="14" x2="23" y2="14" />
61
+ <line x1="1" y1="9" x2="4" y2="9" />
62
+ <line x1="1" y1="14" x2="4" y2="14" />
63
+ </svg>`,
64
+ server: html`<svg
65
+ viewBox="0 0 24 24"
66
+ fill="none"
67
+ stroke="currentColor"
68
+ stroke-width="2"
69
+ stroke-linecap="round"
70
+ stroke-linejoin="round"
71
+ >
72
+ <rect x="2" y="2" width="20" height="8" rx="2" ry="2" />
73
+ <rect x="2" y="14" width="20" height="8" rx="2" ry="2" />
74
+ <line x1="6" y1="6" x2="6.01" y2="6" />
75
+ <line x1="6" y1="18" x2="6.01" y2="18" />
76
+ </svg>`,
77
+ sliders: html`<svg
78
+ viewBox="0 0 24 24"
79
+ fill="none"
80
+ stroke="currentColor"
81
+ stroke-width="2"
82
+ stroke-linecap="round"
83
+ stroke-linejoin="round"
84
+ >
85
+ <line x1="4" y1="21" x2="4" y2="14" />
86
+ <line x1="4" y1="10" x2="4" y2="3" />
87
+ <line x1="12" y1="21" x2="12" y2="12" />
88
+ <line x1="12" y1="8" x2="12" y2="3" />
89
+ <line x1="20" y1="21" x2="20" y2="16" />
90
+ <line x1="20" y1="12" x2="20" y2="3" />
91
+ <line x1="1" y1="14" x2="7" y2="14" />
92
+ <line x1="9" y1="8" x2="15" y2="8" />
93
+ <line x1="17" y1="16" x2="23" y2="16" />
94
+ </svg>`,
95
+ terminal: html`<svg
96
+ viewBox="0 0 24 24"
97
+ fill="none"
98
+ stroke="currentColor"
99
+ stroke-width="2"
100
+ stroke-linecap="round"
101
+ stroke-linejoin="round"
102
+ >
103
+ <polyline points="4 17 10 11 4 5" />
104
+ <line x1="12" y1="19" x2="20" y2="19" />
105
+ </svg>`,
106
+ plus: html`<svg
107
+ viewBox="0 0 24 24"
108
+ fill="none"
109
+ stroke="currentColor"
110
+ stroke-width="2"
111
+ stroke-linecap="round"
112
+ stroke-linejoin="round"
113
+ >
114
+ <line x1="12" y1="5" x2="12" y2="19" />
115
+ <line x1="5" y1="12" x2="19" y2="12" />
116
+ </svg>`,
117
+ chevronDown: html`<svg
118
+ viewBox="0 0 24 24"
119
+ fill="none"
120
+ stroke="currentColor"
121
+ stroke-width="2"
122
+ stroke-linecap="round"
123
+ stroke-linejoin="round"
124
+ width="16"
125
+ height="16"
126
+ >
127
+ <polyline points="6 9 12 15 18 9" />
128
+ </svg>`,
129
+ send: html`<svg
130
+ viewBox="0 0 24 24"
131
+ fill="none"
132
+ stroke="currentColor"
133
+ stroke-width="2"
134
+ stroke-linecap="round"
135
+ stroke-linejoin="round"
136
+ width="16"
137
+ height="16"
138
+ >
139
+ <line x1="22" y1="2" x2="11" y2="13" />
140
+ <polygon points="22 2 15 22 11 13 2 9 22 2" />
141
+ </svg>`,
142
+ refresh: html`<svg
143
+ viewBox="0 0 24 24"
144
+ fill="none"
145
+ stroke="currentColor"
146
+ stroke-width="2"
147
+ stroke-linecap="round"
148
+ stroke-linejoin="round"
149
+ width="16"
150
+ height="16"
151
+ >
152
+ <polyline points="23 4 23 10 17 10" />
153
+ <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
154
+ </svg>`,
155
+ };
156
+
157
+ /* ─── Telegram SDK ─── */
158
+ function getTg() {
159
+ return globalThis.Telegram?.WebApp || null;
160
+ }
161
+
162
+ function haptic(type = "light") {
163
+ try {
164
+ getTg()?.HapticFeedback?.impactOccurred(type);
165
+ } catch {
166
+ /* noop */
167
+ }
168
+ }
169
+
170
+ function applyTgTheme() {
171
+ const tg = getTg();
172
+ if (!tg?.themeParams) return;
173
+ const tp = tg.themeParams;
174
+ const root = document.documentElement;
175
+ root.setAttribute("data-tg-theme", "true");
176
+ if (tp.bg_color) root.style.setProperty("--bg-primary", tp.bg_color);
177
+ if (tp.secondary_bg_color) {
178
+ root.style.setProperty("--bg-secondary", tp.secondary_bg_color);
179
+ root.style.setProperty("--bg-card", tp.secondary_bg_color);
180
+ }
181
+ if (tp.text_color) root.style.setProperty("--text-primary", tp.text_color);
182
+ if (tp.hint_color) {
183
+ root.style.setProperty("--text-secondary", tp.hint_color);
184
+ root.style.setProperty("--text-hint", tp.hint_color);
185
+ }
186
+ if (tp.link_color) root.style.setProperty("--accent", tp.link_color);
187
+ if (tp.button_color) root.style.setProperty("--accent", tp.button_color);
188
+ if (tp.button_text_color)
189
+ root.style.setProperty("--accent-text", tp.button_text_color);
190
+ }
191
+
192
+ /* ─── Global Signals ─── */
193
+ const activeTab = signal("dashboard");
194
+ const connected = signal(false);
195
+ const wsConnected = signal(false);
196
+
197
+ // Per-tab data signals
198
+ const statusData = signal(null);
199
+ const executorData = signal(null);
200
+ const tasksData = signal([]);
201
+ const tasksTotal = signal(0);
202
+ const tasksPage = signal(0);
203
+ const tasksPageSize = signal(8);
204
+ const tasksStatus = signal("todo");
205
+ const tasksProject = signal("");
206
+ const tasksQuery = signal("");
207
+ const projectsData = signal([]);
208
+ const logsData = signal(null);
209
+ const logsLines = signal(200);
210
+ const threadsData = signal([]);
211
+ const worktreesData = signal([]);
212
+ const worktreeStats = signal(null);
213
+ const presenceData = signal(null);
214
+ const sharedWorkspacesData = signal(null);
215
+ const sharedAvailability = signal(null);
216
+ const gitBranches = signal([]);
217
+ const gitDiff = signal("");
218
+ const agentLogFiles = signal([]);
219
+ const agentLogFile = signal("");
220
+ const agentLogLines = signal(200);
221
+ const agentLogQuery = signal("");
222
+ const agentLogTail = signal(null);
223
+ const agentContext = signal(null);
224
+ const manualMode = signal(false);
225
+ const modalState = signal(null);
226
+ const toasts = signal([]);
227
+ const loading = signal(false);
228
+
229
+ // WebSocket state
230
+ let ws = null;
231
+ let wsRetryMs = 1000;
232
+ let wsReconnectTimer = null;
233
+ let wsRefreshTimer = null;
234
+ let pendingMutation = false;
235
+
236
+ /* ─── Toast System ─── */
237
+ let toastId = 0;
238
+ function addToast(message, type = "info") {
239
+ const id = ++toastId;
240
+ toasts.value = [...toasts.value, { id, message, type }];
241
+ setTimeout(() => {
242
+ toasts.value = toasts.value.filter((t) => t.id !== id);
243
+ }, 3500);
244
+ }
245
+
246
+ /* ─── API Client ─── */
247
+ async function apiFetch(path, options = {}) {
248
+ const headers = { "Content-Type": "application/json" };
249
+ const tg = getTg();
250
+ if (tg?.initData) {
251
+ headers["X-Telegram-InitData"] = tg.initData;
252
+ }
253
+ try {
254
+ const res = await fetch(path, { ...options, headers });
255
+ if (!res.ok) {
256
+ const text = await res.text();
257
+ throw new Error(text || `Request failed (${res.status})`);
258
+ }
259
+ return res.json();
260
+ } catch (err) {
261
+ if (!options._silent) addToast(err.message, "error");
262
+ throw err;
263
+ }
264
+ }
265
+
266
+ function sendCommandToChat(command) {
267
+ const tg = getTg();
268
+ if (!tg) return;
269
+ tg.sendData(JSON.stringify({ type: "command", command }));
270
+ if (tg.showPopup) {
271
+ tg.showPopup({
272
+ title: "Sent",
273
+ message: command,
274
+ buttons: [{ type: "ok" }],
275
+ });
276
+ }
277
+ haptic("medium");
278
+ }
279
+
280
+ /* ─── Data Loaders ─── */
281
+ async function loadOverview() {
282
+ const [status, executor] = await Promise.all([
283
+ apiFetch("/api/status", { _silent: true }).catch(() => ({ data: null })),
284
+ apiFetch("/api/executor", { _silent: true }).catch(() => ({ data: null })),
285
+ ]);
286
+ statusData.value = status.data || null;
287
+ executorData.value = executor;
288
+ }
289
+
290
+ async function loadProjects() {
291
+ const res = await apiFetch("/api/projects", { _silent: true }).catch(() => ({
292
+ data: [],
293
+ }));
294
+ projectsData.value = res.data || [];
295
+ if (!tasksProject.value && projectsData.value.length) {
296
+ tasksProject.value = projectsData.value[0].id || "";
297
+ }
298
+ }
299
+
300
+ async function loadTasks() {
301
+ const params = new URLSearchParams({
302
+ status: tasksStatus.value,
303
+ page: String(tasksPage.value),
304
+ pageSize: String(tasksPageSize.value),
305
+ });
306
+ if (tasksProject.value) params.set("project", tasksProject.value);
307
+ const res = await apiFetch(`/api/tasks?${params}`, { _silent: true }).catch(
308
+ () => ({ data: [], total: 0 }),
309
+ );
310
+ tasksData.value = res.data || [];
311
+ tasksTotal.value = res.total || 0;
312
+ }
313
+
314
+ async function loadLogs() {
315
+ const res = await apiFetch(`/api/logs?lines=${logsLines.value}`, {
316
+ _silent: true,
317
+ }).catch(() => ({ data: null }));
318
+ logsData.value = res.data || null;
319
+ }
320
+
321
+ async function loadThreads() {
322
+ const res = await apiFetch("/api/threads", { _silent: true }).catch(() => ({
323
+ data: [],
324
+ }));
325
+ threadsData.value = res.data || [];
326
+ }
327
+
328
+ async function loadWorktrees() {
329
+ const res = await apiFetch("/api/worktrees", { _silent: true }).catch(() => ({
330
+ data: [],
331
+ stats: null,
332
+ }));
333
+ worktreesData.value = res.data || [];
334
+ worktreeStats.value = res.stats || null;
335
+ }
336
+
337
+ async function loadPresence() {
338
+ const res = await apiFetch("/api/presence", { _silent: true }).catch(() => ({
339
+ data: null,
340
+ }));
341
+ presenceData.value = res.data || null;
342
+ }
343
+
344
+ async function loadSharedWorkspaces() {
345
+ const res = await apiFetch("/api/shared-workspaces", { _silent: true }).catch(
346
+ () => ({ data: null, availability: null }),
347
+ );
348
+ sharedWorkspacesData.value = res.data || null;
349
+ sharedAvailability.value = res.availability || null;
350
+ }
351
+
352
+ async function loadGit() {
353
+ const [branches, diff] = await Promise.all([
354
+ apiFetch("/api/git/branches", { _silent: true }).catch(() => ({
355
+ data: [],
356
+ })),
357
+ apiFetch("/api/git/diff", { _silent: true }).catch(() => ({ data: "" })),
358
+ ]);
359
+ gitBranches.value = branches.data || [];
360
+ gitDiff.value = diff.data || "";
361
+ }
362
+
363
+ async function loadAgentLogFileList() {
364
+ const params = new URLSearchParams();
365
+ if (agentLogQuery.value) params.set("query", agentLogQuery.value);
366
+ const path = params.toString()
367
+ ? `/api/agent-logs?${params}`
368
+ : "/api/agent-logs";
369
+ const res = await apiFetch(path, { _silent: true }).catch(() => ({
370
+ data: [],
371
+ }));
372
+ agentLogFiles.value = res.data || [];
373
+ }
374
+
375
+ async function loadAgentLogTailData() {
376
+ if (!agentLogFile.value) {
377
+ agentLogTail.value = null;
378
+ return;
379
+ }
380
+ const params = new URLSearchParams({
381
+ file: agentLogFile.value,
382
+ lines: String(agentLogLines.value),
383
+ });
384
+ const res = await apiFetch(`/api/agent-logs?${params}`, {
385
+ _silent: true,
386
+ }).catch(() => ({ data: null }));
387
+ agentLogTail.value = res.data || null;
388
+ }
389
+
390
+ async function loadAgentContextData(query) {
391
+ if (!query) {
392
+ agentContext.value = null;
393
+ return;
394
+ }
395
+ const res = await apiFetch(
396
+ `/api/agent-logs/context?query=${encodeURIComponent(query)}`,
397
+ { _silent: true },
398
+ ).catch(() => ({ data: null }));
399
+ agentContext.value = res.data || null;
400
+ }
401
+
402
+ /* ─── Tab Refresh ─── */
403
+ async function refreshTab() {
404
+ loading.value = true;
405
+ try {
406
+ const tab = activeTab.value;
407
+ if (tab === "dashboard") {
408
+ await loadOverview();
409
+ }
410
+ if (tab === "tasks") {
411
+ await loadProjects();
412
+ await loadTasks();
413
+ }
414
+ if (tab === "agents") {
415
+ await loadOverview();
416
+ await loadThreads();
417
+ }
418
+ if (tab === "infra") {
419
+ await Promise.all([
420
+ loadWorktrees(),
421
+ loadSharedWorkspaces(),
422
+ loadPresence(),
423
+ ]);
424
+ }
425
+ if (tab === "control") {
426
+ await loadOverview();
427
+ }
428
+ if (tab === "logs") {
429
+ await Promise.all([
430
+ loadLogs(),
431
+ loadAgentLogFileList(),
432
+ loadAgentLogTailData(),
433
+ ]);
434
+ }
435
+ } catch {
436
+ /* handled by apiFetch */
437
+ }
438
+ loading.value = false;
439
+ }
440
+
441
+ /* ─── WebSocket ─── */
442
+ function channelsForTab(tab) {
443
+ const map = {
444
+ dashboard: ["overview", "executor", "tasks", "agents"],
445
+ tasks: ["tasks"],
446
+ agents: ["agents", "executor"],
447
+ infra: ["worktrees", "workspaces", "presence"],
448
+ control: ["executor", "overview"],
449
+ logs: ["*"],
450
+ };
451
+ return map[tab] || ["*"];
452
+ }
453
+
454
+ function scheduleRefresh(delayMs = 120) {
455
+ if (wsRefreshTimer) clearTimeout(wsRefreshTimer);
456
+ wsRefreshTimer = setTimeout(async () => {
457
+ wsRefreshTimer = null;
458
+ if (pendingMutation) return;
459
+ try {
460
+ await refreshTab();
461
+ } catch {
462
+ /* ignore */
463
+ }
464
+ }, delayMs);
465
+ }
466
+
467
+ function connectRealtime() {
468
+ const tg = getTg();
469
+ const proto = globalThis.location.protocol === "https:" ? "wss" : "ws";
470
+ const wsUrl = new URL(`${proto}://${globalThis.location.host}/ws`);
471
+ if (tg?.initData) wsUrl.searchParams.set("initData", tg.initData);
472
+ const socket = new WebSocket(wsUrl.toString());
473
+ ws = socket;
474
+
475
+ socket.addEventListener("open", () => {
476
+ wsConnected.value = true;
477
+ connected.value = true;
478
+ wsRetryMs = 1000;
479
+ socket.send(
480
+ JSON.stringify({
481
+ type: "subscribe",
482
+ channels: channelsForTab(activeTab.value),
483
+ }),
484
+ );
485
+ });
486
+
487
+ socket.addEventListener("message", (event) => {
488
+ let msg;
489
+ try {
490
+ msg = JSON.parse(event.data || "{}");
491
+ } catch {
492
+ return;
493
+ }
494
+ if (msg?.type !== "invalidate") return;
495
+ const channels = Array.isArray(msg.channels) ? msg.channels : [];
496
+ const interested = channelsForTab(activeTab.value);
497
+ if (
498
+ channels.includes("*") ||
499
+ channels.some((c) => interested.includes(c))
500
+ ) {
501
+ scheduleRefresh(120);
502
+ }
503
+ });
504
+
505
+ socket.addEventListener("close", () => {
506
+ wsConnected.value = false;
507
+ connected.value = false;
508
+ if (wsReconnectTimer) clearTimeout(wsReconnectTimer);
509
+ wsReconnectTimer = setTimeout(connectRealtime, wsRetryMs);
510
+ wsRetryMs = Math.min(10000, wsRetryMs * 2);
511
+ });
512
+
513
+ socket.addEventListener("error", () => {
514
+ connected.value = false;
515
+ });
516
+ }
517
+
518
+ function switchWsChannel(tab) {
519
+ if (ws?.readyState === WebSocket.OPEN) {
520
+ ws.send(
521
+ JSON.stringify({ type: "subscribe", channels: channelsForTab(tab) }),
522
+ );
523
+ }
524
+ }
525
+
526
+ /* ─── Optimistic Mutations ─── */
527
+ async function runOptimistic(apply, request, rollback) {
528
+ pendingMutation = true;
529
+ try {
530
+ apply();
531
+ const response = await request();
532
+ pendingMutation = false;
533
+ return response;
534
+ } catch (err) {
535
+ if (typeof rollback === "function") rollback();
536
+ pendingMutation = false;
537
+ throw err;
538
+ }
539
+ }
540
+
541
+ function cloneValue(value) {
542
+ if (typeof structuredClone === "function") return structuredClone(value);
543
+ return JSON.parse(JSON.stringify(value));
544
+ }
545
+
546
+ /* ─── Shared Components ─── */
547
+ function ToastContainer() {
548
+ const items = toasts.value;
549
+ if (!items.length) return null;
550
+ return html`
551
+ <div class="toast-container">
552
+ ${items.map(
553
+ (t) =>
554
+ html`<div key=${t.id} class="toast toast-${t.type}">
555
+ ${t.message}
556
+ </div>`,
557
+ )}
558
+ </div>
559
+ `;
560
+ }
561
+
562
+ function Card({ title, subtitle, children, className = "" }) {
563
+ return html`
564
+ <div class="card ${className}">
565
+ ${title && html`<div class="card-title">${title}</div>`}
566
+ ${subtitle && html`<div class="card-subtitle">${subtitle}</div>`}
567
+ ${children}
568
+ </div>
569
+ `;
570
+ }
571
+
572
+ function Badge({ status, text }) {
573
+ const label = text || status || "";
574
+ const cls = `badge badge-${(status || "").toLowerCase().replace(/\s/g, "")}`;
575
+ return html`<span class=${cls}>${label}</span>`;
576
+ }
577
+
578
+ function StatCard({ value, label, color }) {
579
+ const style = color ? `color: ${color}` : "";
580
+ return html`
581
+ <div class="stat-card">
582
+ <div class="stat-value" style=${style}>${value ?? "—"}</div>
583
+ <div class="stat-label">${label}</div>
584
+ </div>
585
+ `;
586
+ }
587
+
588
+ function SkeletonCard({ count = 3 }) {
589
+ return html`${Array.from(
590
+ { length: count },
591
+ (_, i) => html`<div key=${i} class="skeleton skeleton-card"></div>`,
592
+ )}`;
593
+ }
594
+
595
+ function ProgressBar({ percent = 0 }) {
596
+ return html`
597
+ <div class="progress-bar">
598
+ <div
599
+ class="progress-bar-fill"
600
+ style="width: ${Math.min(100, Math.max(0, percent))}%"
601
+ ></div>
602
+ </div>
603
+ `;
604
+ }
605
+
606
+ function DonutChart({ segments = [] }) {
607
+ const total = segments.reduce((s, seg) => s + (seg.value || 0), 0);
608
+ if (!total) return html`<div class="text-center meta-text">No data</div>`;
609
+ const size = 100;
610
+ const cx = size / 2,
611
+ cy = size / 2,
612
+ r = 36,
613
+ sw = 12;
614
+ const circumference = 2 * Math.PI * r;
615
+ let offset = 0;
616
+ const arcs = segments.map((seg) => {
617
+ const pct = seg.value / total;
618
+ const dash = pct * circumference;
619
+ const o = offset;
620
+ offset += dash;
621
+ return html`<circle
622
+ cx=${cx}
623
+ cy=${cy}
624
+ r=${r}
625
+ fill="none"
626
+ stroke=${seg.color}
627
+ stroke-width=${sw}
628
+ stroke-dasharray="${dash} ${circumference - dash}"
629
+ stroke-dashoffset=${-o}
630
+ style="transition: stroke-dasharray 0.6s ease, stroke-dashoffset 0.6s ease"
631
+ />`;
632
+ });
633
+ return html`
634
+ <div class="donut-wrap">
635
+ <svg
636
+ width=${size}
637
+ height=${size}
638
+ viewBox="0 0 ${size} ${size}"
639
+ style="transform: rotate(-90deg)"
640
+ >
641
+ ${arcs}
642
+ </svg>
643
+ </div>
644
+ <div class="donut-legend">
645
+ ${segments.map(
646
+ (seg) => html`
647
+ <span class="donut-legend-item">
648
+ <span
649
+ class="donut-legend-swatch"
650
+ style="background: ${seg.color}"
651
+ ></span>
652
+ ${seg.label} (${seg.value})
653
+ </span>
654
+ `,
655
+ )}
656
+ </div>
657
+ `;
658
+ }
659
+
660
+ function SegmentedControl({ options, value, onChange }) {
661
+ return html`
662
+ <div class="segmented-control">
663
+ ${options.map(
664
+ (opt) => html`
665
+ <button
666
+ key=${opt.value}
667
+ class="segmented-btn ${value === opt.value ? "active" : ""}"
668
+ onClick=${() => {
669
+ haptic();
670
+ onChange(opt.value);
671
+ }}
672
+ >
673
+ ${opt.label}
674
+ </button>
675
+ `,
676
+ )}
677
+ </div>
678
+ `;
679
+ }
680
+
681
+ function Modal({ title, onClose, children }) {
682
+ useEffect(() => {
683
+ const tg = getTg();
684
+ if (tg?.BackButton) {
685
+ tg.BackButton.show();
686
+ const handler = () => {
687
+ onClose();
688
+ tg.BackButton.hide();
689
+ tg.BackButton.offClick(handler);
690
+ };
691
+ tg.BackButton.onClick(handler);
692
+ return () => {
693
+ tg.BackButton.hide();
694
+ tg.BackButton.offClick(handler);
695
+ };
696
+ }
697
+ }, [onClose]);
698
+
699
+ return html`
700
+ <div
701
+ class="modal-overlay"
702
+ onClick=${(e) => {
703
+ if (e.target === e.currentTarget) onClose();
704
+ }}
705
+ >
706
+ <div class="modal-content" onClick=${(e) => e.stopPropagation()}>
707
+ <div class="modal-handle"></div>
708
+ ${title && html`<div class="modal-title">${title}</div>`} ${children}
709
+ </div>
710
+ </div>
711
+ `;
712
+ }
713
+
714
+ function Collapsible({ title, defaultOpen = true, children }) {
715
+ const [open, setOpen] = useState(defaultOpen);
716
+ return html`
717
+ <div>
718
+ <button
719
+ class="collapsible-header ${open ? "open" : ""}"
720
+ onClick=${() => setOpen(!open)}
721
+ >
722
+ <span>${title}</span>
723
+ ${ICONS.chevronDown}
724
+ </button>
725
+ <div class="collapsible-body ${open ? "open" : ""}">${children}</div>
726
+ </div>
727
+ `;
728
+ }
729
+
730
+ function PullToRefresh({ onRefresh, children }) {
731
+ const [refreshing, setRefreshing] = useState(false);
732
+ const ref = useRef(null);
733
+ const startY = useRef(0);
734
+ const pulling = useRef(false);
735
+
736
+ const handleTouchStart = useCallback((e) => {
737
+ if (ref.current && ref.current.scrollTop === 0) {
738
+ startY.current = e.touches[0].clientY;
739
+ pulling.current = true;
740
+ }
741
+ }, []);
742
+
743
+ const handleTouchMove = useCallback(() => {
744
+ // passive listener – visual feedback could go here
745
+ }, []);
746
+
747
+ const handleTouchEnd = useCallback(
748
+ async (e) => {
749
+ if (!pulling.current) return;
750
+ pulling.current = false;
751
+ const diff = (e.changedTouches?.[0]?.clientY || 0) - startY.current;
752
+ if (diff > 60) {
753
+ setRefreshing(true);
754
+ haptic("medium");
755
+ try {
756
+ await onRefresh();
757
+ } finally {
758
+ setRefreshing(false);
759
+ }
760
+ }
761
+ },
762
+ [onRefresh],
763
+ );
764
+
765
+ return html`
766
+ <div
767
+ ref=${ref}
768
+ class="main-content"
769
+ onTouchStart=${handleTouchStart}
770
+ onTouchMove=${handleTouchMove}
771
+ onTouchEnd=${handleTouchEnd}
772
+ >
773
+ ${refreshing &&
774
+ html`<div class="ptr-spinner"><div class="ptr-spinner-icon"></div></div>`}
775
+ ${children}
776
+ </div>
777
+ `;
778
+ }
779
+
780
+ /* ═══════════════════════════════════════════════
781
+ * TAB: Dashboard
782
+ * ═══════════════════════════════════════════════ */
783
+ function DashboardTab() {
784
+ const status = statusData.value;
785
+ const executor = executorData.value;
786
+ const counts = status?.counts || {};
787
+ const summary = status?.success_metrics || {};
788
+ const execData = executor?.data;
789
+ const mode = executor?.mode || "vk";
790
+ const running = Number(counts.running || 0);
791
+ const review = Number(counts.review || 0);
792
+ const blocked = Number(counts.error || 0);
793
+ const backlog = Number(status?.backlog_remaining || 0);
794
+ const totalActive = running + review + blocked;
795
+ const progressPct =
796
+ backlog + totalActive > 0
797
+ ? Math.round((totalActive / (backlog + totalActive)) * 100)
798
+ : 0;
799
+
800
+ const segments = [
801
+ { label: "Running", value: running, color: "var(--color-inprogress)" },
802
+ { label: "Review", value: review, color: "var(--color-inreview)" },
803
+ { label: "Blocked", value: blocked, color: "var(--color-error)" },
804
+ { label: "Backlog", value: backlog, color: "var(--color-todo)" },
805
+ ];
806
+
807
+ const handlePause = async () => {
808
+ haptic("medium");
809
+ const prev = cloneValue(executor);
810
+ await runOptimistic(
811
+ () => {
812
+ if (executorData.value)
813
+ executorData.value = { ...executorData.value, paused: true };
814
+ },
815
+ () => apiFetch("/api/executor/pause", { method: "POST" }),
816
+ () => {
817
+ executorData.value = prev;
818
+ },
819
+ ).catch(() => {});
820
+ scheduleRefresh(120);
821
+ };
822
+
823
+ const handleResume = async () => {
824
+ haptic("medium");
825
+ const prev = cloneValue(executor);
826
+ await runOptimistic(
827
+ () => {
828
+ if (executorData.value)
829
+ executorData.value = { ...executorData.value, paused: false };
830
+ },
831
+ () => apiFetch("/api/executor/resume", { method: "POST" }),
832
+ () => {
833
+ executorData.value = prev;
834
+ },
835
+ ).catch(() => {});
836
+ scheduleRefresh(120);
837
+ };
838
+
839
+ if (loading.value && !status)
840
+ return html`<${Card} title="Loading..."><${SkeletonCard} count=${4} /><//>`;
841
+
842
+ return html`
843
+ <${Card} title="Today at a Glance">
844
+ <div class="stats-grid">
845
+ <${StatCard}
846
+ value=${running}
847
+ label="Running"
848
+ color="var(--color-inprogress)"
849
+ />
850
+ <${StatCard}
851
+ value=${review}
852
+ label="In Review"
853
+ color="var(--color-inreview)"
854
+ />
855
+ <${StatCard}
856
+ value=${blocked}
857
+ label="Blocked"
858
+ color="var(--color-error)"
859
+ />
860
+ <${StatCard}
861
+ value=${backlog}
862
+ label="Backlog"
863
+ color="var(--color-todo)"
864
+ />
865
+ </div>
866
+ <//>
867
+ <${Card} title="Task Distribution">
868
+ <${DonutChart} segments=${segments} />
869
+ <div class="meta-text text-center mt-sm">
870
+ Active progress · ${progressPct}% engaged
871
+ </div>
872
+ <${ProgressBar} percent=${progressPct} />
873
+ <//>
874
+ <${Card} title="Executor">
875
+ <div class="meta-text mb-sm">
876
+ Mode: ${mode} · Slots:
877
+ ${execData?.activeSlots ?? 0}/${execData?.maxParallel ?? "—"} · Paused:
878
+ ${executor?.paused ? "Yes" : "No"}
879
+ </div>
880
+ <div class="btn-row">
881
+ <button class="btn btn-primary btn-sm" onClick=${handlePause}>
882
+ Pause
883
+ </button>
884
+ <button class="btn btn-secondary btn-sm" onClick=${handleResume}>
885
+ Resume
886
+ </button>
887
+ </div>
888
+ <//>
889
+ <${Card} title="Quality">
890
+ <div class="meta-text">
891
+ First-shot: ${summary.first_shot_rate ?? 0}% · Needed fix:
892
+ ${summary.needed_fix ?? 0} · Failed: ${summary.failed ?? 0}
893
+ </div>
894
+ <div class="btn-row mt-sm">
895
+ <button
896
+ class="btn btn-ghost btn-sm"
897
+ onClick=${() => sendCommandToChat("/status")}
898
+ >
899
+ /status
900
+ </button>
901
+ <button
902
+ class="btn btn-ghost btn-sm"
903
+ onClick=${() => sendCommandToChat("/health")}
904
+ >
905
+ /health
906
+ </button>
907
+ </div>
908
+ <//>
909
+ `;
910
+ }
911
+
912
+ /* ═══════════════════════════════════════════════
913
+ * TAB: Tasks
914
+ * ═══════════════════════════════════════════════ */
915
+ function CreateTaskModal({ onClose }) {
916
+ const [title, setTitle] = useState("");
917
+ const [description, setDescription] = useState("");
918
+ const [status, setStatus] = useState("todo");
919
+ const [priority, setPriority] = useState("");
920
+ const [submitting, setSubmitting] = useState(false);
921
+
922
+ const handleSubmit = async () => {
923
+ if (!title.trim()) {
924
+ addToast("Title is required", "error");
925
+ return;
926
+ }
927
+ setSubmitting(true);
928
+ haptic("medium");
929
+ try {
930
+ const project = tasksProject.value;
931
+ await apiFetch("/api/tasks/create", {
932
+ method: "POST",
933
+ body: JSON.stringify({
934
+ title: title.trim(),
935
+ description: description.trim(),
936
+ status,
937
+ priority: priority || undefined,
938
+ project,
939
+ }),
940
+ });
941
+ addToast("Task created", "success");
942
+ onClose();
943
+ await refreshTab();
944
+ } catch {
945
+ /* toast shown by apiFetch */
946
+ }
947
+ setSubmitting(false);
948
+ };
949
+
950
+ // Use Telegram MainButton for submit
951
+ useEffect(() => {
952
+ const tg = getTg();
953
+ if (tg?.MainButton) {
954
+ tg.MainButton.setText("Create Task");
955
+ tg.MainButton.show();
956
+ tg.MainButton.onClick(handleSubmit);
957
+ return () => {
958
+ tg.MainButton.hide();
959
+ tg.MainButton.offClick(handleSubmit);
960
+ };
961
+ }
962
+ }, [title, description, status, priority]);
963
+
964
+ return html`
965
+ <${Modal} title="New Task" onClose=${onClose}>
966
+ <div class="flex-col gap-md">
967
+ <input
968
+ class="input"
969
+ placeholder="Task title"
970
+ value=${title}
971
+ onInput=${(e) => setTitle(e.target.value)}
972
+ />
973
+ <textarea
974
+ class="input"
975
+ rows="4"
976
+ placeholder="Description"
977
+ value=${description}
978
+ onInput=${(e) => setDescription(e.target.value)}
979
+ ></textarea>
980
+ <div class="input-row">
981
+ <select
982
+ class="input"
983
+ value=${status}
984
+ onChange=${(e) => setStatus(e.target.value)}
985
+ >
986
+ <option value="todo">Todo</option>
987
+ <option value="inprogress">In Progress</option>
988
+ <option value="inreview">In Review</option>
989
+ </select>
990
+ <select
991
+ class="input"
992
+ value=${priority}
993
+ onChange=${(e) => setPriority(e.target.value)}
994
+ >
995
+ <option value="">No priority</option>
996
+ <option value="low">Low</option>
997
+ <option value="medium">Medium</option>
998
+ <option value="high">High</option>
999
+ <option value="critical">Critical</option>
1000
+ </select>
1001
+ </div>
1002
+ <button
1003
+ class="btn btn-primary"
1004
+ onClick=${handleSubmit}
1005
+ disabled=${submitting}
1006
+ >
1007
+ ${submitting ? "Creating..." : "Create Task"}
1008
+ </button>
1009
+ </div>
1010
+ <//>
1011
+ `;
1012
+ }
1013
+
1014
+ function TaskDetailModal({ task, onClose }) {
1015
+ const [title, setTitle] = useState(task?.title || "");
1016
+ const [description, setDescription] = useState(task?.description || "");
1017
+ const [status, setStatus] = useState(task?.status || "todo");
1018
+ const [priority, setPriority] = useState(task?.priority || "");
1019
+ const [saving, setSaving] = useState(false);
1020
+
1021
+ const handleSave = async () => {
1022
+ setSaving(true);
1023
+ haptic("medium");
1024
+ const prev = cloneValue(tasksData.value);
1025
+ try {
1026
+ await runOptimistic(
1027
+ () => {
1028
+ tasksData.value = tasksData.value.map((t) =>
1029
+ t.id === task.id
1030
+ ? { ...t, title, description, status, priority: priority || null }
1031
+ : t,
1032
+ );
1033
+ },
1034
+ async () => {
1035
+ const res = await apiFetch("/api/tasks/edit", {
1036
+ method: "POST",
1037
+ body: JSON.stringify({
1038
+ taskId: task.id,
1039
+ title,
1040
+ description,
1041
+ status,
1042
+ priority,
1043
+ }),
1044
+ });
1045
+ if (res?.data)
1046
+ tasksData.value = tasksData.value.map((t) =>
1047
+ t.id === task.id ? { ...t, ...res.data } : t,
1048
+ );
1049
+ return res;
1050
+ },
1051
+ () => {
1052
+ tasksData.value = prev;
1053
+ },
1054
+ );
1055
+ addToast("Task saved", "success");
1056
+ onClose();
1057
+ } catch {
1058
+ /* toast via apiFetch */
1059
+ }
1060
+ setSaving(false);
1061
+ };
1062
+
1063
+ const handleStatusUpdate = async (newStatus) => {
1064
+ haptic("medium");
1065
+ const prev = cloneValue(tasksData.value);
1066
+ try {
1067
+ await runOptimistic(
1068
+ () => {
1069
+ tasksData.value = tasksData.value.map((t) =>
1070
+ t.id === task.id ? { ...t, status: newStatus } : t,
1071
+ );
1072
+ },
1073
+ async () => {
1074
+ const res = await apiFetch("/api/tasks/update", {
1075
+ method: "POST",
1076
+ body: JSON.stringify({ taskId: task.id, status: newStatus }),
1077
+ });
1078
+ if (res?.data)
1079
+ tasksData.value = tasksData.value.map((t) =>
1080
+ t.id === task.id ? { ...t, ...res.data } : t,
1081
+ );
1082
+ return res;
1083
+ },
1084
+ () => {
1085
+ tasksData.value = prev;
1086
+ },
1087
+ );
1088
+ if (newStatus === "done") onClose();
1089
+ else setStatus(newStatus);
1090
+ } catch {
1091
+ /* toast */
1092
+ }
1093
+ };
1094
+
1095
+ const handleStart = async () => {
1096
+ haptic("medium");
1097
+ const prev = cloneValue(tasksData.value);
1098
+ try {
1099
+ await runOptimistic(
1100
+ () => {
1101
+ tasksData.value = tasksData.value.map((t) =>
1102
+ t.id === task.id ? { ...t, status: "inprogress" } : t,
1103
+ );
1104
+ },
1105
+ () =>
1106
+ apiFetch("/api/tasks/start", {
1107
+ method: "POST",
1108
+ body: JSON.stringify({ taskId: task.id }),
1109
+ }),
1110
+ () => {
1111
+ tasksData.value = prev;
1112
+ },
1113
+ );
1114
+ onClose();
1115
+ } catch {
1116
+ /* toast */
1117
+ }
1118
+ scheduleRefresh(150);
1119
+ };
1120
+
1121
+ return html`
1122
+ <${Modal} title=${task?.title || "Task"} onClose=${onClose}>
1123
+ <div class="meta-text mb-md">ID: ${task?.id}</div>
1124
+ <div class="flex-col gap-md">
1125
+ <input
1126
+ class="input"
1127
+ placeholder="Title"
1128
+ value=${title}
1129
+ onInput=${(e) => setTitle(e.target.value)}
1130
+ />
1131
+ <textarea
1132
+ class="input"
1133
+ rows="5"
1134
+ placeholder="Description"
1135
+ value=${description}
1136
+ onInput=${(e) => setDescription(e.target.value)}
1137
+ ></textarea>
1138
+ <div class="input-row">
1139
+ <select
1140
+ class="input"
1141
+ value=${status}
1142
+ onChange=${(e) => setStatus(e.target.value)}
1143
+ >
1144
+ ${["todo", "inprogress", "inreview", "done", "cancelled"].map(
1145
+ (s) => html`<option value=${s}>${s}</option>`,
1146
+ )}
1147
+ </select>
1148
+ <select
1149
+ class="input"
1150
+ value=${priority}
1151
+ onChange=${(e) => setPriority(e.target.value)}
1152
+ >
1153
+ <option value="">No priority</option>
1154
+ ${["low", "medium", "high", "critical"].map(
1155
+ (p) => html`<option value=${p}>${p}</option>`,
1156
+ )}
1157
+ </select>
1158
+ </div>
1159
+ <div class="btn-row">
1160
+ ${manualMode.value &&
1161
+ task?.status === "todo" &&
1162
+ html`<button class="btn btn-primary btn-sm" onClick=${handleStart}>
1163
+ Start
1164
+ </button>`}
1165
+ <button
1166
+ class="btn btn-secondary btn-sm"
1167
+ onClick=${handleSave}
1168
+ disabled=${saving}
1169
+ >
1170
+ ${saving ? "Saving..." : "Save"}
1171
+ </button>
1172
+ <button
1173
+ class="btn btn-ghost btn-sm"
1174
+ onClick=${() => handleStatusUpdate("inreview")}
1175
+ >
1176
+ → Review
1177
+ </button>
1178
+ <button
1179
+ class="btn btn-ghost btn-sm"
1180
+ onClick=${() => handleStatusUpdate("done")}
1181
+ >
1182
+ → Done
1183
+ </button>
1184
+ </div>
1185
+ </div>
1186
+ <//>
1187
+ `;
1188
+ }
1189
+
1190
+ function TasksTab() {
1191
+ const [showCreate, setShowCreate] = useState(false);
1192
+ const [detailTask, setDetailTask] = useState(null);
1193
+ const searchRef = useRef(null);
1194
+
1195
+ const statuses = ["todo", "inprogress", "inreview", "done"];
1196
+ const search = tasksQuery.value.trim().toLowerCase();
1197
+ const visible = search
1198
+ ? tasksData.value.filter((t) =>
1199
+ `${t.title || ""} ${t.description || ""} ${t.id || ""}`
1200
+ .toLowerCase()
1201
+ .includes(search),
1202
+ )
1203
+ : tasksData.value;
1204
+ const totalPages = Math.max(
1205
+ 1,
1206
+ Math.ceil((tasksTotal.value || 0) / tasksPageSize.value),
1207
+ );
1208
+ const canManual = Boolean(executorData.value?.data);
1209
+
1210
+ const handleFilter = async (s) => {
1211
+ haptic();
1212
+ tasksStatus.value = s;
1213
+ tasksPage.value = 0;
1214
+ await refreshTab();
1215
+ };
1216
+ const handlePrev = async () => {
1217
+ tasksPage.value = Math.max(0, tasksPage.value - 1);
1218
+ await refreshTab();
1219
+ };
1220
+ const handleNext = async () => {
1221
+ tasksPage.value += 1;
1222
+ await refreshTab();
1223
+ };
1224
+
1225
+ const handleStatusUpdate = async (taskId, newStatus) => {
1226
+ haptic("medium");
1227
+ const prev = cloneValue(tasksData.value);
1228
+ await runOptimistic(
1229
+ () => {
1230
+ tasksData.value = tasksData.value.map((t) =>
1231
+ t.id === taskId ? { ...t, status: newStatus } : t,
1232
+ );
1233
+ },
1234
+ async () => {
1235
+ const res = await apiFetch("/api/tasks/update", {
1236
+ method: "POST",
1237
+ body: JSON.stringify({ taskId, status: newStatus }),
1238
+ });
1239
+ if (res?.data)
1240
+ tasksData.value = tasksData.value.map((t) =>
1241
+ t.id === taskId ? { ...t, ...res.data } : t,
1242
+ );
1243
+ },
1244
+ () => {
1245
+ tasksData.value = prev;
1246
+ },
1247
+ ).catch(() => {});
1248
+ };
1249
+
1250
+ const handleStart = async (taskId) => {
1251
+ haptic("medium");
1252
+ const prev = cloneValue(tasksData.value);
1253
+ await runOptimistic(
1254
+ () => {
1255
+ tasksData.value = tasksData.value.map((t) =>
1256
+ t.id === taskId ? { ...t, status: "inprogress" } : t,
1257
+ );
1258
+ },
1259
+ () =>
1260
+ apiFetch("/api/tasks/start", {
1261
+ method: "POST",
1262
+ body: JSON.stringify({ taskId }),
1263
+ }),
1264
+ () => {
1265
+ tasksData.value = prev;
1266
+ },
1267
+ ).catch(() => {});
1268
+ scheduleRefresh(150);
1269
+ };
1270
+
1271
+ const openDetail = async (taskId) => {
1272
+ haptic();
1273
+ const local = tasksData.value.find((t) => t.id === taskId);
1274
+ const result = await apiFetch(
1275
+ `/api/tasks/detail?taskId=${encodeURIComponent(taskId)}`,
1276
+ { _silent: true },
1277
+ ).catch(() => ({ data: local }));
1278
+ setDetailTask(result.data || local);
1279
+ };
1280
+
1281
+ const handleProjectChange = async (e) => {
1282
+ tasksProject.value = e.target.value;
1283
+ tasksPage.value = 0;
1284
+ await refreshTab();
1285
+ };
1286
+
1287
+ if (loading.value && !tasksData.value.length)
1288
+ return html`<${Card} title="Loading Tasks..."><${SkeletonCard} /><//>`;
1289
+
1290
+ return html`
1291
+ <${Card} title="Task Board">
1292
+ <div class="chip-group">
1293
+ ${statuses.map(
1294
+ (s) =>
1295
+ html`<button
1296
+ key=${s}
1297
+ class="chip ${tasksStatus.value === s ? "active" : ""}"
1298
+ onClick=${() => handleFilter(s)}
1299
+ >
1300
+ ${s.toUpperCase()}
1301
+ </button>`,
1302
+ )}
1303
+ </div>
1304
+ <div class="input-row">
1305
+ <select
1306
+ class="input"
1307
+ value=${tasksProject.value}
1308
+ onChange=${handleProjectChange}
1309
+ >
1310
+ ${projectsData.value.map(
1311
+ (p) =>
1312
+ html`<option key=${p.id} value=${p.id}>
1313
+ ${p.name || p.id}
1314
+ </option>`,
1315
+ )}
1316
+ </select>
1317
+ </div>
1318
+ <div class="flex-between mb-sm">
1319
+ <label
1320
+ class="meta-text"
1321
+ style="display:flex;align-items:center;gap:6px;cursor:pointer"
1322
+ onClick=${() => {
1323
+ if (canManual) {
1324
+ manualMode.value = !manualMode.value;
1325
+ haptic();
1326
+ }
1327
+ }}
1328
+ >
1329
+ <input
1330
+ type="checkbox"
1331
+ checked=${manualMode.value}
1332
+ disabled=${!canManual}
1333
+ style="accent-color:var(--accent)"
1334
+ />
1335
+ Manual Mode
1336
+ </label>
1337
+ <span class="pill">${visible.length} shown</span>
1338
+ </div>
1339
+ <input
1340
+ ref=${searchRef}
1341
+ class="input mb-md"
1342
+ placeholder="Search tasks..."
1343
+ value=${tasksQuery.value}
1344
+ onInput=${(e) => {
1345
+ tasksQuery.value = e.target.value;
1346
+ }}
1347
+ />
1348
+ <//>
1349
+
1350
+ ${visible.map(
1351
+ (task) => html`
1352
+ <div
1353
+ key=${task.id}
1354
+ class="task-card"
1355
+ onClick=${() => openDetail(task.id)}
1356
+ >
1357
+ <div class="task-card-header">
1358
+ <div>
1359
+ <div class="task-card-title">${task.title || "(untitled)"}</div>
1360
+ <div class="task-card-meta">
1361
+ ${task.id}${task.priority
1362
+ ? html` ·
1363
+ <${Badge}
1364
+ status=${task.priority}
1365
+ text=${task.priority}
1366
+ />`
1367
+ : ""}
1368
+ </div>
1369
+ </div>
1370
+ <${Badge} status=${task.status} text=${task.status} />
1371
+ </div>
1372
+ <div class="meta-text">
1373
+ ${task.description
1374
+ ? task.description.slice(0, 120)
1375
+ : "No description."}
1376
+ </div>
1377
+ <div class="btn-row mt-sm" onClick=${(e) => e.stopPropagation()}>
1378
+ ${manualMode.value &&
1379
+ task.status === "todo" &&
1380
+ canManual &&
1381
+ html`<button
1382
+ class="btn btn-primary btn-sm"
1383
+ onClick=${() => handleStart(task.id)}
1384
+ >
1385
+ Start
1386
+ </button>`}
1387
+ <button
1388
+ class="btn btn-secondary btn-sm"
1389
+ onClick=${() => handleStatusUpdate(task.id, "inreview")}
1390
+ >
1391
+ → Review
1392
+ </button>
1393
+ <button
1394
+ class="btn btn-ghost btn-sm"
1395
+ onClick=${() => handleStatusUpdate(task.id, "done")}
1396
+ >
1397
+ → Done
1398
+ </button>
1399
+ </div>
1400
+ </div>
1401
+ `,
1402
+ )}
1403
+ ${!visible.length &&
1404
+ html`<div class="card text-center meta-text" style="padding:24px">
1405
+ No tasks found.
1406
+ </div>`}
1407
+
1408
+ <div class="pager">
1409
+ <button class="btn btn-secondary btn-sm" onClick=${handlePrev}>
1410
+ Prev
1411
+ </button>
1412
+ <span class="pager-info"
1413
+ >Page ${tasksPage.value + 1} / ${totalPages}</span
1414
+ >
1415
+ <button class="btn btn-secondary btn-sm" onClick=${handleNext}>
1416
+ Next
1417
+ </button>
1418
+ </div>
1419
+
1420
+ <button
1421
+ class="fab"
1422
+ onClick=${() => {
1423
+ haptic();
1424
+ setShowCreate(true);
1425
+ }}
1426
+ >
1427
+ ${ICONS.plus}
1428
+ </button>
1429
+
1430
+ ${showCreate &&
1431
+ html`<${CreateTaskModal} onClose=${() => setShowCreate(false)} />`}
1432
+ ${detailTask &&
1433
+ html`<${TaskDetailModal}
1434
+ task=${detailTask}
1435
+ onClose=${() => setDetailTask(null)}
1436
+ />`}
1437
+ `;
1438
+ }
1439
+
1440
+ /* ═══════════════════════════════════════════════
1441
+ * TAB: Agents
1442
+ * ═══════════════════════════════════════════════ */
1443
+ function AgentsTab() {
1444
+ const executor = executorData.value;
1445
+ const slots = executor?.data?.slots || [];
1446
+ const threads = threadsData.value;
1447
+
1448
+ const viewAgentLogs = (query) => {
1449
+ haptic();
1450
+ agentLogQuery.value = query;
1451
+ agentLogFile.value = "";
1452
+ activeTab.value = "logs";
1453
+ switchWsChannel("logs");
1454
+ refreshTab();
1455
+ };
1456
+
1457
+ if (loading.value && !slots.length)
1458
+ return html`<${Card} title="Loading..."><${SkeletonCard} count=${3} /><//>`;
1459
+
1460
+ return html`
1461
+ <${Card} title="Active Agents">
1462
+ ${slots.length
1463
+ ? slots.map(
1464
+ (slot, i) => html`
1465
+ <div key=${i} class="task-card">
1466
+ <div class="task-card-header">
1467
+ <div>
1468
+ <div class="task-card-title">${slot.taskTitle}</div>
1469
+ <div class="task-card-meta">
1470
+ ${slot.taskId} · Agent ${slot.agentInstanceId || "n/a"} ·
1471
+ ${slot.sdk}
1472
+ </div>
1473
+ </div>
1474
+ <${Badge} status=${slot.status} text=${slot.status} />
1475
+ </div>
1476
+ <div class="meta-text">Attempt ${slot.attempt}</div>
1477
+ <div class="btn-row mt-sm">
1478
+ <button
1479
+ class="btn btn-ghost btn-sm"
1480
+ onClick=${() =>
1481
+ viewAgentLogs(
1482
+ (slot.taskId || slot.branch || "").slice(0, 12),
1483
+ )}
1484
+ >
1485
+ View Logs
1486
+ </button>
1487
+ <button
1488
+ class="btn btn-ghost btn-sm"
1489
+ onClick=${() =>
1490
+ sendCommandToChat(`/steer focus on ${slot.taskTitle}`)}
1491
+ >
1492
+ Steer
1493
+ </button>
1494
+ </div>
1495
+ </div>
1496
+ `,
1497
+ )
1498
+ : html`<div class="meta-text">No active agents.</div>`}
1499
+ <//>
1500
+ <${Card} title="Threads">
1501
+ ${threads.length
1502
+ ? html`
1503
+ <div class="stats-grid">
1504
+ ${threads.map(
1505
+ (t, i) => html`
1506
+ <${StatCard}
1507
+ key=${i}
1508
+ value=${t.turnCount}
1509
+ label="${t.taskKey} (${t.sdk})"
1510
+ />
1511
+ `,
1512
+ )}
1513
+ </div>
1514
+ `
1515
+ : html`<div class="meta-text">No threads.</div>`}
1516
+ <//>
1517
+ `;
1518
+ }
1519
+
1520
+ /* ═══════════════════════════════════════════════
1521
+ * TAB: Infra (Worktrees + Workspaces + Presence)
1522
+ * ═══════════════════════════════════════════════ */
1523
+ function InfraTab() {
1524
+ const wts = worktreesData.value;
1525
+ const wStats = worktreeStats.value || {};
1526
+ const registry = sharedWorkspacesData.value;
1527
+ const workspaces = registry?.workspaces || [];
1528
+ const availability = sharedAvailability.value || {};
1529
+ const presence = presenceData.value;
1530
+ const instances = presence?.instances || [];
1531
+ const coordinator = presence?.coordinator || null;
1532
+
1533
+ const [releaseInput, setReleaseInput] = useState("");
1534
+ const [sharedOwner, setSharedOwner] = useState("");
1535
+ const [sharedTtl, setSharedTtl] = useState("");
1536
+ const [sharedNote, setSharedNote] = useState("");
1537
+
1538
+ const handlePrune = async () => {
1539
+ haptic("medium");
1540
+ await apiFetch("/api/worktrees/prune", { method: "POST" }).catch(() => {});
1541
+ scheduleRefresh(120);
1542
+ };
1543
+
1544
+ const handleRelease = async (key, branch) => {
1545
+ haptic("medium");
1546
+ const prev = cloneValue(wts);
1547
+ await runOptimistic(
1548
+ () => {
1549
+ worktreesData.value = worktreesData.value.filter(
1550
+ (w) => w.taskKey !== key && w.branch !== branch,
1551
+ );
1552
+ },
1553
+ () =>
1554
+ apiFetch("/api/worktrees/release", {
1555
+ method: "POST",
1556
+ body: JSON.stringify({ taskKey: key, branch }),
1557
+ }),
1558
+ () => {
1559
+ worktreesData.value = prev;
1560
+ },
1561
+ ).catch(() => {});
1562
+ scheduleRefresh(120);
1563
+ };
1564
+
1565
+ const handleReleaseInput = async () => {
1566
+ if (!releaseInput.trim()) return;
1567
+ haptic("medium");
1568
+ await apiFetch("/api/worktrees/release", {
1569
+ method: "POST",
1570
+ body: JSON.stringify({
1571
+ taskKey: releaseInput.trim(),
1572
+ branch: releaseInput.trim(),
1573
+ }),
1574
+ }).catch(() => {});
1575
+ setReleaseInput("");
1576
+ scheduleRefresh(120);
1577
+ };
1578
+
1579
+ const handleClaim = async (wsId) => {
1580
+ haptic("medium");
1581
+ const prev = cloneValue(sharedWorkspacesData.value);
1582
+ await runOptimistic(
1583
+ () => {
1584
+ const w = sharedWorkspacesData.value?.workspaces?.find(
1585
+ (x) => x.id === wsId,
1586
+ );
1587
+ if (w) {
1588
+ w.availability = "leased";
1589
+ w.lease = {
1590
+ owner: sharedOwner || "telegram-ui",
1591
+ lease_expires_at: new Date(
1592
+ Date.now() + (Number(sharedTtl) || 60) * 60000,
1593
+ ).toISOString(),
1594
+ note: sharedNote,
1595
+ };
1596
+ }
1597
+ },
1598
+ () =>
1599
+ apiFetch("/api/shared-workspaces/claim", {
1600
+ method: "POST",
1601
+ body: JSON.stringify({
1602
+ workspaceId: wsId,
1603
+ owner: sharedOwner,
1604
+ ttlMinutes: Number(sharedTtl) || undefined,
1605
+ note: sharedNote,
1606
+ }),
1607
+ }),
1608
+ () => {
1609
+ sharedWorkspacesData.value = prev;
1610
+ },
1611
+ ).catch(() => {});
1612
+ scheduleRefresh(120);
1613
+ };
1614
+
1615
+ const handleRenew = async (wsId) => {
1616
+ haptic("medium");
1617
+ const prev = cloneValue(sharedWorkspacesData.value);
1618
+ await runOptimistic(
1619
+ () => {
1620
+ const w = sharedWorkspacesData.value?.workspaces?.find(
1621
+ (x) => x.id === wsId,
1622
+ );
1623
+ if (w?.lease) {
1624
+ w.lease.owner = sharedOwner || w.lease.owner;
1625
+ w.lease.lease_expires_at = new Date(
1626
+ Date.now() + (Number(sharedTtl) || 60) * 60000,
1627
+ ).toISOString();
1628
+ }
1629
+ },
1630
+ () =>
1631
+ apiFetch("/api/shared-workspaces/renew", {
1632
+ method: "POST",
1633
+ body: JSON.stringify({
1634
+ workspaceId: wsId,
1635
+ owner: sharedOwner,
1636
+ ttlMinutes: Number(sharedTtl) || undefined,
1637
+ }),
1638
+ }),
1639
+ () => {
1640
+ sharedWorkspacesData.value = prev;
1641
+ },
1642
+ ).catch(() => {});
1643
+ scheduleRefresh(120);
1644
+ };
1645
+
1646
+ const handleSharedRelease = async (wsId) => {
1647
+ haptic("medium");
1648
+ const prev = cloneValue(sharedWorkspacesData.value);
1649
+ await runOptimistic(
1650
+ () => {
1651
+ const w = sharedWorkspacesData.value?.workspaces?.find(
1652
+ (x) => x.id === wsId,
1653
+ );
1654
+ if (w) {
1655
+ w.availability = "available";
1656
+ w.lease = null;
1657
+ }
1658
+ },
1659
+ () =>
1660
+ apiFetch("/api/shared-workspaces/release", {
1661
+ method: "POST",
1662
+ body: JSON.stringify({ workspaceId: wsId, owner: sharedOwner }),
1663
+ }),
1664
+ () => {
1665
+ sharedWorkspacesData.value = prev;
1666
+ },
1667
+ ).catch(() => {});
1668
+ scheduleRefresh(120);
1669
+ };
1670
+
1671
+ return html`
1672
+ <${Collapsible} title="Worktrees" defaultOpen=${true}>
1673
+ <${Card}>
1674
+ <div class="stats-grid mb-md">
1675
+ <${StatCard} value=${wStats.total ?? wts.length} label="Total" />
1676
+ <${StatCard}
1677
+ value=${wStats.active ?? 0}
1678
+ label="Active"
1679
+ color="var(--color-done)"
1680
+ />
1681
+ <${StatCard}
1682
+ value=${wStats.stale ?? 0}
1683
+ label="Stale"
1684
+ color="var(--color-inreview)"
1685
+ />
1686
+ </div>
1687
+ <div class="input-row mb-md">
1688
+ <input
1689
+ class="input"
1690
+ placeholder="Task key or branch"
1691
+ value=${releaseInput}
1692
+ onInput=${(e) => setReleaseInput(e.target.value)}
1693
+ />
1694
+ <button
1695
+ class="btn btn-secondary btn-sm"
1696
+ onClick=${handleReleaseInput}
1697
+ >
1698
+ Release
1699
+ </button>
1700
+ <button class="btn btn-danger btn-sm" onClick=${handlePrune}>
1701
+ Prune
1702
+ </button>
1703
+ </div>
1704
+ ${wts.map((wt) => {
1705
+ const ageMin = Math.round((wt.age || 0) / 60000);
1706
+ const ageStr =
1707
+ ageMin >= 60 ? `${Math.round(ageMin / 60)}h` : `${ageMin}m`;
1708
+ return html`
1709
+ <div key=${wt.branch || wt.path} class="task-card">
1710
+ <div class="task-card-header">
1711
+ <div>
1712
+ <div class="task-card-title">
1713
+ ${wt.branch || "(detached)"}
1714
+ </div>
1715
+ <div class="task-card-meta">${wt.path}</div>
1716
+ </div>
1717
+ <${Badge}
1718
+ status=${wt.status || "active"}
1719
+ text=${wt.status || "active"}
1720
+ />
1721
+ </div>
1722
+ <div class="meta-text">
1723
+ Age
1724
+ ${ageStr}${wt.taskKey ? ` · ${wt.taskKey}` : ""}${wt.owner
1725
+ ? ` · Owner ${wt.owner}`
1726
+ : ""}
1727
+ </div>
1728
+ <div class="btn-row mt-sm">
1729
+ ${wt.taskKey &&
1730
+ html`<button
1731
+ class="btn btn-ghost btn-sm"
1732
+ onClick=${() => handleRelease(wt.taskKey, "")}
1733
+ >
1734
+ Release Key
1735
+ </button>`}
1736
+ ${wt.branch &&
1737
+ html`<button
1738
+ class="btn btn-ghost btn-sm"
1739
+ onClick=${() => handleRelease("", wt.branch)}
1740
+ >
1741
+ Release Branch
1742
+ </button>`}
1743
+ </div>
1744
+ </div>
1745
+ `;
1746
+ })}
1747
+ ${!wts.length &&
1748
+ html`<div class="meta-text">No worktrees tracked.</div>`}
1749
+ <//>
1750
+ <//>
1751
+
1752
+ <${Collapsible} title="Shared Workspaces" defaultOpen=${true}>
1753
+ <${Card}>
1754
+ <div class="chip-group mb-sm">
1755
+ ${Object.entries(availability).map(
1756
+ ([k, v]) => html`<span key=${k} class="pill">${k}: ${v}</span>`,
1757
+ )}
1758
+ ${!Object.keys(availability).length &&
1759
+ html`<span class="pill">No registry</span>`}
1760
+ </div>
1761
+ <div class="input-row mb-sm">
1762
+ <input
1763
+ class="input"
1764
+ placeholder="Owner"
1765
+ value=${sharedOwner}
1766
+ onInput=${(e) => setSharedOwner(e.target.value)}
1767
+ />
1768
+ <input
1769
+ class="input"
1770
+ type="number"
1771
+ min="30"
1772
+ step="15"
1773
+ placeholder="TTL (min)"
1774
+ value=${sharedTtl}
1775
+ onInput=${(e) => setSharedTtl(e.target.value)}
1776
+ />
1777
+ </div>
1778
+ <input
1779
+ class="input mb-md"
1780
+ placeholder="Note (optional)"
1781
+ value=${sharedNote}
1782
+ onInput=${(e) => setSharedNote(e.target.value)}
1783
+ />
1784
+ ${workspaces.map((ws) => {
1785
+ const lease = ws.lease;
1786
+ const leaseInfo = lease
1787
+ ? `Leased to ${lease.owner} until ${new Date(lease.lease_expires_at).toLocaleString()}`
1788
+ : "Available";
1789
+ return html`
1790
+ <div key=${ws.id} class="task-card">
1791
+ <div class="task-card-header">
1792
+ <div>
1793
+ <div class="task-card-title">${ws.name || ws.id}</div>
1794
+ <div class="task-card-meta">
1795
+ ${ws.provider || "provider"} · ${ws.region || "region?"}
1796
+ </div>
1797
+ </div>
1798
+ <${Badge} status=${ws.availability} text=${ws.availability} />
1799
+ </div>
1800
+ <div class="meta-text">${leaseInfo}</div>
1801
+ <div class="btn-row mt-sm">
1802
+ <button
1803
+ class="btn btn-primary btn-sm"
1804
+ onClick=${() => handleClaim(ws.id)}
1805
+ >
1806
+ Claim
1807
+ </button>
1808
+ <button
1809
+ class="btn btn-secondary btn-sm"
1810
+ onClick=${() => handleRenew(ws.id)}
1811
+ >
1812
+ Renew
1813
+ </button>
1814
+ <button
1815
+ class="btn btn-ghost btn-sm"
1816
+ onClick=${() => handleSharedRelease(ws.id)}
1817
+ >
1818
+ Release
1819
+ </button>
1820
+ </div>
1821
+ </div>
1822
+ `;
1823
+ })}
1824
+ ${!workspaces.length &&
1825
+ html`<div class="meta-text">No shared workspaces configured.</div>`}
1826
+ <//>
1827
+ <//>
1828
+
1829
+ <${Collapsible} title="Presence" defaultOpen=${true}>
1830
+ <${Card}>
1831
+ <div class="task-card mb-md">
1832
+ <div class="task-card-title">Coordinator</div>
1833
+ <div class="meta-text">
1834
+ ${coordinator?.instance_label || coordinator?.instance_id || "none"}
1835
+ · Priority ${coordinator?.coordinator_priority ?? "—"}
1836
+ </div>
1837
+ </div>
1838
+ ${instances.length
1839
+ ? html`
1840
+ <div class="stats-grid">
1841
+ ${instances.map(
1842
+ (inst, i) => html`
1843
+ <div
1844
+ key=${i}
1845
+ class="stat-card"
1846
+ style="text-align:left;padding:10px"
1847
+ >
1848
+ <div style="font-weight:600;font-size:13px">
1849
+ ${inst.instance_label || inst.instance_id}
1850
+ </div>
1851
+ <div class="meta-text">
1852
+ ${inst.workspace_role || "workspace"} ·
1853
+ ${inst.host || "host"}
1854
+ </div>
1855
+ <div class="meta-text">
1856
+ Last:
1857
+ ${inst.last_seen_at
1858
+ ? new Date(inst.last_seen_at).toLocaleString()
1859
+ : "unknown"}
1860
+ </div>
1861
+ </div>
1862
+ `,
1863
+ )}
1864
+ </div>
1865
+ `
1866
+ : html`<div class="meta-text">No active instances.</div>`}
1867
+ <//>
1868
+ <//>
1869
+ `;
1870
+ }
1871
+
1872
+ /* ═══════════════════════════════════════════════
1873
+ * TAB: Control (Executor + Commands + Routing)
1874
+ * ═══════════════════════════════════════════════ */
1875
+ function ControlTab() {
1876
+ const executor = executorData.value;
1877
+ const execData = executor?.data;
1878
+ const mode = executor?.mode || "vk";
1879
+
1880
+ const [commandInput, setCommandInput] = useState("");
1881
+ const [startTaskInput, setStartTaskInput] = useState("");
1882
+ const [retryInput, setRetryInput] = useState("");
1883
+ const [askInput, setAskInput] = useState("");
1884
+ const [steerInput, setSteerInput] = useState("");
1885
+ const [shellInput, setShellInput] = useState("");
1886
+ const [gitInput, setGitInput] = useState("");
1887
+ const [maxParallel, setMaxParallel] = useState(execData?.maxParallel ?? 0);
1888
+
1889
+ const handlePause = async () => {
1890
+ haptic("medium");
1891
+ const prev = cloneValue(executor);
1892
+ await runOptimistic(
1893
+ () => {
1894
+ if (executorData.value)
1895
+ executorData.value = { ...executorData.value, paused: true };
1896
+ },
1897
+ () => apiFetch("/api/executor/pause", { method: "POST" }),
1898
+ () => {
1899
+ executorData.value = prev;
1900
+ },
1901
+ ).catch(() => {});
1902
+ scheduleRefresh(120);
1903
+ };
1904
+
1905
+ const handleResume = async () => {
1906
+ haptic("medium");
1907
+ const prev = cloneValue(executor);
1908
+ await runOptimistic(
1909
+ () => {
1910
+ if (executorData.value)
1911
+ executorData.value = { ...executorData.value, paused: false };
1912
+ },
1913
+ () => apiFetch("/api/executor/resume", { method: "POST" }),
1914
+ () => {
1915
+ executorData.value = prev;
1916
+ },
1917
+ ).catch(() => {});
1918
+ scheduleRefresh(120);
1919
+ };
1920
+
1921
+ const handleMaxParallel = async (value) => {
1922
+ setMaxParallel(value);
1923
+ haptic();
1924
+ const prev = cloneValue(executor);
1925
+ await runOptimistic(
1926
+ () => {
1927
+ if (executorData.value?.data)
1928
+ executorData.value.data.maxParallel = value;
1929
+ },
1930
+ () =>
1931
+ apiFetch("/api/executor/maxparallel", {
1932
+ method: "POST",
1933
+ body: JSON.stringify({ value }),
1934
+ }),
1935
+ () => {
1936
+ executorData.value = prev;
1937
+ },
1938
+ ).catch(() => {});
1939
+ scheduleRefresh(120);
1940
+ };
1941
+
1942
+ return html`
1943
+ <${Card} title="Executor Controls">
1944
+ <div class="meta-text mb-sm">
1945
+ Mode: ${mode} · Slots:
1946
+ ${execData?.activeSlots ?? 0}/${execData?.maxParallel ?? "—"} · Paused:
1947
+ ${executor?.paused ? "Yes" : "No"}
1948
+ </div>
1949
+ <div class="meta-text mb-sm">
1950
+ Poll:
1951
+ ${execData?.pollIntervalMs ? execData.pollIntervalMs / 1000 : "—"}s ·
1952
+ Timeout:
1953
+ ${execData?.taskTimeoutMs
1954
+ ? Math.round(execData.taskTimeoutMs / 60000)
1955
+ : "—"}m
1956
+ </div>
1957
+ <div class="range-row mb-md">
1958
+ <input
1959
+ type="range"
1960
+ min="0"
1961
+ max="20"
1962
+ step="1"
1963
+ value=${maxParallel}
1964
+ onInput=${(e) => setMaxParallel(Number(e.target.value))}
1965
+ onChange=${(e) => handleMaxParallel(Number(e.target.value))}
1966
+ />
1967
+ <span class="pill">Max ${maxParallel}</span>
1968
+ </div>
1969
+ <div class="btn-row">
1970
+ <button class="btn btn-primary btn-sm" onClick=${handlePause}>
1971
+ Pause
1972
+ </button>
1973
+ <button class="btn btn-secondary btn-sm" onClick=${handleResume}>
1974
+ Resume
1975
+ </button>
1976
+ <button
1977
+ class="btn btn-ghost btn-sm"
1978
+ onClick=${() => sendCommandToChat("/executor")}
1979
+ >
1980
+ /executor
1981
+ </button>
1982
+ </div>
1983
+ <//>
1984
+
1985
+ <${Card} title="Command Console">
1986
+ <div class="input-row mb-sm">
1987
+ <input
1988
+ class="input"
1989
+ placeholder="/status"
1990
+ value=${commandInput}
1991
+ onInput=${(e) => setCommandInput(e.target.value)}
1992
+ onKeyDown=${(e) => {
1993
+ if (e.key === "Enter" && commandInput.trim()) {
1994
+ sendCommandToChat(commandInput.trim());
1995
+ setCommandInput("");
1996
+ }
1997
+ }}
1998
+ />
1999
+ <button
2000
+ class="btn btn-primary btn-sm"
2001
+ onClick=${() => {
2002
+ if (commandInput.trim()) {
2003
+ sendCommandToChat(commandInput.trim());
2004
+ setCommandInput("");
2005
+ }
2006
+ }}
2007
+ >
2008
+ ${ICONS.send}
2009
+ </button>
2010
+ </div>
2011
+ <div class="btn-row">
2012
+ <button
2013
+ class="btn btn-ghost btn-sm"
2014
+ onClick=${() => sendCommandToChat("/status")}
2015
+ >
2016
+ /status
2017
+ </button>
2018
+ <button
2019
+ class="btn btn-ghost btn-sm"
2020
+ onClick=${() => sendCommandToChat("/health")}
2021
+ >
2022
+ /health
2023
+ </button>
2024
+ <button
2025
+ class="btn btn-ghost btn-sm"
2026
+ onClick=${() => sendCommandToChat("/menu")}
2027
+ >
2028
+ /menu
2029
+ </button>
2030
+ <button
2031
+ class="btn btn-ghost btn-sm"
2032
+ onClick=${() => sendCommandToChat("/helpfull")}
2033
+ >
2034
+ /helpfull
2035
+ </button>
2036
+ </div>
2037
+ <//>
2038
+
2039
+ <${Card} title="Task Ops">
2040
+ <div class="input-row mb-sm">
2041
+ <input
2042
+ class="input"
2043
+ placeholder="Task ID"
2044
+ value=${startTaskInput}
2045
+ onInput=${(e) => setStartTaskInput(e.target.value)}
2046
+ />
2047
+ <button
2048
+ class="btn btn-secondary btn-sm"
2049
+ onClick=${() => {
2050
+ if (startTaskInput.trim())
2051
+ sendCommandToChat(`/starttask ${startTaskInput.trim()}`);
2052
+ }}
2053
+ >
2054
+ Start
2055
+ </button>
2056
+ </div>
2057
+ <div class="input-row">
2058
+ <input
2059
+ class="input"
2060
+ placeholder="Retry reason"
2061
+ value=${retryInput}
2062
+ onInput=${(e) => setRetryInput(e.target.value)}
2063
+ />
2064
+ <button
2065
+ class="btn btn-secondary btn-sm"
2066
+ onClick=${() =>
2067
+ sendCommandToChat(
2068
+ retryInput.trim() ? `/retry ${retryInput.trim()}` : "/retry",
2069
+ )}
2070
+ >
2071
+ Retry
2072
+ </button>
2073
+ <button
2074
+ class="btn btn-ghost btn-sm"
2075
+ onClick=${() => sendCommandToChat("/plan")}
2076
+ >
2077
+ Plan
2078
+ </button>
2079
+ </div>
2080
+ <//>
2081
+
2082
+ <${Card} title="Agent Control">
2083
+ <textarea
2084
+ class="input mb-sm"
2085
+ rows="2"
2086
+ placeholder="Ask the agent..."
2087
+ value=${askInput}
2088
+ onInput=${(e) => setAskInput(e.target.value)}
2089
+ ></textarea>
2090
+ <div class="btn-row mb-md">
2091
+ <button
2092
+ class="btn btn-primary btn-sm"
2093
+ onClick=${() => {
2094
+ if (askInput.trim()) {
2095
+ sendCommandToChat(`/ask ${askInput.trim()}`);
2096
+ setAskInput("");
2097
+ }
2098
+ }}
2099
+ >
2100
+ Ask
2101
+ </button>
2102
+ </div>
2103
+ <div class="input-row">
2104
+ <input
2105
+ class="input"
2106
+ placeholder="Steer prompt (focus on...)"
2107
+ value=${steerInput}
2108
+ onInput=${(e) => setSteerInput(e.target.value)}
2109
+ />
2110
+ <button
2111
+ class="btn btn-secondary btn-sm"
2112
+ onClick=${() => {
2113
+ if (steerInput.trim()) {
2114
+ sendCommandToChat(`/steer ${steerInput.trim()}`);
2115
+ setSteerInput("");
2116
+ }
2117
+ }}
2118
+ >
2119
+ Steer
2120
+ </button>
2121
+ </div>
2122
+ <//>
2123
+
2124
+ <${Card} title="Routing">
2125
+ <div class="card-subtitle">SDK</div>
2126
+ <${SegmentedControl}
2127
+ options=${[
2128
+ { value: "codex", label: "Codex" },
2129
+ { value: "copilot", label: "Copilot" },
2130
+ { value: "claude", label: "Claude" },
2131
+ { value: "auto", label: "Auto" },
2132
+ ]}
2133
+ value=""
2134
+ onChange=${(v) => sendCommandToChat(`/sdk ${v}`)}
2135
+ />
2136
+ <div class="card-subtitle mt-sm">Kanban</div>
2137
+ <${SegmentedControl}
2138
+ options=${[
2139
+ { value: "vk", label: "VK" },
2140
+ { value: "github", label: "GitHub" },
2141
+ { value: "jira", label: "Jira" },
2142
+ ]}
2143
+ value=""
2144
+ onChange=${(v) => sendCommandToChat(`/kanban ${v}`)}
2145
+ />
2146
+ <div class="card-subtitle mt-sm">Region</div>
2147
+ <${SegmentedControl}
2148
+ options=${[
2149
+ { value: "us", label: "US" },
2150
+ { value: "sweden", label: "Sweden" },
2151
+ { value: "auto", label: "Auto" },
2152
+ ]}
2153
+ value=""
2154
+ onChange=${(v) => sendCommandToChat(`/region ${v}`)}
2155
+ />
2156
+ <//>
2157
+
2158
+ <${Card} title="Shell / Git">
2159
+ <div class="input-row mb-sm">
2160
+ <input
2161
+ class="input"
2162
+ placeholder="ls -la"
2163
+ value=${shellInput}
2164
+ onInput=${(e) => setShellInput(e.target.value)}
2165
+ />
2166
+ <button
2167
+ class="btn btn-secondary btn-sm"
2168
+ onClick=${() =>
2169
+ sendCommandToChat(`/shell ${shellInput.trim()}`.trim())}
2170
+ >
2171
+ Shell
2172
+ </button>
2173
+ </div>
2174
+ <div class="input-row">
2175
+ <input
2176
+ class="input"
2177
+ placeholder="status --short"
2178
+ value=${gitInput}
2179
+ onInput=${(e) => setGitInput(e.target.value)}
2180
+ />
2181
+ <button
2182
+ class="btn btn-secondary btn-sm"
2183
+ onClick=${() => sendCommandToChat(`/git ${gitInput.trim()}`.trim())}
2184
+ >
2185
+ Git
2186
+ </button>
2187
+ </div>
2188
+ <//>
2189
+ `;
2190
+ }
2191
+
2192
+ /* ═══════════════════════════════════════════════
2193
+ * TAB: Logs (System Logs + Agent Log Library)
2194
+ * ═══════════════════════════════════════════════ */
2195
+ function LogsTab() {
2196
+ const logRef = useRef(null);
2197
+ const [localLogLines, setLocalLogLines] = useState(logsLines.value);
2198
+ const [localAgentLines, setLocalAgentLines] = useState(agentLogLines.value);
2199
+ const [contextQuery, setContextQuery] = useState("");
2200
+
2201
+ const logText = logsData.value?.lines
2202
+ ? logsData.value.lines.join("\n")
2203
+ : "No logs yet.";
2204
+ const tailText = agentLogTail.value?.lines
2205
+ ? agentLogTail.value.lines.join("\n")
2206
+ : "Select a log file.";
2207
+
2208
+ useEffect(() => {
2209
+ if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
2210
+ }, [logText]);
2211
+
2212
+ const handleLogLinesChange = async (value) => {
2213
+ setLocalLogLines(value);
2214
+ logsLines.value = value;
2215
+ await loadLogs();
2216
+ };
2217
+
2218
+ const handleAgentSearch = async () => {
2219
+ agentLogFile.value = "";
2220
+ await loadAgentLogFileList();
2221
+ await loadAgentLogTailData();
2222
+ };
2223
+
2224
+ const handleAgentOpen = async (name) => {
2225
+ agentLogFile.value = name;
2226
+ await loadAgentLogTailData();
2227
+ };
2228
+
2229
+ const handleAgentLinesChange = async (value) => {
2230
+ setLocalAgentLines(value);
2231
+ agentLogLines.value = value;
2232
+ await loadAgentLogTailData();
2233
+ };
2234
+
2235
+ const handleContextLoad = async () => {
2236
+ await loadAgentContextData(contextQuery.trim());
2237
+ };
2238
+
2239
+ return html`
2240
+ <${Card} title="System Logs">
2241
+ <div class="range-row mb-sm">
2242
+ <input
2243
+ type="range"
2244
+ min="20"
2245
+ max="800"
2246
+ step="20"
2247
+ value=${localLogLines}
2248
+ onInput=${(e) => setLocalLogLines(Number(e.target.value))}
2249
+ onChange=${(e) => handleLogLinesChange(Number(e.target.value))}
2250
+ />
2251
+ <span class="pill">${localLogLines} lines</span>
2252
+ </div>
2253
+ <div class="chip-group mb-sm">
2254
+ ${[50, 200, 500].map(
2255
+ (n) => html`
2256
+ <button
2257
+ key=${n}
2258
+ class="chip ${logsLines.value === n ? "active" : ""}"
2259
+ onClick=${() => handleLogLinesChange(n)}
2260
+ >
2261
+ ${n}
2262
+ </button>
2263
+ `,
2264
+ )}
2265
+ </div>
2266
+ <div ref=${logRef} class="log-box">${logText}</div>
2267
+ <div class="btn-row mt-sm">
2268
+ <button
2269
+ class="btn btn-ghost btn-sm"
2270
+ onClick=${() => sendCommandToChat(`/logs ${logsLines.value}`)}
2271
+ >
2272
+ /logs to chat
2273
+ </button>
2274
+ </div>
2275
+ <//>
2276
+
2277
+ <${Card} title="Agent Log Library">
2278
+ <div class="input-row mb-sm">
2279
+ <input
2280
+ class="input"
2281
+ placeholder="Search log files"
2282
+ value=${agentLogQuery.value}
2283
+ onInput=${(e) => {
2284
+ agentLogQuery.value = e.target.value;
2285
+ }}
2286
+ />
2287
+ <button class="btn btn-secondary btn-sm" onClick=${handleAgentSearch}>
2288
+ Search
2289
+ </button>
2290
+ </div>
2291
+ <div class="range-row mb-md">
2292
+ <input
2293
+ type="range"
2294
+ min="50"
2295
+ max="800"
2296
+ step="50"
2297
+ value=${localAgentLines}
2298
+ onInput=${(e) => setLocalAgentLines(Number(e.target.value))}
2299
+ onChange=${(e) => handleAgentLinesChange(Number(e.target.value))}
2300
+ />
2301
+ <span class="pill">${localAgentLines} lines</span>
2302
+ </div>
2303
+ <//>
2304
+
2305
+ <${Card} title="Log Files">
2306
+ ${agentLogFiles.value.length
2307
+ ? agentLogFiles.value.map(
2308
+ (file) => html`
2309
+ <div
2310
+ key=${file.name}
2311
+ class="task-card"
2312
+ onClick=${() => handleAgentOpen(file.name)}
2313
+ >
2314
+ <div class="task-card-header">
2315
+ <div>
2316
+ <div class="task-card-title">${file.name}</div>
2317
+ <div class="task-card-meta">
2318
+ ${Math.round(file.size / 1024)}kb ·
2319
+ ${new Date(file.mtime).toLocaleString()}
2320
+ </div>
2321
+ </div>
2322
+ <${Badge} status="log" text="log" />
2323
+ </div>
2324
+ </div>
2325
+ `,
2326
+ )
2327
+ : html`<div class="meta-text">No log files found.</div>`}
2328
+ <//>
2329
+
2330
+ <${Card} title=${agentLogFile.value || "Log Tail"}>
2331
+ ${agentLogTail.value?.truncated &&
2332
+ html`<span class="pill mb-sm">Tail clipped</span>`}
2333
+ <div class="log-box">${tailText}</div>
2334
+ <//>
2335
+
2336
+ <${Card} title="Worktree Context">
2337
+ <div class="input-row mb-sm">
2338
+ <input
2339
+ class="input"
2340
+ placeholder="Branch fragment"
2341
+ value=${contextQuery}
2342
+ onInput=${(e) => setContextQuery(e.target.value)}
2343
+ />
2344
+ <button class="btn btn-secondary btn-sm" onClick=${handleContextLoad}>
2345
+ Load
2346
+ </button>
2347
+ </div>
2348
+ <div class="log-box">
2349
+ ${agentContext.value
2350
+ ? [
2351
+ `Worktree: ${agentContext.value.name || "?"}`,
2352
+ "",
2353
+ agentContext.value.gitLog || "No git log.",
2354
+ "",
2355
+ agentContext.value.gitStatus || "Clean worktree.",
2356
+ "",
2357
+ agentContext.value.diffStat || "No diff stat.",
2358
+ ].join("\n")
2359
+ : "Load a worktree context to view git log/status."}
2360
+ </div>
2361
+ <//>
2362
+
2363
+ <${Card} title="Git Snapshot">
2364
+ <div class="btn-row mb-sm">
2365
+ <button
2366
+ class="btn btn-secondary btn-sm"
2367
+ onClick=${async () => {
2368
+ await loadGit();
2369
+ haptic();
2370
+ }}
2371
+ >
2372
+ ${ICONS.refresh} Refresh
2373
+ </button>
2374
+ <button
2375
+ class="btn btn-ghost btn-sm"
2376
+ onClick=${() => sendCommandToChat("/diff")}
2377
+ >
2378
+ /diff
2379
+ </button>
2380
+ </div>
2381
+ <div class="log-box mb-md">${gitDiff.value || "Clean working tree."}</div>
2382
+ <div class="card-subtitle">Recent Branches</div>
2383
+ ${gitBranches.value.length
2384
+ ? gitBranches.value.map(
2385
+ (line, i) => html`<div key=${i} class="meta-text">${line}</div>`,
2386
+ )
2387
+ : html`<div class="meta-text">No branches found.</div>`}
2388
+ <//>
2389
+ `;
2390
+ }
2391
+
2392
+ /* ═══════════════════════════════════════════════
2393
+ * Header + BottomNav + App Root
2394
+ * ═══════════════════════════════════════════════ */
2395
+ function Header() {
2396
+ const isConn = connected.value;
2397
+ return html`
2398
+ <header class="app-header">
2399
+ <div class="app-header-title">VirtEngine</div>
2400
+ <div class="connection-pill ${isConn ? "connected" : "disconnected"}">
2401
+ <span class="connection-dot"></span>
2402
+ ${isConn ? "Live" : "Offline"}
2403
+ </div>
2404
+ </header>
2405
+ `;
2406
+ }
2407
+
2408
+ function BottomNav() {
2409
+ const tabs = [
2410
+ { id: "dashboard", label: "Home", icon: ICONS.grid },
2411
+ { id: "tasks", label: "Tasks", icon: ICONS.check },
2412
+ { id: "agents", label: "Agents", icon: ICONS.cpu },
2413
+ { id: "infra", label: "Infra", icon: ICONS.server },
2414
+ { id: "control", label: "Control", icon: ICONS.sliders },
2415
+ { id: "logs", label: "Logs", icon: ICONS.terminal },
2416
+ ];
2417
+
2418
+ const handleSwitch = async (tab) => {
2419
+ if (activeTab.value === tab) return;
2420
+ haptic();
2421
+ activeTab.value = tab;
2422
+ switchWsChannel(tab);
2423
+ // Hide Telegram BackButton on main tabs
2424
+ const tg = getTg();
2425
+ if (tg?.BackButton) tg.BackButton.hide();
2426
+ await refreshTab();
2427
+ };
2428
+
2429
+ return html`
2430
+ <nav class="bottom-nav">
2431
+ ${tabs.map(
2432
+ (t) => html`
2433
+ <button
2434
+ key=${t.id}
2435
+ class="nav-item ${activeTab.value === t.id ? "active" : ""}"
2436
+ onClick=${() => handleSwitch(t.id)}
2437
+ >
2438
+ ${t.icon}
2439
+ <span class="nav-label">${t.label}</span>
2440
+ </button>
2441
+ `,
2442
+ )}
2443
+ </nav>
2444
+ `;
2445
+ }
2446
+
2447
+ function App() {
2448
+ useEffect(() => {
2449
+ const tg = getTg();
2450
+ applyTgTheme();
2451
+ if (tg) {
2452
+ tg.expand();
2453
+ tg.ready();
2454
+ connected.value = true;
2455
+ }
2456
+ refreshTab();
2457
+ connectRealtime();
2458
+
2459
+ return () => {
2460
+ try {
2461
+ ws?.close();
2462
+ } catch {
2463
+ /* noop */
2464
+ }
2465
+ if (wsReconnectTimer) clearTimeout(wsReconnectTimer);
2466
+ if (wsRefreshTimer) clearTimeout(wsRefreshTimer);
2467
+ };
2468
+ }, []);
2469
+
2470
+ const tab = activeTab.value;
2471
+
2472
+ return html`
2473
+ <${ToastContainer} />
2474
+ <${Header} />
2475
+ <${PullToRefresh} onRefresh=${refreshTab}>
2476
+ ${tab === "dashboard" && html`<${DashboardTab} />`}
2477
+ ${tab === "tasks" && html`<${TasksTab} />`}
2478
+ ${tab === "agents" && html`<${AgentsTab} />`}
2479
+ ${tab === "infra" && html`<${InfraTab} />`}
2480
+ ${tab === "control" && html`<${ControlTab} />`}
2481
+ ${tab === "logs" && html`<${LogsTab} />`}
2482
+ <//>
2483
+ <${BottomNav} />
2484
+ `;
2485
+ }
2486
+
2487
+ /* ─── Mount ─── */
2488
+ preactRender(html`<${App} />`, document.getElementById("app"));