bosun 0.31.3 → 0.31.5
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/error-detector.mjs +77 -10
- package/package.json +1 -1
- package/task-executor.mjs +52 -9
- package/ui/app.js +228 -20
- package/ui/modules/api.js +56 -19
- package/ui/styles/components.css +367 -0
- package/ui/tabs/dashboard.js +229 -24
- package/utils.mjs +107 -0
package/error-detector.mjs
CHANGED
|
@@ -132,6 +132,7 @@ const GIT_CONFLICT_PATTERNS = [
|
|
|
132
132
|
/rebase.*conflict/i,
|
|
133
133
|
/cannot.*merge|unable to merge/i,
|
|
134
134
|
/both modified/i,
|
|
135
|
+
/cannot rebase|rebase failed/i,
|
|
135
136
|
];
|
|
136
137
|
|
|
137
138
|
export const PUSH_FAILURE_PATTERNS = [
|
|
@@ -173,6 +174,20 @@ export const LINT_FAILURE_PATTERNS = [
|
|
|
173
174
|
/gofmt.*differ|goimports.*differ/i,
|
|
174
175
|
];
|
|
175
176
|
|
|
177
|
+
// ── OOM / SIGKILL patterns ──────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
export const OOM_KILL_PATTERNS = [
|
|
180
|
+
/SIGKILL/,
|
|
181
|
+
/killed.*out.?of.?memory|oom.?kill/i,
|
|
182
|
+
/out of memory: kill process/i,
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
export const OOM_PATTERNS = [
|
|
186
|
+
/heap out of memory|javascript heap/i,
|
|
187
|
+
/fatal error.*allocation failed|allocation failure/i,
|
|
188
|
+
/process out of memory/i,
|
|
189
|
+
];
|
|
190
|
+
|
|
176
191
|
/**
|
|
177
192
|
* Ordered list of pattern groups to check. Earlier entries win on ties.
|
|
178
193
|
* Each entry: [patternName, regexArray, baseConfidence]
|
|
@@ -187,6 +202,8 @@ const PATTERN_GROUPS = [
|
|
|
187
202
|
["request_error", REQUEST_ERROR_PATTERNS, 0.91], // Client errors (400/404/422) — needs prompt fix
|
|
188
203
|
["api_error", API_ERROR_PATTERNS, 0.9],
|
|
189
204
|
["session_expired", SESSION_EXPIRED_PATTERNS, 0.9],
|
|
205
|
+
["oom_kill", OOM_KILL_PATTERNS, 0.97], // SIGKILL / OS-level OOM kill — critical
|
|
206
|
+
["oom", OOM_PATTERNS, 0.95], // JavaScript heap OOM — critical
|
|
190
207
|
["codex_sandbox", CODEX_SANDBOX_PATTERNS, 0.88], // Codex sandbox failures
|
|
191
208
|
["push_failure", PUSH_FAILURE_PATTERNS, 0.85],
|
|
192
209
|
["test_failure", TEST_FAILURE_PATTERNS, 0.83],
|
|
@@ -195,6 +212,50 @@ const PATTERN_GROUPS = [
|
|
|
195
212
|
["git_conflict", GIT_CONFLICT_PATTERNS, 0.85],
|
|
196
213
|
];
|
|
197
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Severity level for each error pattern type.
|
|
217
|
+
* 'low' | 'medium' | 'high' | 'critical'
|
|
218
|
+
* @type {Record<string, 'low'|'medium'|'high'|'critical'>}
|
|
219
|
+
*/
|
|
220
|
+
export const PATTERN_SEVERITY = {
|
|
221
|
+
auth_error: "high",
|
|
222
|
+
content_policy: "high",
|
|
223
|
+
plan_stuck: "low",
|
|
224
|
+
rate_limit: "medium",
|
|
225
|
+
token_overflow: "medium",
|
|
226
|
+
model_error: "high",
|
|
227
|
+
request_error: "medium",
|
|
228
|
+
api_error: "medium",
|
|
229
|
+
session_expired: "medium",
|
|
230
|
+
oom_kill: "critical",
|
|
231
|
+
oom: "critical",
|
|
232
|
+
codex_sandbox: "high",
|
|
233
|
+
push_failure: "medium",
|
|
234
|
+
test_failure: "medium",
|
|
235
|
+
lint_failure: "low",
|
|
236
|
+
build_failure: "medium",
|
|
237
|
+
git_conflict: "medium",
|
|
238
|
+
permission_wait: "low",
|
|
239
|
+
empty_response: "low",
|
|
240
|
+
unknown: "low",
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// ── Remediation hints ──────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Human-readable remediation hints for each error type.
|
|
247
|
+
* Surfaced in UI and Telegram notifications to guide users.
|
|
248
|
+
*/
|
|
249
|
+
const REMEDIATION_HINTS = {
|
|
250
|
+
rate_limit: "Wait a few minutes before retrying. Consider reducing MAX_PARALLEL.",
|
|
251
|
+
oom: "Reduce MAX_PARALLEL or increase available memory. Use --max-old-space-size.",
|
|
252
|
+
oom_kill: "Process was killed by the OS. Reduce memory usage or increase system RAM.",
|
|
253
|
+
git_conflict: "Manual conflict resolution required. Run: git mergetool",
|
|
254
|
+
push_failure: "Rebase failed or push rejected. Run: git rebase --abort then try again.",
|
|
255
|
+
auth_error: "Authentication failed. Check your API tokens and credentials.",
|
|
256
|
+
api_error: "Network connectivity issue. Check your internet connection.",
|
|
257
|
+
};
|
|
258
|
+
|
|
198
259
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
199
260
|
|
|
200
261
|
/** Safely truncate a string for logging / details. */
|
|
@@ -224,12 +285,14 @@ const PATTERN_DESCRIPTIONS = {
|
|
|
224
285
|
test_failure: "Unit or integration test failure",
|
|
225
286
|
lint_failure: "Lint or code formatting failure",
|
|
226
287
|
push_failure: "Git push or pre-push hook failure",
|
|
227
|
-
git_conflict: "Git merge conflict detected",
|
|
288
|
+
git_conflict: "Git merge or rebase conflict detected",
|
|
228
289
|
auth_error: "API key invalid, expired, or missing — NOT retryable",
|
|
229
290
|
model_error: "Model not found, deprecated, or unavailable",
|
|
230
291
|
request_error: "Bad request (400/404/422) — invalid payload or endpoint",
|
|
231
292
|
content_policy: "Content policy / safety filter violation — NOT retryable",
|
|
232
293
|
codex_sandbox: "Codex CLI sandbox or permission error",
|
|
294
|
+
oom_kill: "Process killed by OS due to out-of-memory (SIGKILL)",
|
|
295
|
+
oom: "JavaScript heap out-of-memory error",
|
|
233
296
|
permission_wait: "Agent waiting for human input/permission",
|
|
234
297
|
empty_response: "Agent produced no meaningful output",
|
|
235
298
|
unknown: "Unclassified error",
|
|
@@ -271,7 +334,7 @@ export class ErrorDetector {
|
|
|
271
334
|
*
|
|
272
335
|
* @param {string} output Agent stdout / response text
|
|
273
336
|
* @param {string} [error] Agent stderr or error message
|
|
274
|
-
* @returns {{ pattern: string, confidence: number, details: string, rawMatch: string|null }}
|
|
337
|
+
* @returns {{ pattern: string, confidence: number, details: string, rawMatch: string|null, severity: 'low'|'medium'|'high'|'critical' }}
|
|
275
338
|
*/
|
|
276
339
|
classify(output, error) {
|
|
277
340
|
const combined = [output, error].filter(Boolean).join("\n");
|
|
@@ -281,6 +344,7 @@ export class ErrorDetector {
|
|
|
281
344
|
confidence: 0,
|
|
282
345
|
details: "No output to analyse",
|
|
283
346
|
rawMatch: null,
|
|
347
|
+
severity: PATTERN_SEVERITY.unknown ?? "low",
|
|
284
348
|
};
|
|
285
349
|
}
|
|
286
350
|
|
|
@@ -307,14 +371,17 @@ export class ErrorDetector {
|
|
|
307
371
|
}
|
|
308
372
|
}
|
|
309
373
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
374
|
+
const result = best || {
|
|
375
|
+
pattern: "unknown",
|
|
376
|
+
confidence: 0.3,
|
|
377
|
+
details: PATTERN_DESCRIPTIONS.unknown,
|
|
378
|
+
rawMatch: null,
|
|
379
|
+
};
|
|
380
|
+
return {
|
|
381
|
+
...result,
|
|
382
|
+
severity: PATTERN_SEVERITY[result.pattern] ?? "low",
|
|
383
|
+
remediation: REMEDIATION_HINTS[result.pattern] || null,
|
|
384
|
+
};
|
|
318
385
|
}
|
|
319
386
|
|
|
320
387
|
// ── recordError ─────────────────────────────────────────────────────────
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosun",
|
|
3
|
-
"version": "0.31.
|
|
3
|
+
"version": "0.31.5",
|
|
4
4
|
"description": "AI-powered orchestrator supervisor — manages AI agent executors with failover, auto-restarts on failure, analyzes crashes with Codex SDK, creates PRs via Vibe-Kanban API, and sends Telegram notifications. Supports N executors with weighted distribution, multi-repo projects, and auto-setup.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache 2.0",
|
package/task-executor.mjs
CHANGED
|
@@ -68,7 +68,7 @@ import {
|
|
|
68
68
|
collectDiffStats,
|
|
69
69
|
} from "./diff-stats.mjs";
|
|
70
70
|
import { createAnomalyDetector } from "./anomaly-detector.mjs";
|
|
71
|
-
import { normalizeDedupKey } from "./utils.mjs";
|
|
71
|
+
import { normalizeDedupKey, yieldToEventLoop, withRetry } from "./utils.mjs";
|
|
72
72
|
import {
|
|
73
73
|
resolveExecutorForTask,
|
|
74
74
|
executorToSdk,
|
|
@@ -114,6 +114,23 @@ const CODEX_TASK_LABELS = (() => {
|
|
|
114
114
|
/** Watchdog interval: how often to check for stalled agent slots */
|
|
115
115
|
const WATCHDOG_INTERVAL_MS = 60_000; // 1 minute
|
|
116
116
|
|
|
117
|
+
// ── Error categorization ───────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Categorize an error for smarter retry/backoff decisions.
|
|
121
|
+
* @param {unknown} err
|
|
122
|
+
* @returns {'transient'|'auth'|'network'|'conflict'|'permanent'|'unknown'}
|
|
123
|
+
*/
|
|
124
|
+
function categorizeError(err) {
|
|
125
|
+
const msg = (err?.message || err?.stderr || String(err ?? "")).toLowerCase();
|
|
126
|
+
if (/rate.?limit|too many requests|429/i.test(msg)) return "transient";
|
|
127
|
+
if (/auth|token|unauthorized|forbidden|403|401/i.test(msg)) return "auth";
|
|
128
|
+
if (/network|econnrefused|enotfound|etimedout|econnreset|socket/i.test(msg)) return "network";
|
|
129
|
+
if (/conflict|merge conflict|rebase conflict|CONFLICT/i.test(msg)) return "conflict";
|
|
130
|
+
if (/fatal|crash|exit code 1|exit code 127|SIGKILL/i.test(msg)) return "permanent";
|
|
131
|
+
return "unknown";
|
|
132
|
+
}
|
|
133
|
+
|
|
117
134
|
/**
|
|
118
135
|
* Returns the Co-authored-by trailer for bosun-botswain[bot], or empty string
|
|
119
136
|
* if the GitHub App ID is not configured. Used to attribute agent commits to
|
|
@@ -3152,10 +3169,20 @@ class TaskExecutor {
|
|
|
3152
3169
|
return;
|
|
3153
3170
|
}
|
|
3154
3171
|
|
|
3155
|
-
// Fetch todo tasks
|
|
3172
|
+
// Fetch todo tasks (with transient-error retry)
|
|
3156
3173
|
let tasks;
|
|
3157
3174
|
try {
|
|
3158
|
-
tasks = await
|
|
3175
|
+
tasks = await withRetry(
|
|
3176
|
+
() => listTasks(projectId, { status: "todo" }),
|
|
3177
|
+
{
|
|
3178
|
+
maxAttempts: 3,
|
|
3179
|
+
baseMs: 2000,
|
|
3180
|
+
retryIf: (err) => {
|
|
3181
|
+
const cat = categorizeError(err);
|
|
3182
|
+
return cat === "transient" || cat === "network";
|
|
3183
|
+
},
|
|
3184
|
+
},
|
|
3185
|
+
);
|
|
3159
3186
|
this._resetListTasksBackoff();
|
|
3160
3187
|
} catch (err) {
|
|
3161
3188
|
this._noteListTasksFailure(err);
|
|
@@ -3235,12 +3262,19 @@ class TaskExecutor {
|
|
|
3235
3262
|
for (const task of toDispatch) {
|
|
3236
3263
|
// Normalize task id
|
|
3237
3264
|
task.id = task.id || task.task_id;
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3265
|
+
try {
|
|
3266
|
+
// Fire and forget — executeTask handles its own lifecycle
|
|
3267
|
+
this.executeTask(task).catch((err) => {
|
|
3268
|
+
console.error(
|
|
3269
|
+
`${TAG} unhandled error in executeTask for "${task.title}": ${err.message}`,
|
|
3270
|
+
);
|
|
3271
|
+
});
|
|
3272
|
+
} catch (err) {
|
|
3273
|
+
console.warn(`${TAG} slot ${task.id} dispatch error: ${err.message}`);
|
|
3274
|
+
} finally {
|
|
3275
|
+
// ALWAYS yield, even on error, to prevent event loop starvation
|
|
3276
|
+
await yieldToEventLoop();
|
|
3277
|
+
}
|
|
3244
3278
|
}
|
|
3245
3279
|
} catch (err) {
|
|
3246
3280
|
console.error(`${TAG} poll loop error: ${err.message}`);
|
|
@@ -4795,6 +4829,15 @@ class TaskExecutor {
|
|
|
4795
4829
|
/* best-effort */
|
|
4796
4830
|
}
|
|
4797
4831
|
|
|
4832
|
+
// Category-specific backoff to avoid hammering services under stress
|
|
4833
|
+
const errCategory = categorizeError(result.error || "");
|
|
4834
|
+
console.log(`${TAG} error category for "${task.title}": ${errCategory}`);
|
|
4835
|
+
if (errCategory === "transient") {
|
|
4836
|
+
await new Promise((r) => setTimeout(r, 5_000));
|
|
4837
|
+
} else if (errCategory === "network") {
|
|
4838
|
+
await new Promise((r) => setTimeout(r, 15_000));
|
|
4839
|
+
}
|
|
4840
|
+
|
|
4798
4841
|
// If plan-stuck, use recovery prompt instead of generic retry
|
|
4799
4842
|
if (
|
|
4800
4843
|
classification.pattern === "plan_stuck" &&
|
package/ui/app.js
CHANGED
|
@@ -3,12 +3,28 @@
|
|
|
3
3
|
* Modular SPA for Telegram Mini App (no build step)
|
|
4
4
|
* ────────────────────────────────────────────────────────────── */
|
|
5
5
|
|
|
6
|
+
// ── Error telemetry ring buffer (max 50 entries, persisted to sessionStorage) ──
|
|
7
|
+
const MAX_ERROR_LOG = 50;
|
|
8
|
+
function getErrorLog() {
|
|
9
|
+
try { return JSON.parse(sessionStorage.getItem("ve_error_log") || "[]"); } catch { return []; }
|
|
10
|
+
}
|
|
11
|
+
function appendErrorLog(entry) {
|
|
12
|
+
try {
|
|
13
|
+
const log = getErrorLog();
|
|
14
|
+
log.unshift({ ...entry, ts: Date.now() });
|
|
15
|
+
if (log.length > MAX_ERROR_LOG) log.length = MAX_ERROR_LOG;
|
|
16
|
+
sessionStorage.setItem("ve_error_log", JSON.stringify(log));
|
|
17
|
+
} catch { /* quota exceeded */ }
|
|
18
|
+
}
|
|
19
|
+
|
|
6
20
|
/* ── Global error handlers — catch unhandled errors before they freeze the UI ── */
|
|
7
21
|
globalThis.addEventListener?.("error", (e) => {
|
|
8
22
|
console.error("[ve:global-error]", e.error || e.message);
|
|
23
|
+
appendErrorLog({ type: "global", message: e.message, stack: e.error?.stack });
|
|
9
24
|
});
|
|
10
25
|
globalThis.addEventListener?.("unhandledrejection", (e) => {
|
|
11
26
|
console.error("[ve:unhandled-rejection]", e.reason);
|
|
27
|
+
appendErrorLog({ type: "rejection", message: String(e.reason?.message || e.reason) });
|
|
12
28
|
});
|
|
13
29
|
|
|
14
30
|
import { h, render as preactRender } from "preact";
|
|
@@ -43,9 +59,12 @@ import {
|
|
|
43
59
|
connectWebSocket,
|
|
44
60
|
disconnectWebSocket,
|
|
45
61
|
wsConnected,
|
|
62
|
+
loadingCount,
|
|
46
63
|
} from "./modules/api.js";
|
|
47
64
|
import {
|
|
48
65
|
connected,
|
|
66
|
+
statusData,
|
|
67
|
+
executorData,
|
|
49
68
|
refreshTab,
|
|
50
69
|
toasts,
|
|
51
70
|
initWsInvalidationListener,
|
|
@@ -107,6 +126,60 @@ try {
|
|
|
107
126
|
if (stateMod.dataFreshness) dataFreshness = stateMod.dataFreshness;
|
|
108
127
|
} catch { /* use placeholder signals */ }
|
|
109
128
|
|
|
129
|
+
/* ── Shared components ── */
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* AnimatedNumber — smoothly counts from previous to new value using rAF.
|
|
133
|
+
*/
|
|
134
|
+
function AnimatedNumber({ value, duration = 600, className = "" }) {
|
|
135
|
+
const displayRef = useRef(value);
|
|
136
|
+
const rafRef = useRef(null);
|
|
137
|
+
const [display, setDisplay] = useState(value);
|
|
138
|
+
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
const from = displayRef.current;
|
|
141
|
+
const to = value;
|
|
142
|
+
if (from === to) return;
|
|
143
|
+
const start = performance.now();
|
|
144
|
+
const animate = (now) => {
|
|
145
|
+
const t = Math.min((now - start) / duration, 1);
|
|
146
|
+
const eased = 1 - Math.pow(1 - t, 3); // ease out cubic
|
|
147
|
+
const current = Math.round(from + (to - from) * eased);
|
|
148
|
+
displayRef.current = current;
|
|
149
|
+
setDisplay(current);
|
|
150
|
+
if (t < 1) rafRef.current = requestAnimationFrame(animate);
|
|
151
|
+
};
|
|
152
|
+
rafRef.current = requestAnimationFrame(animate);
|
|
153
|
+
return () => rafRef.current && cancelAnimationFrame(rafRef.current);
|
|
154
|
+
}, [value, duration]);
|
|
155
|
+
|
|
156
|
+
return html`<span class="${className}">${display}</span>`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* KeyboardShortcutsModal — shows available keyboard shortcuts.
|
|
161
|
+
*/
|
|
162
|
+
function KeyboardShortcutsModal({ onClose }) {
|
|
163
|
+
const shortcuts = [
|
|
164
|
+
{ key: "1–8", desc: "Switch tabs" },
|
|
165
|
+
{ key: "c", desc: "Create task (on Dashboard)" },
|
|
166
|
+
{ key: "?", desc: "Show keyboard shortcuts" },
|
|
167
|
+
{ key: "Esc", desc: "Close modal / palette" },
|
|
168
|
+
];
|
|
169
|
+
return html`
|
|
170
|
+
<${Modal} title="Keyboard Shortcuts" onClose=${onClose}>
|
|
171
|
+
<div class="shortcuts-list">
|
|
172
|
+
${shortcuts.map((s) => html`
|
|
173
|
+
<div class="shortcut-item" key=${s.key}>
|
|
174
|
+
<kbd class="shortcut-key">${s.key}</kbd>
|
|
175
|
+
<span class="shortcut-desc">${s.desc}</span>
|
|
176
|
+
</div>
|
|
177
|
+
`)}
|
|
178
|
+
</div>
|
|
179
|
+
<//>
|
|
180
|
+
`;
|
|
181
|
+
}
|
|
182
|
+
|
|
110
183
|
/* ── Backend health helpers ── */
|
|
111
184
|
|
|
112
185
|
function formatTimeAgo(ts) {
|
|
@@ -124,17 +197,48 @@ if (typeof document !== "undefined" && !document.getElementById("offline-banner-
|
|
|
124
197
|
gap: 12px;
|
|
125
198
|
padding: 12px 16px;
|
|
126
199
|
margin: 8px 16px;
|
|
127
|
-
background: rgba(239, 68, 68, 0.08);
|
|
128
200
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
|
129
201
|
border-radius: 14px;
|
|
130
202
|
box-shadow: var(--shadow-sm);
|
|
131
203
|
backdrop-filter: blur(6px);
|
|
132
204
|
animation: slideDown 0.3s ease-out;
|
|
205
|
+
transition: background 0.4s ease, border-color 0.4s ease;
|
|
206
|
+
}
|
|
207
|
+
.offline-banner.tone-orange {
|
|
208
|
+
background: rgba(249, 115, 22, 0.08);
|
|
209
|
+
border-color: rgba(249, 115, 22, 0.25);
|
|
210
|
+
}
|
|
211
|
+
.offline-banner.tone-red {
|
|
212
|
+
background: rgba(239, 68, 68, 0.12);
|
|
213
|
+
border-color: rgba(239, 68, 68, 0.3);
|
|
133
214
|
}
|
|
134
215
|
.offline-banner-icon { font-size: 20px; }
|
|
135
216
|
.offline-banner-content { flex: 1; }
|
|
136
|
-
.offline-banner-title { font-weight: 600; font-size: 13px;
|
|
217
|
+
.offline-banner-title { font-weight: 600; font-size: 13px; }
|
|
218
|
+
.tone-orange .offline-banner-title { color: #f97316; }
|
|
219
|
+
.tone-red .offline-banner-title { color: #ef4444; }
|
|
137
220
|
.offline-banner-meta { font-size: 12px; opacity: 0.7; margin-top: 2px; }
|
|
221
|
+
.offline-reconnect-bar {
|
|
222
|
+
height: 2px; border-radius: 2px; margin-top: 6px;
|
|
223
|
+
background: rgba(249,115,22,0.18);
|
|
224
|
+
overflow: hidden;
|
|
225
|
+
}
|
|
226
|
+
.offline-reconnect-fill {
|
|
227
|
+
height: 100%; border-radius: 2px;
|
|
228
|
+
background: #f97316;
|
|
229
|
+
transition: width 1s linear;
|
|
230
|
+
}
|
|
231
|
+
.tone-red .offline-reconnect-fill { background: #ef4444; }
|
|
232
|
+
.offline-dot {
|
|
233
|
+
width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
|
|
234
|
+
animation: offlinePulse 1.6s ease-in-out infinite;
|
|
235
|
+
}
|
|
236
|
+
.offline-dot.orange { background: #f97316; }
|
|
237
|
+
.offline-dot.red { background: #ef4444; }
|
|
238
|
+
@keyframes offlinePulse {
|
|
239
|
+
0%,100% { opacity:1; transform:scale(1); }
|
|
240
|
+
50% { opacity:0.5; transform:scale(1.3); }
|
|
241
|
+
}
|
|
138
242
|
`;
|
|
139
243
|
document.head.appendChild(style);
|
|
140
244
|
}
|
|
@@ -203,16 +307,38 @@ function OfflineBanner() {
|
|
|
203
307
|
}
|
|
204
308
|
} catch { /* handled by signal */ }
|
|
205
309
|
}, []);
|
|
310
|
+
|
|
311
|
+
const retryCount = backendRetryCount.value;
|
|
312
|
+
const isPersistent = retryCount > 3;
|
|
313
|
+
const tone = isPersistent ? "red" : "orange";
|
|
314
|
+
const title = isPersistent
|
|
315
|
+
? "Persistent connection failure"
|
|
316
|
+
: "Backend Unreachable";
|
|
317
|
+
|
|
318
|
+
// Reconnect countdown drives the progress bar
|
|
319
|
+
const countdown = wsReconnectIn.value;
|
|
320
|
+
const maxWait = 15; // max backoff seconds
|
|
321
|
+
const reconnectPct = countdown != null && countdown > 0
|
|
322
|
+
? Math.round(((maxWait - Math.min(countdown, maxWait)) / maxWait) * 100)
|
|
323
|
+
: 100;
|
|
324
|
+
|
|
206
325
|
return html`
|
|
207
|
-
<div class="offline-banner">
|
|
208
|
-
<div class="offline-
|
|
326
|
+
<div class="offline-banner tone-${tone}">
|
|
327
|
+
<div class="offline-dot ${tone}"></div>
|
|
209
328
|
<div class="offline-banner-content">
|
|
210
|
-
<div class="offline-banner-title"
|
|
329
|
+
<div class="offline-banner-title">${title}</div>
|
|
211
330
|
<div class="offline-banner-meta">${backendError.value || "Connection lost"}</div>
|
|
212
331
|
${backendLastSeen.value
|
|
213
332
|
? html`<div class="offline-banner-meta">Last connected: ${formatTimeAgo(backendLastSeen.value)}</div>`
|
|
214
333
|
: null}
|
|
215
|
-
<div class="offline-banner-meta">
|
|
334
|
+
<div class="offline-banner-meta">
|
|
335
|
+
${countdown != null && countdown > 0
|
|
336
|
+
? `Reconnecting in ${countdown}s…`
|
|
337
|
+
: `Retry attempt #${retryCount}`}
|
|
338
|
+
</div>
|
|
339
|
+
<div class="offline-reconnect-bar">
|
|
340
|
+
<div class="offline-reconnect-fill" style="width:${reconnectPct}%"></div>
|
|
341
|
+
</div>
|
|
216
342
|
</div>
|
|
217
343
|
<button class="btn btn-ghost btn-sm" onClick=${manualRetry}>Retry</button>
|
|
218
344
|
</div>
|
|
@@ -224,33 +350,59 @@ import { Component } from "preact";
|
|
|
224
350
|
class TabErrorBoundary extends Component {
|
|
225
351
|
constructor(props) {
|
|
226
352
|
super(props);
|
|
227
|
-
this.state = { error: null };
|
|
353
|
+
this.state = { error: null, showStack: false };
|
|
228
354
|
}
|
|
229
355
|
static getDerivedStateFromError(error) {
|
|
230
356
|
return { error };
|
|
231
357
|
}
|
|
232
358
|
componentDidCatch(error, info) {
|
|
233
359
|
console.error("[TabErrorBoundary] Caught error:", error, info);
|
|
360
|
+
appendErrorLog({ type: "render", tab: this.props.tabName, message: error?.message, stack: error?.stack });
|
|
234
361
|
}
|
|
235
362
|
render() {
|
|
236
363
|
if (this.state.error) {
|
|
237
|
-
const retry = () => this.setState({ error: null });
|
|
364
|
+
const retry = () => this.setState({ error: null, showStack: false });
|
|
365
|
+
const err = this.state.error;
|
|
366
|
+
const tabName = this.props.tabName || "";
|
|
367
|
+
const errorMsg = err?.message || "An unexpected error occurred while rendering this tab.";
|
|
368
|
+
const stack = err?.stack || "";
|
|
369
|
+
const copyError = () => {
|
|
370
|
+
const text = `${errorMsg}\n\n${stack}`;
|
|
371
|
+
navigator?.clipboard?.writeText(text).catch(() => {});
|
|
372
|
+
};
|
|
373
|
+
const toggleStack = () => this.setState((s) => ({ showStack: !s.showStack }));
|
|
238
374
|
return html`
|
|
239
|
-
<div
|
|
240
|
-
<div
|
|
241
|
-
|
|
242
|
-
Something went wrong
|
|
375
|
+
<div class="tab-error-boundary">
|
|
376
|
+
<div class="tab-error-pulse">
|
|
377
|
+
<span style="font-size:20px;color:#ef4444;">⚠</span>
|
|
243
378
|
</div>
|
|
244
|
-
<div
|
|
245
|
-
|
|
379
|
+
<div>
|
|
380
|
+
<div style="font-size:14px;font-weight:600;margin-bottom:4px;color:var(--text-primary);">
|
|
381
|
+
Something went wrong${tabName ? ` in ${tabName}` : ""}
|
|
382
|
+
</div>
|
|
383
|
+
<div style="font-size:12px;color:var(--text-secondary);max-width:400px;">
|
|
384
|
+
${errorMsg}
|
|
385
|
+
</div>
|
|
246
386
|
</div>
|
|
247
|
-
<
|
|
248
|
-
Retry
|
|
249
|
-
|
|
387
|
+
<div style="display:flex;gap:8px;flex-wrap:wrap;justify-content:center;">
|
|
388
|
+
<button class="btn btn-primary btn-sm" onClick=${retry}>Retry</button>
|
|
389
|
+
<button class="btn btn-ghost btn-sm" onClick=${copyError}>Copy Error</button>
|
|
390
|
+
${stack ? html`<button class="btn btn-ghost btn-sm" onClick=${toggleStack}>
|
|
391
|
+
${this.state.showStack ? "Hide Stack" : "Stack Trace"}
|
|
392
|
+
</button>` : null}
|
|
393
|
+
<button class="btn btn-ghost btn-sm" onClick=${() => {
|
|
394
|
+
console.group("[ve:error-log]");
|
|
395
|
+
getErrorLog().forEach((e, i) => console.log(i, e));
|
|
396
|
+
console.groupEnd();
|
|
397
|
+
}}>Error Log</button>
|
|
398
|
+
</div>
|
|
399
|
+
${this.state.showStack && stack ? html`
|
|
400
|
+
<div class="tab-error-stack">${stack}</div>
|
|
401
|
+
` : null}
|
|
250
402
|
</div>
|
|
251
403
|
`;
|
|
252
404
|
}
|
|
253
|
-
return this.props.children
|
|
405
|
+
return html`<div class="tab-content-enter">${this.props.children}</div>`;
|
|
254
406
|
}
|
|
255
407
|
}
|
|
256
408
|
|
|
@@ -364,10 +516,18 @@ function SidebarNav() {
|
|
|
364
516
|
${TAB_CONFIG.map((tab) => {
|
|
365
517
|
const isActive = activeTab.value === tab.id;
|
|
366
518
|
const isHome = tab.id === "dashboard";
|
|
519
|
+
const sCounts = statusData.value?.counts || {};
|
|
520
|
+
const badge =
|
|
521
|
+
tab.id === "tasks"
|
|
522
|
+
? Number(sCounts.running || sCounts.inprogress || 0) + Number(sCounts.inreview || sCounts.review || 0)
|
|
523
|
+
: tab.id === "agents"
|
|
524
|
+
? Number(executorData.value?.data?.activeSlots || 0)
|
|
525
|
+
: 0;
|
|
367
526
|
return html`
|
|
368
527
|
<button
|
|
369
528
|
key=${tab.id}
|
|
370
529
|
class="sidebar-nav-item ${isActive ? "active" : ""}"
|
|
530
|
+
style="position:relative"
|
|
371
531
|
aria-label=${tab.label}
|
|
372
532
|
aria-current=${isActive ? "page" : null}
|
|
373
533
|
onClick=${() =>
|
|
@@ -378,6 +538,7 @@ function SidebarNav() {
|
|
|
378
538
|
>
|
|
379
539
|
${ICONS[tab.icon]}
|
|
380
540
|
<span>${tab.label}</span>
|
|
541
|
+
${badge > 0 ? html`<span class="nav-badge">${badge}</span>` : null}
|
|
381
542
|
</button>
|
|
382
543
|
`;
|
|
383
544
|
})}
|
|
@@ -609,15 +770,20 @@ function getTabsById(ids) {
|
|
|
609
770
|
|
|
610
771
|
function BottomNav({ compact, moreOpen, onToggleMore, onNavigate }) {
|
|
611
772
|
const primaryTabs = getTabsById(PRIMARY_NAV_TABS);
|
|
773
|
+
const sCounts = statusData.value?.counts || {};
|
|
774
|
+
const tasksBadge = Number(sCounts.running || sCounts.inprogress || 0) + Number(sCounts.inreview || sCounts.review || 0);
|
|
775
|
+
const agentsBadge = Number(executorData.value?.data?.activeSlots || 0);
|
|
612
776
|
return html`
|
|
613
777
|
<nav class=${`bottom-nav ${compact ? "compact" : ""}`}>
|
|
614
778
|
${primaryTabs.map((tab) => {
|
|
615
779
|
const isHome = tab.id === "dashboard";
|
|
616
780
|
const isActive = activeTab.value === tab.id;
|
|
781
|
+
const badge = tab.id === "tasks" ? tasksBadge : tab.id === "agents" ? agentsBadge : 0;
|
|
617
782
|
return html`
|
|
618
783
|
<button
|
|
619
784
|
key=${tab.id}
|
|
620
785
|
class="nav-item ${isActive ? "active" : ""}"
|
|
786
|
+
style="position:relative"
|
|
621
787
|
aria-label=${`Go to ${tab.label}`}
|
|
622
788
|
type="button"
|
|
623
789
|
onClick=${() =>
|
|
@@ -628,6 +794,7 @@ function BottomNav({ compact, moreOpen, onToggleMore, onNavigate }) {
|
|
|
628
794
|
>
|
|
629
795
|
${ICONS[tab.icon]}
|
|
630
796
|
<span class="nav-label">${tab.label}</span>
|
|
797
|
+
${badge > 0 ? html`<span class="nav-badge">${badge}</span>` : null}
|
|
631
798
|
</button>
|
|
632
799
|
`;
|
|
633
800
|
})}
|
|
@@ -709,9 +876,30 @@ function MoreSheet({ open, onClose, onNavigate }) {
|
|
|
709
876
|
function App() {
|
|
710
877
|
useBackendHealth();
|
|
711
878
|
const { open: paletteOpen, onClose: paletteClose } = useCommandPalette();
|
|
879
|
+
const [showShortcuts, setShowShortcuts] = useState(false);
|
|
712
880
|
const mainRef = useRef(null);
|
|
713
881
|
const [showScrollTop, setShowScrollTop] = useState(false);
|
|
714
882
|
const scrollVisibilityRef = useRef(false);
|
|
883
|
+
|
|
884
|
+
// ── Top loading bar state ──
|
|
885
|
+
const [loadingPct, setLoadingPct] = useState(0);
|
|
886
|
+
const [loadingVisible, setLoadingVisible] = useState(false);
|
|
887
|
+
const loadingTimerRef = useRef(null);
|
|
888
|
+
const isLoading = loadingCount.value > 0;
|
|
889
|
+
useEffect(() => {
|
|
890
|
+
if (isLoading) {
|
|
891
|
+
if (loadingTimerRef.current) clearTimeout(loadingTimerRef.current);
|
|
892
|
+
setLoadingVisible(true);
|
|
893
|
+
setLoadingPct(70);
|
|
894
|
+
} else {
|
|
895
|
+
setLoadingPct(100);
|
|
896
|
+
loadingTimerRef.current = setTimeout(() => {
|
|
897
|
+
setLoadingVisible(false);
|
|
898
|
+
setLoadingPct(0);
|
|
899
|
+
}, 500);
|
|
900
|
+
}
|
|
901
|
+
return () => {};
|
|
902
|
+
}, [isLoading]);
|
|
715
903
|
const [isMoreOpen, setIsMoreOpen] = useState(false);
|
|
716
904
|
const resizeRef = useRef(null);
|
|
717
905
|
const [isCompactNav, setIsCompactNav] = useState(() => {
|
|
@@ -871,7 +1059,10 @@ function App() {
|
|
|
871
1059
|
|
|
872
1060
|
useEffect(() => {
|
|
873
1061
|
if (typeof globalThis === "undefined") return;
|
|
874
|
-
const handler = () =>
|
|
1062
|
+
const handler = () => {
|
|
1063
|
+
setIsMoreOpen(false);
|
|
1064
|
+
setShowShortcuts(false);
|
|
1065
|
+
};
|
|
875
1066
|
globalThis.addEventListener("ve:close-modals", handler);
|
|
876
1067
|
return () => globalThis.removeEventListener("ve:close-modals", handler);
|
|
877
1068
|
}, []);
|
|
@@ -922,9 +1113,24 @@ function App() {
|
|
|
922
1113
|
return;
|
|
923
1114
|
}
|
|
924
1115
|
|
|
1116
|
+
// "c" to create task (when not in a form element)
|
|
1117
|
+
if (e.key === "c") {
|
|
1118
|
+
e.preventDefault();
|
|
1119
|
+
globalThis.dispatchEvent(new CustomEvent("ve:create-task"));
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// "?" to toggle keyboard shortcuts help
|
|
1124
|
+
if (e.key === "?") {
|
|
1125
|
+
e.preventDefault();
|
|
1126
|
+
setShowShortcuts((v) => !v);
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
925
1130
|
// Escape to close modals/palette
|
|
926
1131
|
if (e.key === "Escape") {
|
|
927
1132
|
globalThis.dispatchEvent(new CustomEvent("ve:close-modals"));
|
|
1133
|
+
setShowShortcuts(false);
|
|
928
1134
|
}
|
|
929
1135
|
}
|
|
930
1136
|
document.addEventListener("keydown", handleGlobalKeys);
|
|
@@ -1055,6 +1261,7 @@ function App() {
|
|
|
1055
1261
|
}, []);
|
|
1056
1262
|
|
|
1057
1263
|
return html`
|
|
1264
|
+
<div class="top-loading-bar" style="width: ${loadingPct}%; opacity: ${loadingVisible ? 1 : 0}"></div>
|
|
1058
1265
|
<div
|
|
1059
1266
|
class="app-shell"
|
|
1060
1267
|
style=${shellStyle}
|
|
@@ -1128,9 +1335,10 @@ function App() {
|
|
|
1128
1335
|
${backendDown.value ? html`<${OfflineBanner} />` : null}
|
|
1129
1336
|
<${ToastContainer} />
|
|
1130
1337
|
<${CommandPalette} open=${paletteOpen} onClose=${paletteClose} />
|
|
1338
|
+
${showShortcuts ? html`<${KeyboardShortcutsModal} onClose=${() => setShowShortcuts(false)} />` : null}
|
|
1131
1339
|
<${PullToRefresh} onRefresh=${() => refreshTab(activeTab.value)}>
|
|
1132
1340
|
<main class="main-content" ref=${mainRef}>
|
|
1133
|
-
<${TabErrorBoundary} key=${activeTab.value}>
|
|
1341
|
+
<${TabErrorBoundary} key=${activeTab.value} tabName=${activeTab.value}>
|
|
1134
1342
|
<${CurrentTab} />
|
|
1135
1343
|
<//>
|
|
1136
1344
|
</main>
|