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