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.
@@ -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
- return (
311
- best || {
312
- pattern: "unknown",
313
- confidence: 0.3,
314
- details: PATTERN_DESCRIPTIONS.unknown,
315
- rawMatch: null,
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",
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 listTasks(projectId, { status: "todo" });
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
- // Fire and forget — executeTask handles its own lifecycle
3239
- this.executeTask(task).catch((err) => {
3240
- console.error(
3241
- `${TAG} unhandled error in executeTask for "${task.title}": ${err.message}`,
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; color: #ef4444; }
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-banner-icon">⚠️</div>
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">Backend Unreachable</div>
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">Retry attempt #${backendRetryCount.value}</div>
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 style="padding:24px;text-align:center;color:var(--text-secondary);">
240
- <div style="font-size:32px;margin-bottom:12px;">⚠️</div>
241
- <div style="font-size:14px;font-weight:600;margin-bottom:8px;">
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 style="font-size:12px;opacity:0.7;margin-bottom:16px;max-width:400px;margin-left:auto;margin-right:auto;">
245
- ${this.state.error?.message || "An unexpected error occurred while rendering this tab."}
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
- <button class="btn btn-primary btn-sm" onClick=${retry}>
248
- Retry
249
- </button>
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 = () => setIsMoreOpen(false);
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>