@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.
- package/.env.example +914 -0
- package/LICENSE +190 -0
- package/README.md +500 -0
- package/agent-endpoint.mjs +918 -0
- package/agent-hook-bridge.mjs +230 -0
- package/agent-hooks.mjs +1188 -0
- package/agent-pool.mjs +2403 -0
- package/agent-prompts.mjs +689 -0
- package/agent-sdk.mjs +141 -0
- package/anomaly-detector.mjs +1195 -0
- package/autofix.mjs +1294 -0
- package/claude-shell.mjs +708 -0
- package/cli.mjs +906 -0
- package/codex-config.mjs +1274 -0
- package/codex-model-profiles.mjs +135 -0
- package/codex-shell.mjs +762 -0
- package/config-doctor.mjs +613 -0
- package/config.mjs +1720 -0
- package/conflict-resolver.mjs +248 -0
- package/container-runner.mjs +450 -0
- package/copilot-shell.mjs +827 -0
- package/daemon-restart-policy.mjs +56 -0
- package/diff-stats.mjs +282 -0
- package/error-detector.mjs +829 -0
- package/fetch-runtime.mjs +34 -0
- package/fleet-coordinator.mjs +838 -0
- package/get-telegram-chat-id.mjs +71 -0
- package/git-safety.mjs +170 -0
- package/github-reconciler.mjs +403 -0
- package/hook-profiles.mjs +651 -0
- package/kanban-adapter.mjs +4491 -0
- package/lib/logger.mjs +645 -0
- package/maintenance.mjs +828 -0
- package/merge-strategy.mjs +1171 -0
- package/monitor.mjs +12207 -0
- package/openfleet.config.example.json +115 -0
- package/openfleet.schema.json +465 -0
- package/package.json +203 -0
- package/postinstall.mjs +187 -0
- package/pr-cleanup-daemon.mjs +978 -0
- package/preflight.mjs +408 -0
- package/prepublish-check.mjs +90 -0
- package/presence.mjs +328 -0
- package/primary-agent.mjs +282 -0
- package/publish.mjs +151 -0
- package/repo-root.mjs +29 -0
- package/restart-controller.mjs +100 -0
- package/review-agent.mjs +557 -0
- package/rotate-agent-logs.sh +133 -0
- package/sdk-conflict-resolver.mjs +973 -0
- package/session-tracker.mjs +880 -0
- package/setup.mjs +3937 -0
- package/shared-knowledge.mjs +410 -0
- package/shared-state-manager.mjs +841 -0
- package/shared-workspace-cli.mjs +199 -0
- package/shared-workspace-registry.mjs +537 -0
- package/shared-workspaces.json +18 -0
- package/startup-service.mjs +1070 -0
- package/sync-engine.mjs +1063 -0
- package/task-archiver.mjs +801 -0
- package/task-assessment.mjs +550 -0
- package/task-claims.mjs +924 -0
- package/task-complexity.mjs +581 -0
- package/task-executor.mjs +5111 -0
- package/task-store.mjs +753 -0
- package/telegram-bot.mjs +9281 -0
- package/telegram-sentinel.mjs +2010 -0
- package/ui/app.js +867 -0
- package/ui/app.legacy.js +1464 -0
- package/ui/app.monolith.js +2488 -0
- package/ui/components/charts.js +226 -0
- package/ui/components/chat-view.js +567 -0
- package/ui/components/command-palette.js +587 -0
- package/ui/components/diff-viewer.js +190 -0
- package/ui/components/forms.js +327 -0
- package/ui/components/kanban-board.js +451 -0
- package/ui/components/session-list.js +305 -0
- package/ui/components/shared.js +473 -0
- package/ui/index.html +70 -0
- package/ui/modules/api.js +297 -0
- package/ui/modules/icons.js +461 -0
- package/ui/modules/router.js +81 -0
- package/ui/modules/settings-schema.js +261 -0
- package/ui/modules/state.js +679 -0
- package/ui/modules/telegram.js +331 -0
- package/ui/modules/utils.js +270 -0
- package/ui/styles/animations.css +140 -0
- package/ui/styles/base.css +98 -0
- package/ui/styles/components.css +1915 -0
- package/ui/styles/kanban.css +286 -0
- package/ui/styles/layout.css +809 -0
- package/ui/styles/sessions.css +827 -0
- package/ui/styles/variables.css +188 -0
- package/ui/styles.css +141 -0
- package/ui/styles.monolith.css +1046 -0
- package/ui/tabs/agents.js +1417 -0
- package/ui/tabs/chat.js +74 -0
- package/ui/tabs/control.js +887 -0
- package/ui/tabs/dashboard.js +515 -0
- package/ui/tabs/infra.js +537 -0
- package/ui/tabs/logs.js +783 -0
- package/ui/tabs/settings.js +1487 -0
- package/ui/tabs/tasks.js +1385 -0
- package/ui-server.mjs +4073 -0
- package/update-check.mjs +465 -0
- package/utils.mjs +172 -0
- package/ve-kanban.mjs +654 -0
- package/ve-kanban.ps1 +1365 -0
- package/ve-kanban.sh +18 -0
- package/ve-orchestrator.mjs +340 -0
- package/ve-orchestrator.ps1 +6546 -0
- package/ve-orchestrator.sh +18 -0
- package/vibe-kanban-wrapper.mjs +41 -0
- package/vk-error-resolver.mjs +470 -0
- package/vk-log-stream.mjs +914 -0
- package/whatsapp-channel.mjs +520 -0
- package/workspace-monitor.mjs +581 -0
- package/workspace-reaper.mjs +405 -0
- package/workspace-registry.mjs +238 -0
- 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"));
|