(function () {
  // Injected by the supervisor into the workspace dashboard HTML (and ONLY there — never the
  // chat PWA or the supervisor's own interstitials). Six jobs, all aimed at non-technical users:
  //
  //   1. Backend-down auto-detect — when the workspace backend crash-loops and gives up, the
  //      supervisor serves a full "backend down" interstitial on the next navigation. Poll for
  //      that state and reload automatically so the user doesn't have to press F5 to discover
  //      their app's server broke. We reload ONLY on the terminal "dead" (gave-up) state, never
  //      during a normal 1-2s restart (which reports alive:false, dead:false).
  //
  //   2. Friendly frontend-error overlay — Vite's dev server throws a raw red error box on a
  //      compile/import error, covering the whole screen (even the chat). We hide Vite's overlay
  //      and show a friendly one instead: the error text behind a "copy" button + a pointer to
  //      the chat. It auto-clears when the agent fixes the error (Vite removes its overlay).
  //
  //   3. Reload forensics — the dashboard sometimes reloads "from nothing" (suspect: Vite's
  //      client reloads the page whenever its HMR WebSocket drops + reconnects, which happens on
  //      tunnel blips and mobile backgrounding). Every reload-relevant event is logged to the
  //      console AND to a sessionStorage ring buffer that survives the reload, so the page that
  //      comes back can print exactly what killed its predecessor. Look for the
  //      "[bloby-forensics]" banner in the console after a mystery refresh.
  //
  //   4. Shell relay — under shell inversion the workspace renders inside the immortal shell's
  //      same-origin iframe and the chat bubble lives in the shell, so signals that used to be
  //      same-window must cross the frame boundary: app readiness ({type:'bloby:app-ready'}),
  //      document title ({type:'bloby:title'}), Escape keydowns ({type:'bloby:esc'}), and the
  //      nested onboard wizard's {type:'bloby:onboard-complete'} are posted up to the parent.
  //      No-ops when this document IS the top window (legacy mode / BLOBY_NO_SHELL=1).
  //
  //   5. Vite reconnect-reload suppression + staleness check — Vite's HMR client hard-reloads
  //      the page whenever its WebSocket drops. With an immortal dev server behind a fragile
  //      tunnel (and mobile backgrounding), that reload is almost always pointless. A
  //      synchronously-throwing 'vite:ws:disconnect' listener cancels it — but Vite 8 has NO
  //      reconnect loop (the suppressed reload IS its reconnection strategy), so the same
  //      handler starts a recovery poll against a server-side change counter
  //      (/__bloby/fe-stamp) and reloads ONLY when frontend files actually changed while the
  //      socket was dead.
  //
  //   6. Pull-to-refresh — shell mode cost installed PWAs their native drag-down refresh (the
  //      gesture lands in this iframe; the shell is overflow:hidden). Recreated here, scoped
  //      tighter than the native one: it reloads only the workspace iframe, never the chat.
  if (window.__blobyWorkspaceGuard) return;
  window.__blobyWorkspaceGuard = true;

  /* ── 3. Reload forensics (runs first so the location.reload wrap is in place early) ──── */
  var FORENSICS_KEY = '__bloby_reload_forensics';

  function flog(event, extra) {
    var entry = { t: new Date().toISOString(), e: event };
    if (extra) entry.x = String(extra).slice(0, 600);
    try {
      var arr = JSON.parse(sessionStorage.getItem(FORENSICS_KEY) || '[]');
      arr.push(entry);
      if (arr.length > 80) arr = arr.slice(-80);
      sessionStorage.setItem(FORENSICS_KEY, JSON.stringify(arr));
    } catch (e) {}
  }

  // On load: record how we got here into the forensics ring buffer. The trail the previous
  // page session left behind survives the reload in sessionStorage under FORENSICS_KEY and can
  // be inspected there after a mystery refresh.
  (function () {
    var navType = '?';
    try {
      var nav = performance.getEntriesByType('navigation')[0];
      if (nav) navType = nav.type; // 'navigate' | 'reload' | 'back_forward' | 'prerender'
    } catch (e) {}
    flog('page-load', 'navType=' + navType + ' wasDiscarded=' + (document.wasDiscarded === true) + ' visible=' + document.visibilityState);
  })();

  // Capture WHO calls location.reload(), with a stack trace. Non-configurable so the workspace
  // template's own splash wrapper in App.tsx can't re-wrap it and hide the caller behind a rAF
  // frame (App.tsx already try/catches its defineProperty and skips — splash is replicated here).
  try {
    var __origReload = location.reload.bind(location);
    Object.defineProperty(location, 'reload', {
      configurable: false,
      value: function () {
        var stack = '';
        try { stack = String(new Error('trace').stack || '').split('\n').slice(1, 8).join(' | '); } catch (e) {}
        flog('location.reload', stack);
        // Hidden page: rAF never fires (stalls the reload until refocus) and nobody sees a splash.
        if (document.visibilityState === 'hidden') { __origReload(); return; }
        // Replicate App.tsx's splash-before-reload so the user sees dark splash, not a white flash.
        try {
          var s = document.getElementById('splash');
          if (s) { s.style.display = 'block'; s.style.opacity = '1'; }
        } catch (e) {}
        requestAnimationFrame(function () { requestAnimationFrame(function () { __origReload(); }); });
      },
    });
    flog('reload-wrap', 'installed');
  } catch (e) {
    flog('reload-wrap', 'FAILED: ' + e);
  }

  // Lifecycle breadcrumbs — these distinguish "JS reloaded the page" from "the browser/OS
  // killed and restored it" (iOS PWA eviction, Chrome tab discard, bfcache).
  window.addEventListener('pagehide', function (e) { flog('pagehide', 'persisted=' + e.persisted); });
  window.addEventListener('beforeunload', function () { flog('beforeunload'); });
  document.addEventListener('visibilitychange', function () { flog('visibility', document.visibilityState); });
  document.addEventListener('freeze', function () { flog('freeze'); });
  document.addEventListener('resume', function () { flog('resume'); });
  window.addEventListener('online', function () { flog('online'); });
  window.addEventListener('offline', function () { flog('offline'); });
  window.addEventListener('pageshow', function (e) { flog('pageshow', 'persisted=' + e.persisted); });
  try {
    if (navigator.serviceWorker) {
      navigator.serviceWorker.addEventListener('controllerchange', function () { flog('sw-controllerchange'); });
    }
  } catch (e) {}

  // Vite HMR client events — forensics (job 3) + reconnect-reload suppression and staleness
  // check (job 5), all on one hot context.
  //
  // The suppression throw below is dispatched from the transport's socket 'close' handler
  // without an await/catch (client.mjs), so it surfaces as a window 'unhandledrejection'.
  // The error-overlay handler further down recognizes this marker and ignores it.
  var VITE_SUPPRESS_MARK = '[bloby] suppressing vite reload-on-reconnect';
  try {
    import('/@vite/client').then(function (m) {
      var hot = m.createHotContext('/__bloby_forensics.js');

      // Staleness stamp: a Vite-plugin middleware (reached through the supervisor's dashboard
      // proxy) serves /__bloby/fe-stamp as {stamp:N}, bumped on every frontend hot update.
      // Comparing the stamp across a disconnect tells us whether anything actually changed
      // while we were gone. When Vite is down the supervisor answers 503 HTML — json() rejects
      // and we treat it as "not back yet", never as "changed".
      var lastStamp = null;
      function fetchStamp() {
        return fetch('/__bloby/fe-stamp', { cache: 'no-store' })
          .then(function (r) { return r.json(); })
          .then(function (j) { return (j && typeof j.stamp === 'number') ? j.stamp : null; });
      }
      function refreshStamp(reason) {
        fetchStamp().then(function (s) {
          if (s !== null) lastStamp = s;
        }).catch(function (err) { flog('fe-stamp', reason + ' fetch failed: ' + err); });
      }
      // Baseline with retry: a transient blip right at page load must not leave us without a
      // baseline — the recovery poll would then adopt a possibly-already-moved stamp as truth.
      (function initStamp(attempt) {
        fetchStamp().then(function (s) {
          if (s !== null) { lastStamp = s; return; }
          throw new Error('bad payload');
        }).catch(function (err) {
          if (attempt >= 5) { flog('fe-stamp', 'init gave up: ' + err); return; }
          setTimeout(function () { initStamp(attempt + 1); }, 2000 * (attempt + 1));
        });
      })(0);

      // Recovery poll — the ONLY change signal after a disconnect. Vite 8 never reconnects a
      // dropped socket: transport.connect() runs once at module init, and the reload we
      // suppress below is what would have re-run it. So once disconnected, HMR messages and
      // 'vite:ws:connect' are gone for good; we poll the stamp instead. Stamp moved → reload
      // (restores both freshness and HMR). Stamp unchanged → page content is current, keep
      // polling (HMR stays dead until the next agent edit bumps the stamp). A failed fetch
      // (supervisor's 503 HTML while Vite is down → json() rejects) means "not back yet".
      // Single instance so rapid disconnects don't stack timers; paused while hidden
      // (mirrors Vite's own visibility-aware ping) and kicked on visibility/online.
      var recoveryTimer = null;
      var stalePending = false;
      function recoveryTick() {
        if (document.visibilityState === 'hidden') return;
        fetchStamp().then(function (s) {
          if (s === null || recoveryTimer === null) return;
          if (lastStamp === null) { lastStamp = s; return; } // no baseline — adopt and watch
          if (s !== lastStamp) {
            // Stamp moved, but require a SECOND consecutive healthy fetch before reloading:
            // right after a supervisor restart (self-update) the tunnel still drops the odd
            // request — reloading into that window is how the iframe used to land on
            // cloudflared's raw 502 page, which has no Bloby code in it to recover.
            if (!stalePending) {
              stalePending = true;
              flog('stale-after-disconnect', 'stamp ' + lastStamp + '→' + s + ' (confirming next tick)');
              return;
            }
            flog('stale-after-disconnect', 'stamp ' + lastStamp + '→' + s);
            stopRecoveryPoll();
            location.reload(); // goes through the forensics wrapper: logged + splash
          } else {
            stalePending = false;
          }
        }).catch(function () { stalePending = false; }); // consecutive = no failures in between
      }
      function startRecoveryPoll() {
        if (recoveryTimer !== null) return;
        stalePending = false;
        flog('recovery-poll', 'started');
        recoveryTimer = setInterval(recoveryTick, 3000);
        recoveryTick();
      }
      function stopRecoveryPoll() {
        if (recoveryTimer === null) return;
        clearInterval(recoveryTimer);
        recoveryTimer = null;
        flog('recovery-poll', 'stopped');
      }
      document.addEventListener('visibilitychange', function () {
        if (document.visibilityState === 'visible' && recoveryTimer !== null) recoveryTick();
      });
      window.addEventListener('online', function () {
        if (recoveryTimer !== null) recoveryTick();
      });

      hot.on('vite:ws:disconnect', function () {
        flog('vite:ws:disconnect', 'visible=' + document.visibilityState);
        // The poll must start BEFORE the suppressing throw aborts this handler.
        startRecoveryPoll();
        // Cancel Vite's reload-on-reconnect: client.mjs handleMessage awaits
        // notifyListeners('vite:ws:disconnect') BEFORE waitForSuccessfulPing() +
        // location.reload(). notifyListeners runs cbs.map((cb) => cb(data)) synchronously, so
        // a SYNCHRONOUS throw here rejects that awaited promise and the reload never runs.
        // (An async listener would NOT work — Promise.allSettled swallows rejections.) We
        // suppress because the dev server is immortal while the tunnel/mobile connection is
        // fragile: every blip would otherwise hard-reload a perfectly-current page. Net
        // effect with the recovery poll above: connection blips no longer reload the page;
        // we reload ONLY when edits actually happened while disconnected.
        throw new Error(VITE_SUPPRESS_MARK + ' — staleness is handled by the recovery poll instead');
      });
      // Dead code on Vite 8 after the first disconnect (the event is emitted only inside
      // transport.connect(), which never re-runs) — kept as a fast path for a future Vite
      // that reconnects in place.
      hot.on('vite:ws:connect', function () {
        flog('vite:ws:connect');
        stopRecoveryPoll();
        if (lastStamp === null) { refreshStamp('connect'); return; }
        fetchStamp().then(function (s) {
          if (s === null) return;
          if (s !== lastStamp) {
            flog('stale-after-reconnect', 'stamp ' + lastStamp + '→' + s);
            location.reload(); // goes through the forensics wrapper: logged + splash
          }
        }).catch(function (err) { flog('fe-stamp', 'connect fetch failed: ' + err); });
      });
      // We just received these updates over the live socket, so we're current — resync.
      hot.on('vite:afterUpdate', function () { refreshStamp('after-update'); });
      hot.on('vite:beforeFullReload', function (p) {
        flog('vite:beforeFullReload', p && p.path ? 'path=' + p.path : '');
        refreshStamp('before-full-reload');
      });
      hot.on('vite:invalidate', function (p) { flog('vite:invalidate', (p && p.path) || ''); });
      hot.on('vite:error', function (p) { flog('vite:error', p && p.err && p.err.message || ''); });
      flog('vite-hooks', 'installed');
    }).catch(function (err) { flog('vite-hooks', 'import failed: ' + err); });
  } catch (e) {
    flog('vite-hooks', 'unavailable: ' + e);
  }

  /* ── 4. Shell relay ───────────────────────────────────────────────────── */
  // The shell (parent) hosts the bubble + chat; this document is its same-origin iframe.
  // Everything here no-ops when we ARE the top window (legacy mode / BLOBY_NO_SHELL=1).
  (function () {
    var framed = false;
    try { framed = !!window.parent && window.parent !== window; } catch (e) {}
    if (!framed) return;

    function post(msg) {
      try { window.parent.postMessage(msg, location.origin); } catch (e) {}
    }

    // a. App readiness. The workspace's main.tsx dispatches the same-window DOM Event
    // 'bloby:app-ready' and sets window.__blobyAppReady after first paint. The event may have
    // fired before this script ran (or a custom workspace may never dispatch it), so also
    // check the flag now and poll it; give up after 20s. Send at most once.
    //
    // Readiness also hides the workspace's own opaque #splash (index.html, z-index 9998):
    // widget.js — the only thing that ever hid it — no-ops inside frames, so without this
    // every shell-mode load (including onboarding, whose iframe sits at z-index 200) stays
    // black forever. Timeout backstop (~8s, matching the shell's own) keeps broken apps
    // visible too. Framed-only, so legacy mode still gets widget.js's splash animation.
    var readySent = false;
    function hideSplash() {
      try {
        var s = document.getElementById('splash');
        if (s) s.style.display = 'none';
      } catch (e) {}
    }
    setTimeout(hideSplash, 8000);

    // Perf sample for the shell's beacon: this document's navigation timing + a resource
    // census (count / bytes over the wire / how many were served from cache — transferSize 0).
    // Sent once, a beat after app-ready so late modules land in the same sample.
    try { performance.setResourceTimingBufferSize(1000); } catch (e) {} // dev graphs exceed the 250 default
    var perfSent = false;
    function sendPerf(appReadyMs) {
      if (perfSent) return;
      perfSent = true;
      setTimeout(function () {
        try {
          var nav = performance.getEntriesByType('navigation')[0] || null;
          var resources = performance.getEntriesByType('resource');
          var count = 0, transfer = 0, encoded = 0, cached = 0;
          for (var i = 0; i < resources.length; i++) {
            count++;
            transfer += resources[i].transferSize || 0;
            encoded += resources[i].encodedBodySize || 0;
            if ((resources[i].transferSize || 0) === 0) cached++;
          }
          post({ type: 'bloby:perf', data: {
            timeOrigin: performance.timeOrigin,
            ttfb: nav ? Math.round(nav.responseStart) : null,
            htmlDone: nav ? Math.round(nav.responseEnd) : null,
            domLoaded: nav ? Math.round(nav.domContentLoadedEventEnd) : null,
            appReadyMs: appReadyMs,
            resources: { count: count, transferKB: Math.round(transfer / 1024), encodedKB: Math.round(encoded / 1024), cached: cached },
          } });
        } catch (e) {}
      }, 2500);
    }

    function sendReady() {
      if (readySent) return;
      readySent = true;
      hideSplash();
      post({ type: 'bloby:app-ready' });
      sendPerf(Math.round(performance.now()));
    }
    window.addEventListener('bloby:app-ready', sendReady);
    if (window.__blobyAppReady) sendReady();
    var readyPolls = 0;
    var readyTimer = setInterval(function () {
      if (window.__blobyAppReady) sendReady();
      if (readySent || ++readyPolls >= 80) clearInterval(readyTimer); // 80 × 250ms = 20s
    }, 250);

    // b. Title sync — the shell mirrors the iframe's title into the real tab title.
    function postTitle() {
      if (document.title) post({ type: 'bloby:title', title: document.title });
    }
    postTitle();
    try {
      var titleEl = document.querySelector('title');
      if (titleEl) {
        new MutationObserver(postTitle).observe(titleEl, { childList: true, characterData: true, subtree: true });
      }
    } catch (e) {}

    // c. Escape — keydown inside the iframe never reaches the shell document, so the shell's
    // widget would miss its close-the-chat-panel shortcut.
    window.addEventListener('keydown', function (e) {
      if (e.key === 'Escape') post({ type: 'bloby:esc' });
    });

    // d. Onboard relay — the onboarding wizard iframe is nested INSIDE this workspace app and
    // posts {type:'bloby:onboard-complete'} to ITS parent (= this window); the widget that
    // must un-hide the bubble now lives in the shell. Re-post upward. Loop-safe by direction:
    // only messages from below (never from the parent shell) are relayed.
    window.addEventListener('message', function (e) {
      if (e.source === window.parent) return;
      if (e.origin === location.origin && e.data && e.data.type === 'bloby:onboard-complete') {
        post(e.data);
      }
    });
  })();

  /* ── 6. Pull-to-refresh ───────────────────────────────────────────────── */
  // Native pull-to-refresh belongs to the top-level document; in shell mode that's the
  // overflow:hidden shell, and touch gestures land in this iframe — so installed PWAs lost
  // drag-down refresh entirely. Recreate it here: a mostly-vertical downward drag that
  // starts with every scrollable ancestor at its top pulls out an indicator; releasing past
  // the threshold reloads ONLY this workspace document (through the forensics wrapper →
  // splash), so the chat in the shell never blinks. Framed + touch devices only — legacy
  // mode (BLOBY_NO_SHELL=1) keeps the browser's native behavior.
  (function () {
    var framed = false;
    try { framed = !!window.parent && window.parent !== window; } catch (e) {}
    if (!framed || !('ontouchstart' in window)) return;

    var THRESHOLD = 75; // px of damped pull that arms the refresh
    var ENGAGE = 10;    // px of raw downward travel before we claim the gesture
    var startX = null, startY = null, pulling = false, dist = 0;
    var indicator = null, arrow = null;

    var spinStyle = document.createElement('style');
    spinStyle.textContent = '@keyframes bloby-ptr-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}';
    (document.head || document.documentElement).appendChild(spinStyle);

    function ensureIndicator() {
      if (indicator) return;
      indicator = document.createElement('div');
      // z-index 99996: above the app and the splash (9998) so the spinner stays visible
      // through the pre-reload splash, below the shell's chat chrome (which is a separate
      // document anyway).
      indicator.style.cssText = 'position:fixed;top:-48px;left:50%;width:38px;height:38px;margin-left:-19px;z-index:99996;border-radius:50%;background:#18181b;border:1px solid #27272a;box-shadow:0 2px 12px rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;pointer-events:none';
      indicator.innerHTML = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#4AEEFF" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="transition:transform .15s ease"><path d="M12 5v14"/><path d="M5 12l7 7 7-7"/></svg>';
      document.body.appendChild(indicator);
      arrow = indicator.firstChild;
    }

    function setPull(d) {
      ensureIndicator();
      indicator.style.transition = 'none';
      indicator.style.transform = 'translateY(' + Math.min(d + 48, 140) + 'px)';
      if (arrow) arrow.style.transform = d >= THRESHOLD ? 'rotate(180deg)' : 'rotate(0deg)';
    }

    function settle(triggered) {
      if (!indicator) return;
      indicator.style.transition = 'transform .25s ease';
      if (triggered) {
        indicator.style.transform = 'translateY(72px)';
        if (arrow) { arrow.style.transition = 'none'; arrow.style.animation = 'bloby-ptr-spin .7s linear infinite'; }
      } else {
        indicator.style.transform = 'translateY(0)'; // back above the viewport edge
      }
    }

    // The pull only makes sense when the gesture couldn't have scrolled anything instead:
    // every scrollable ancestor of the touch target (and the page itself) is at its top.
    function ancestorsAtTop(el) {
      var n = el;
      while (n && n.nodeType === 1 && n !== document.documentElement) {
        if (n.scrollTop > 0) return false;
        n = n.parentElement;
      }
      var se = document.scrollingElement || document.documentElement;
      return (se.scrollTop || 0) <= 0;
    }

    window.addEventListener('touchstart', function (e) {
      startX = startY = null; pulling = false; dist = 0;
      if (e.touches.length !== 1) return;
      if (!ancestorsAtTop(e.target)) return;
      startX = e.touches[0].clientX;
      startY = e.touches[0].clientY;
    }, { passive: true });

    window.addEventListener('touchmove', function (e) {
      if (startY === null || e.touches.length !== 1) return;
      var dx = e.touches[0].clientX - startX;
      var dy = e.touches[0].clientY - startY;
      if (!pulling) {
        // Claim only a clearly-vertical downward drag; anything else stays a normal gesture.
        if (dy < ENGAGE || Math.abs(dy) < Math.abs(dx) * 1.5) {
          if (dy < -ENGAGE || Math.abs(dx) > 24) startY = null; // it's a scroll/swipe — let go for good
          return;
        }
        pulling = true;
      }
      e.preventDefault(); // claimed: keep the page from rubber-banding underneath the pull
      dist = Math.max(0, (dy - ENGAGE) * 0.45); // damped, native-ish feel
      setPull(dist);
    }, { passive: false });

    function release(cancelled) {
      if (pulling) {
        var go = !cancelled && dist >= THRESHOLD;
        settle(go);
        if (go) { flog('pull-to-refresh', 'triggered'); location.reload(); }
      }
      startX = startY = null; pulling = false; dist = 0;
    }
    window.addEventListener('touchend', function () { release(false); }, { passive: true });
    window.addEventListener('touchcancel', function () { release(true); }, { passive: true });
  })();

  /* ── 1. Backend-down auto-detect ──────────────────────────────────────── */
  var reloading = false;
  function pollBackend() {
    fetch('/__bloby/backend-status', { cache: 'no-store' })
      .then(function (r) { return r.json(); })
      .then(function (s) { if (s && s.dead && !reloading) { reloading = true; location.reload(); } })
      .catch(function () {});
  }
  setInterval(pollBackend, 4000);
  pollBackend();

  /* ── 2. Friendly frontend-error overlay ───────────────────────────────── */
  // Hide Vite's built-in overlay (kept enabled so we can read the error text from its shadow DOM).
  var hideStyle = document.createElement('style');
  hideStyle.textContent = 'vite-error-overlay{display:none!important}';
  (document.head || document.documentElement).appendChild(hideStyle);

  /* ── 2a. Frontend error capture → supervisor ──────────────────────────────
     Captures EVERY break class — runtime window.onerror, unhandledrejection, console.error/warn,
     and the Vite compile overlay — into a ring. The ring backs (1) the "Copy error" button, so it
     is never empty even for runtime/black-screen breaks (no Vite overlay), and (2) a debounced POST
     to /__bloby/control/fe-log, so the agent's `…/control/logs/frontend` tail is never empty either.
     Before this, only Vite's compile overlay was readable and the listeners captured nothing. */
  var feRing = [];      // last ~120 entries, for the Copy button
  var feUnsent = [];    // not-yet-POSTed entries
  var feFlushTimer = null;
  function safeStringify(o) { try { return JSON.stringify(o); } catch (e) { return String(o); } }
  function pushFe(kind, text, stack) {
    if (text == null) return;
    text = String(text).slice(0, 4000).trim();
    if (!text) return;
    if (text.indexOf(VITE_SUPPRESS_MARK) !== -1) return; // benign HMR-reconnect marker, not an app error
    var last = feRing[feRing.length - 1];
    if (last && last.kind === kind && last.text === text) return; // collapse repeats (1.5s overlay tick)
    var entry = { kind: kind, text: text, stack: stack ? String(stack).slice(0, 4000) : undefined, t: Date.now() };
    feRing.push(entry); if (feRing.length > 120) feRing.shift();
    feUnsent.push(entry);
    if (!feFlushTimer) feFlushTimer = setTimeout(flushFe, 1000);
  }
  function flushFe() {
    feFlushTimer = null;
    if (!feUnsent.length) return;
    var batch = feUnsent.splice(0, feUnsent.length);
    try {
      fetch('/__bloby/control/fe-log', {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ entries: batch }), keepalive: true, cache: 'no-store',
      }).catch(function () {});
    } catch (e) {}
  }
  function feTail(n) {
    return feRing.slice(-(n || 6)).map(function (e) {
      return '[' + e.kind + '] ' + e.text + (e.stack ? '\n' + e.stack : '');
    }).join('\n\n');
  }
  // Patch console.error/console.warn once — the ONLY source for the "frontend devtools" tail.
  ['error', 'warn'].forEach(function (level) {
    var orig = console[level];
    if (typeof orig !== 'function' || orig.__blobyPatched) return;
    var patched = function () {
      try {
        var parts = [];
        for (var i = 0; i < arguments.length; i++) {
          var a = arguments[i];
          parts.push(a instanceof Error ? (a.message + (a.stack ? '\n' + a.stack : ''))
            : (a && typeof a === 'object' ? safeStringify(a) : String(a)));
        }
        pushFe('console.' + level, parts.join(' '));
      } catch (e) {}
      return orig.apply(console, arguments);
    };
    patched.__blobyPatched = true;
    console[level] = patched;
  });

  // Flush buffered errors before the page goes away — a runtime error very often immediately precedes
  // a reload, and this guard itself reloads aggressively (backend-down poll, vite-stale recovery).
  // keepalive (set in flushFe) lets the POST complete during unload, so the agent's frontend-log tail
  // doesn't miss the error that broke the page.
  window.addEventListener('pagehide', flushFe);
  document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'hidden') flushFe(); });

  var overlay = null;
  var dismissed = false;
  var lastErr = '';

  function readViteError() {
    var ov = document.querySelector('vite-error-overlay');
    if (!ov || !ov.shadowRoot) return '';
    var sr = ov.shadowRoot, parts = [];
    ['.message-body', '.file', '.frame'].forEach(function (sel) {
      var el = sr.querySelector(sel);
      if (el && el.textContent && el.textContent.trim()) parts.push(el.textContent.trim());
    });
    return parts.join('\n\n');
  }

  function buildOverlay() {
    var d = document.createElement('div');
    d.id = '__bloby_fe_error';
    // z-index 99990: below the chat widget (99998/99999) so the user can still open chat, above the app.
    d.setAttribute('style', 'position:fixed;inset:0;z-index:99990;background:#0a0a0b;color:#e4e4e7;display:flex;align-items:center;justify-content:center;padding:1.5rem;font-family:system-ui,-apple-system,sans-serif');
    d.innerHTML =
      '<div style="text-align:center;max-width:480px;width:100%">' +
        '<div style="position:relative;width:160px;height:160px;margin:0 auto 1.2rem">' +
          '<div style="position:absolute;inset:-18px;background:radial-gradient(circle,rgba(1,102,255,.18) 0%,transparent 60%);filter:blur(18px)"></div>' +
          '<video autoplay loop muted playsinline style="position:relative;width:100%;height:100%;object-fit:contain;border-radius:50%">' +
            '<source src="/what-happened.webm" type="video/webm"><source src="/what-happened.mp4" type="video/mp4">' +
          '</video>' +
        '</div>' +
        '<h1 style="font-size:1.5rem;font-weight:700;margin:0 0 .6rem;background:linear-gradient(135deg,#0166FF,#009AFE,#4AEEFF);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">Workspace error</h1>' +
        '<p style="color:#e4e4e7;font-size:1rem;line-height:1.6;margin:0 0 .4rem">Your latest frontend change didn’t compile. If your Bloby is currently building or editing something, this is usually normal while the work is still in progress.</p>' +
        '<p style="color:#a1a1aa;font-size:.93rem;line-height:1.6;margin:0 0 1.2rem">When your agent finishes, this may fix itself. If it doesn’t, ask your Bloby to fix it and copy the error so it can debug faster.</p>' +
        '<div><button id="__bloby_fe_copy" style="font:inherit;cursor:pointer;border:none;border-radius:10px;padding:.65rem 1.2rem;font-size:.9rem;font-weight:600;background:linear-gradient(135deg,#0166FF,#0069FE);color:#fff">Copy error for your agent</button> ' +
        '<button id="__bloby_fe_dismiss" style="font:inherit;cursor:pointer;border-radius:10px;padding:.65rem 1.1rem;font-size:.9rem;font-weight:600;border:1px solid #27272a;background:#18181b;color:#e4e4e7">Dismiss</button></div>' +
      '</div>';
    document.body.appendChild(d);

    var copyBtn = d.querySelector('#__bloby_fe_copy');
    copyBtn.addEventListener('click', function () {
      // Re-scrape Vite's overlay at click time (it may have populated since the overlay was built),
      // then fall back to the captured ring — so runtime errors / black screens (no Vite overlay)
      // still copy real text instead of "(no details captured)".
      var detail = readViteError() || lastErr || feTail(6);
      var text = 'A screen in my app has a frontend error. Find and fix the root cause. Error:\n\n' + (detail || '(no error text was captured — read the frontend logs to investigate)');
      function ok() { copyBtn.textContent = '✓ Copied — paste it to your agent'; setTimeout(function () { copyBtn.textContent = 'Copy error for your agent'; }, 2600); }
      function fb() { var ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); ok(); } catch (e) {} document.body.removeChild(ta); }
      if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(ok).catch(fb); else fb();
    });
    d.querySelector('#__bloby_fe_dismiss').addEventListener('click', function () {
      dismissed = true;
      if (overlay) { overlay.remove(); overlay = null; }
    });
    return d;
  }

  var start = Date.now();
  var sawError = false;

  function appLooksBroken() {
    // 1. Compile/import error → Vite renders <vite-error-overlay>. Immediate, reliable.
    if (document.querySelector('vite-error-overlay')) return true;
    var root = document.getElementById('root');
    if (!root) return false; // not the dashboard shell — don't interfere
    // Healthy = React rendered real content into #root (and it isn't the workspace template's
    // own "Your app crashed" fallback, which we supersede with the friendly screen).
    var mounted = root.children.length > 0 && (root.textContent || '').indexOf('Your app crashed') === -1;
    if (mounted) return false;
    // Not mounted: if we already saw a hard error, judge immediately; otherwise give a cold
    // load a generous grace window before declaring a black screen (avoids false positives).
    if (sawError) return true;
    return (Date.now() - start) > 8000;
  }

  function evaluate() {
    if (appLooksBroken()) {
      var err = readViteError();
      if (err) { lastErr = err; pushFe('vite-overlay', err); }
      if (!overlay && !dismissed) overlay = buildOverlay();
    } else {
      // App healthy (or recovered) — drop our overlay and re-arm for the next episode.
      dismissed = false;
      if (overlay) { overlay.remove(); overlay = null; }
    }
  }

  // Triggers: Vite appends <vite-error-overlay> directly under <body> (childList catches it
  // instantly + cheaply); a steady tick handles the timeout-based black-screen detection and
  // recovery; window errors flip sawError so real load failures surface fast.
  new MutationObserver(evaluate).observe(document.body, { childList: true });
  setInterval(evaluate, 1500);
  window.addEventListener('error', function (e) {
    // Capture the actual error text + stack. Runtime errors render NO Vite overlay, so before this
    // they were completely invisible to the copy button / logs. Skip resource-load errors (e.target
    // is an element, with no e.error/e.message) — they carry no useful app-error text.
    try {
      if (e && (e.error || e.message)) {
        var emsg = e.message || (e.error && e.error.message) || 'Error';
        if (e.filename) emsg += ' @ ' + e.filename + ':' + (e.lineno || 0) + ':' + (e.colno || 0);
        pushFe('error', emsg, e.error && e.error.stack);
      }
    } catch (err) {}
    sawError = true; evaluate();
  });
  window.addEventListener('unhandledrejection', function (e) {
    // The reconnect-reload suppressor (job 5) throws a marker error inside Vite's HMR
    // dispatch; it surfaces here on every tunnel blip. Not an app error — swallow it.
    try {
      var msg = e && e.reason && e.reason.message;
      if (msg && String(msg).indexOf(VITE_SUPPRESS_MARK) !== -1) { e.preventDefault(); return; }
    } catch (err) {}
    try {
      var reason = e && e.reason;
      var rtext = reason && (reason.message || reason);
      pushFe('unhandledrejection', rtext != null ? String(rtext) : 'Unhandled promise rejection', reason && reason.stack);
    } catch (err2) {}
    sawError = true; evaluate();
  });
  evaluate();
})();
