bloby-bot 0.62.3 → 0.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  (function () {
2
2
  // Injected by the supervisor into the workspace dashboard HTML (and ONLY there — never the
3
- // chat PWA or the supervisor's own interstitials). Two jobs, both aimed at non-technical users:
3
+ // chat PWA or the supervisor's own interstitials). Five jobs, all aimed at non-technical users:
4
4
  //
5
5
  // 1. Backend-down auto-detect — when the workspace backend crash-loops and gives up, the
6
6
  // supervisor serves a full "backend down" interstitial on the next navigation. Poll for
@@ -12,9 +12,306 @@
12
12
  // compile/import error, covering the whole screen (even the chat). We hide Vite's overlay
13
13
  // and show a friendly one instead: the error text behind a "copy" button + a pointer to
14
14
  // the chat. It auto-clears when the agent fixes the error (Vite removes its overlay).
15
+ //
16
+ // 3. Reload forensics — the dashboard sometimes reloads "from nothing" (suspect: Vite's
17
+ // client reloads the page whenever its HMR WebSocket drops + reconnects, which happens on
18
+ // tunnel blips and mobile backgrounding). Every reload-relevant event is logged to the
19
+ // console AND to a sessionStorage ring buffer that survives the reload, so the page that
20
+ // comes back can print exactly what killed its predecessor. Look for the
21
+ // "[bloby-forensics]" banner in the console after a mystery refresh.
22
+ //
23
+ // 4. Shell relay — under shell inversion the workspace renders inside the immortal shell's
24
+ // same-origin iframe and the chat bubble lives in the shell, so signals that used to be
25
+ // same-window must cross the frame boundary: app readiness ({type:'bloby:app-ready'}),
26
+ // document title ({type:'bloby:title'}), Escape keydowns ({type:'bloby:esc'}), and the
27
+ // nested onboard wizard's {type:'bloby:onboard-complete'} are posted up to the parent.
28
+ // No-ops when this document IS the top window (legacy mode / BLOBY_NO_SHELL=1).
29
+ //
30
+ // 5. Vite reconnect-reload suppression + staleness check — Vite's HMR client hard-reloads
31
+ // the page whenever its WebSocket drops. With an immortal dev server behind a fragile
32
+ // tunnel (and mobile backgrounding), that reload is almost always pointless. A
33
+ // synchronously-throwing 'vite:ws:disconnect' listener cancels it — but Vite 8 has NO
34
+ // reconnect loop (the suppressed reload IS its reconnection strategy), so the same
35
+ // handler starts a recovery poll against a server-side change counter
36
+ // (/__bloby/fe-stamp) and reloads ONLY when frontend files actually changed while the
37
+ // socket was dead.
15
38
  if (window.__blobyWorkspaceGuard) return;
16
39
  window.__blobyWorkspaceGuard = true;
17
40
 
41
+ /* ── 3. Reload forensics (runs first so the location.reload wrap is in place early) ──── */
42
+ var FORENSICS_KEY = '__bloby_reload_forensics';
43
+
44
+ function flog(event, extra) {
45
+ var entry = { t: new Date().toISOString(), e: event };
46
+ if (extra) entry.x = String(extra).slice(0, 600);
47
+ console.log('[bloby-forensics]', entry.t, event, extra || '');
48
+ try {
49
+ var arr = JSON.parse(sessionStorage.getItem(FORENSICS_KEY) || '[]');
50
+ arr.push(entry);
51
+ if (arr.length > 80) arr = arr.slice(-80);
52
+ sessionStorage.setItem(FORENSICS_KEY, JSON.stringify(arr));
53
+ } catch (e) {}
54
+ }
55
+
56
+ // On load: report how we got here + dump the trail the previous page session left behind.
57
+ (function () {
58
+ var navType = '?';
59
+ try {
60
+ var nav = performance.getEntriesByType('navigation')[0];
61
+ if (nav) navType = nav.type; // 'navigate' | 'reload' | 'back_forward' | 'prerender'
62
+ } catch (e) {}
63
+ var trail = null;
64
+ try { trail = sessionStorage.getItem(FORENSICS_KEY); } catch (e) {}
65
+ console.log(
66
+ '%c[bloby-forensics] PAGE LOADED — navigation.type=' + navType +
67
+ ' wasDiscarded=' + (document.wasDiscarded === true) +
68
+ '\nTrail left by the previous page session (newest last):',
69
+ 'color:#4AEEFF;font-weight:bold',
70
+ trail ? JSON.parse(trail) : '(none — first load in this tab)'
71
+ );
72
+ flog('page-load', 'navType=' + navType + ' wasDiscarded=' + (document.wasDiscarded === true) + ' visible=' + document.visibilityState);
73
+ })();
74
+
75
+ // Capture WHO calls location.reload(), with a stack trace. Non-configurable so the workspace
76
+ // template's own splash wrapper in App.tsx can't re-wrap it and hide the caller behind a rAF
77
+ // frame (App.tsx already try/catches its defineProperty and skips — splash is replicated here).
78
+ try {
79
+ var __origReload = location.reload.bind(location);
80
+ Object.defineProperty(location, 'reload', {
81
+ configurable: false,
82
+ value: function () {
83
+ var stack = '';
84
+ try { stack = String(new Error('trace').stack || '').split('\n').slice(1, 8).join(' | '); } catch (e) {}
85
+ flog('location.reload', stack);
86
+ // Hidden page: rAF never fires (stalls the reload until refocus) and nobody sees a splash.
87
+ if (document.visibilityState === 'hidden') { __origReload(); return; }
88
+ // Replicate App.tsx's splash-before-reload so the user sees dark splash, not a white flash.
89
+ try {
90
+ var s = document.getElementById('splash');
91
+ if (s) { s.style.display = 'block'; s.style.opacity = '1'; }
92
+ } catch (e) {}
93
+ requestAnimationFrame(function () { requestAnimationFrame(function () { __origReload(); }); });
94
+ },
95
+ });
96
+ flog('reload-wrap', 'installed');
97
+ } catch (e) {
98
+ flog('reload-wrap', 'FAILED: ' + e);
99
+ }
100
+
101
+ // Lifecycle breadcrumbs — these distinguish "JS reloaded the page" from "the browser/OS
102
+ // killed and restored it" (iOS PWA eviction, Chrome tab discard, bfcache).
103
+ window.addEventListener('pagehide', function (e) { flog('pagehide', 'persisted=' + e.persisted); });
104
+ window.addEventListener('beforeunload', function () { flog('beforeunload'); });
105
+ document.addEventListener('visibilitychange', function () { flog('visibility', document.visibilityState); });
106
+ document.addEventListener('freeze', function () { flog('freeze'); });
107
+ document.addEventListener('resume', function () { flog('resume'); });
108
+ window.addEventListener('online', function () { flog('online'); });
109
+ window.addEventListener('offline', function () { flog('offline'); });
110
+ window.addEventListener('pageshow', function (e) { flog('pageshow', 'persisted=' + e.persisted); });
111
+ try {
112
+ if (navigator.serviceWorker) {
113
+ navigator.serviceWorker.addEventListener('controllerchange', function () { flog('sw-controllerchange'); });
114
+ }
115
+ } catch (e) {}
116
+
117
+ // Vite HMR client events — forensics (job 3) + reconnect-reload suppression and staleness
118
+ // check (job 5), all on one hot context.
119
+ //
120
+ // The suppression throw below is dispatched from the transport's socket 'close' handler
121
+ // without an await/catch (client.mjs), so it surfaces as a window 'unhandledrejection'.
122
+ // The error-overlay handler further down recognizes this marker and ignores it.
123
+ var VITE_SUPPRESS_MARK = '[bloby] suppressing vite reload-on-reconnect';
124
+ try {
125
+ import('/@vite/client').then(function (m) {
126
+ var hot = m.createHotContext('/__bloby_forensics.js');
127
+
128
+ // Staleness stamp: a Vite-plugin middleware (reached through the supervisor's dashboard
129
+ // proxy) serves /__bloby/fe-stamp as {stamp:N}, bumped on every frontend hot update.
130
+ // Comparing the stamp across a disconnect tells us whether anything actually changed
131
+ // while we were gone. When Vite is down the supervisor answers 503 HTML — json() rejects
132
+ // and we treat it as "not back yet", never as "changed".
133
+ var lastStamp = null;
134
+ function fetchStamp() {
135
+ return fetch('/__bloby/fe-stamp', { cache: 'no-store' })
136
+ .then(function (r) { return r.json(); })
137
+ .then(function (j) { return (j && typeof j.stamp === 'number') ? j.stamp : null; });
138
+ }
139
+ function refreshStamp(reason) {
140
+ fetchStamp().then(function (s) {
141
+ if (s !== null) lastStamp = s;
142
+ }).catch(function (err) { flog('fe-stamp', reason + ' fetch failed: ' + err); });
143
+ }
144
+ // Baseline with retry: a transient blip right at page load must not leave us without a
145
+ // baseline — the recovery poll would then adopt a possibly-already-moved stamp as truth.
146
+ (function initStamp(attempt) {
147
+ fetchStamp().then(function (s) {
148
+ if (s !== null) { lastStamp = s; return; }
149
+ throw new Error('bad payload');
150
+ }).catch(function (err) {
151
+ if (attempt >= 5) { flog('fe-stamp', 'init gave up: ' + err); return; }
152
+ setTimeout(function () { initStamp(attempt + 1); }, 2000 * (attempt + 1));
153
+ });
154
+ })(0);
155
+
156
+ // Recovery poll — the ONLY change signal after a disconnect. Vite 8 never reconnects a
157
+ // dropped socket: transport.connect() runs once at module init, and the reload we
158
+ // suppress below is what would have re-run it. So once disconnected, HMR messages and
159
+ // 'vite:ws:connect' are gone for good; we poll the stamp instead. Stamp moved → reload
160
+ // (restores both freshness and HMR). Stamp unchanged → page content is current, keep
161
+ // polling (HMR stays dead until the next agent edit bumps the stamp). A failed fetch
162
+ // (supervisor's 503 HTML while Vite is down → json() rejects) means "not back yet".
163
+ // Single instance so rapid disconnects don't stack timers; paused while hidden
164
+ // (mirrors Vite's own visibility-aware ping) and kicked on visibility/online.
165
+ var recoveryTimer = null;
166
+ function recoveryTick() {
167
+ if (document.visibilityState === 'hidden') return;
168
+ fetchStamp().then(function (s) {
169
+ if (s === null || recoveryTimer === null) return;
170
+ if (lastStamp === null) { lastStamp = s; return; } // no baseline — adopt and watch
171
+ if (s !== lastStamp) {
172
+ flog('stale-after-disconnect', 'stamp ' + lastStamp + '→' + s);
173
+ stopRecoveryPoll();
174
+ location.reload(); // goes through the forensics wrapper: logged + splash
175
+ }
176
+ }).catch(function () {});
177
+ }
178
+ function startRecoveryPoll() {
179
+ if (recoveryTimer !== null) return;
180
+ flog('recovery-poll', 'started');
181
+ recoveryTimer = setInterval(recoveryTick, 3000);
182
+ recoveryTick();
183
+ }
184
+ function stopRecoveryPoll() {
185
+ if (recoveryTimer === null) return;
186
+ clearInterval(recoveryTimer);
187
+ recoveryTimer = null;
188
+ flog('recovery-poll', 'stopped');
189
+ }
190
+ document.addEventListener('visibilitychange', function () {
191
+ if (document.visibilityState === 'visible' && recoveryTimer !== null) recoveryTick();
192
+ });
193
+ window.addEventListener('online', function () {
194
+ if (recoveryTimer !== null) recoveryTick();
195
+ });
196
+
197
+ hot.on('vite:ws:disconnect', function () {
198
+ flog('vite:ws:disconnect', 'visible=' + document.visibilityState);
199
+ // The poll must start BEFORE the suppressing throw aborts this handler.
200
+ startRecoveryPoll();
201
+ // Cancel Vite's reload-on-reconnect: client.mjs handleMessage awaits
202
+ // notifyListeners('vite:ws:disconnect') BEFORE waitForSuccessfulPing() +
203
+ // location.reload(). notifyListeners runs cbs.map((cb) => cb(data)) synchronously, so
204
+ // a SYNCHRONOUS throw here rejects that awaited promise and the reload never runs.
205
+ // (An async listener would NOT work — Promise.allSettled swallows rejections.) We
206
+ // suppress because the dev server is immortal while the tunnel/mobile connection is
207
+ // fragile: every blip would otherwise hard-reload a perfectly-current page. Net
208
+ // effect with the recovery poll above: connection blips no longer reload the page;
209
+ // we reload ONLY when edits actually happened while disconnected.
210
+ throw new Error(VITE_SUPPRESS_MARK + ' — staleness is handled by the recovery poll instead');
211
+ });
212
+ // Dead code on Vite 8 after the first disconnect (the event is emitted only inside
213
+ // transport.connect(), which never re-runs) — kept as a fast path for a future Vite
214
+ // that reconnects in place.
215
+ hot.on('vite:ws:connect', function () {
216
+ flog('vite:ws:connect');
217
+ stopRecoveryPoll();
218
+ if (lastStamp === null) { refreshStamp('connect'); return; }
219
+ fetchStamp().then(function (s) {
220
+ if (s === null) return;
221
+ if (s !== lastStamp) {
222
+ flog('stale-after-reconnect', 'stamp ' + lastStamp + '→' + s);
223
+ location.reload(); // goes through the forensics wrapper: logged + splash
224
+ }
225
+ }).catch(function (err) { flog('fe-stamp', 'connect fetch failed: ' + err); });
226
+ });
227
+ // We just received these updates over the live socket, so we're current — resync.
228
+ hot.on('vite:afterUpdate', function () { refreshStamp('after-update'); });
229
+ hot.on('vite:beforeFullReload', function (p) {
230
+ flog('vite:beforeFullReload', p && p.path ? 'path=' + p.path : '');
231
+ refreshStamp('before-full-reload');
232
+ });
233
+ hot.on('vite:invalidate', function (p) { flog('vite:invalidate', (p && p.path) || ''); });
234
+ hot.on('vite:error', function (p) { flog('vite:error', p && p.err && p.err.message || ''); });
235
+ flog('vite-hooks', 'installed');
236
+ }).catch(function (err) { flog('vite-hooks', 'import failed: ' + err); });
237
+ } catch (e) {
238
+ flog('vite-hooks', 'unavailable: ' + e);
239
+ }
240
+
241
+ /* ── 4. Shell relay ───────────────────────────────────────────────────── */
242
+ // The shell (parent) hosts the bubble + chat; this document is its same-origin iframe.
243
+ // Everything here no-ops when we ARE the top window (legacy mode / BLOBY_NO_SHELL=1).
244
+ (function () {
245
+ var framed = false;
246
+ try { framed = !!window.parent && window.parent !== window; } catch (e) {}
247
+ if (!framed) return;
248
+
249
+ function post(msg) {
250
+ try { window.parent.postMessage(msg, location.origin); } catch (e) {}
251
+ }
252
+
253
+ // a. App readiness. The workspace's main.tsx dispatches the same-window DOM Event
254
+ // 'bloby:app-ready' and sets window.__blobyAppReady after first paint. The event may have
255
+ // fired before this script ran (or a custom workspace may never dispatch it), so also
256
+ // check the flag now and poll it; give up after 20s. Send at most once.
257
+ //
258
+ // Readiness also hides the workspace's own opaque #splash (index.html, z-index 9998):
259
+ // widget.js — the only thing that ever hid it — no-ops inside frames, so without this
260
+ // every shell-mode load (including onboarding, whose iframe sits at z-index 200) stays
261
+ // black forever. Timeout backstop (~8s, matching the shell's own) keeps broken apps
262
+ // visible too. Framed-only, so legacy mode still gets widget.js's splash animation.
263
+ var readySent = false;
264
+ function hideSplash() {
265
+ try {
266
+ var s = document.getElementById('splash');
267
+ if (s) s.style.display = 'none';
268
+ } catch (e) {}
269
+ }
270
+ setTimeout(hideSplash, 8000);
271
+ function sendReady() {
272
+ if (readySent) return;
273
+ readySent = true;
274
+ hideSplash();
275
+ post({ type: 'bloby:app-ready' });
276
+ }
277
+ window.addEventListener('bloby:app-ready', sendReady);
278
+ if (window.__blobyAppReady) sendReady();
279
+ var readyPolls = 0;
280
+ var readyTimer = setInterval(function () {
281
+ if (window.__blobyAppReady) sendReady();
282
+ if (readySent || ++readyPolls >= 80) clearInterval(readyTimer); // 80 × 250ms = 20s
283
+ }, 250);
284
+
285
+ // b. Title sync — the shell mirrors the iframe's title into the real tab title.
286
+ function postTitle() {
287
+ if (document.title) post({ type: 'bloby:title', title: document.title });
288
+ }
289
+ postTitle();
290
+ try {
291
+ var titleEl = document.querySelector('title');
292
+ if (titleEl) {
293
+ new MutationObserver(postTitle).observe(titleEl, { childList: true, characterData: true, subtree: true });
294
+ }
295
+ } catch (e) {}
296
+
297
+ // c. Escape — keydown inside the iframe never reaches the shell document, so the shell's
298
+ // widget would miss its close-the-chat-panel shortcut.
299
+ window.addEventListener('keydown', function (e) {
300
+ if (e.key === 'Escape') post({ type: 'bloby:esc' });
301
+ });
302
+
303
+ // d. Onboard relay — the onboarding wizard iframe is nested INSIDE this workspace app and
304
+ // posts {type:'bloby:onboard-complete'} to ITS parent (= this window); the widget that
305
+ // must un-hide the bubble now lives in the shell. Re-post upward. Loop-safe by direction:
306
+ // only messages from below (never from the parent shell) are relayed.
307
+ window.addEventListener('message', function (e) {
308
+ if (e.source === window.parent) return;
309
+ if (e.origin === location.origin && e.data && e.data.type === 'bloby:onboard-complete') {
310
+ post(e.data);
311
+ }
312
+ });
313
+ })();
314
+
18
315
  /* ── 1. Backend-down auto-detect ──────────────────────────────────────── */
19
316
  var reloading = false;
20
317
  function pollBackend() {
@@ -118,6 +415,14 @@
118
415
  new MutationObserver(evaluate).observe(document.body, { childList: true });
119
416
  setInterval(evaluate, 1500);
120
417
  window.addEventListener('error', function () { sawError = true; evaluate(); });
121
- window.addEventListener('unhandledrejection', function () { sawError = true; evaluate(); });
418
+ window.addEventListener('unhandledrejection', function (e) {
419
+ // The reconnect-reload suppressor (job 5) throws a marker error inside Vite's HMR
420
+ // dispatch; it surfaces here on every tunnel blip. Not an app error — swallow it.
421
+ try {
422
+ var msg = e && e.reason && e.reason.message;
423
+ if (msg && String(msg).indexOf(VITE_SUPPRESS_MARK) !== -1) { e.preventDefault(); return; }
424
+ } catch (err) {}
425
+ sawError = true; evaluate();
426
+ });
122
427
  evaluate();
123
428
  })();
package/vite.config.ts CHANGED
@@ -1,8 +1,39 @@
1
- import { defineConfig } from 'vite';
1
+ import { defineConfig, type Plugin } from 'vite';
2
2
  import react from '@vitejs/plugin-react';
3
3
  import tailwindcss from '@tailwindcss/vite';
4
4
  import path from 'path';
5
5
 
6
+ // Backs workspace-guard.js's reconnect staleness check: Vite's reload-on-reconnect
7
+ // is suppressed client-side, and on reconnect the client fetches this stamp and
8
+ // reloads ONLY if frontend code actually changed while it was disconnected.
9
+ // Seeded with Date.now() (not 0) so a Vite/supervisor restart — which resets this
10
+ // module — always reads as "changed" → clients reload after restarts, which is
11
+ // exactly right (in-memory HMR state is gone).
12
+ function blobyFeStamp(): Plugin {
13
+ let stamp = Date.now();
14
+ return {
15
+ name: 'bloby-fe-stamp',
16
+ // hotUpdate (not legacy handleHotUpdate): fires on create/update/delete and
17
+ // for .html edits that trigger full-reload. It runs once per environment, so
18
+ // only count the client one.
19
+ hotUpdate() {
20
+ if (this.environment.name !== 'client') return;
21
+ stamp++;
22
+ },
23
+ configureServer(server) {
24
+ // Reached through the supervisor's catch-all proxy to Vite. connect mounts
25
+ // by prefix, so reject subpaths to keep this an exact match.
26
+ server.middlewares.use('/__bloby/fe-stamp', (req, res, next) => {
27
+ const rest = (req.url || '').split('?')[0];
28
+ if (rest !== '' && rest !== '/') return next();
29
+ res.setHeader('Content-Type', 'application/json');
30
+ res.setHeader('Cache-Control', 'no-store');
31
+ res.end(JSON.stringify({ stamp }));
32
+ });
33
+ },
34
+ };
35
+ }
36
+
6
37
  export default defineConfig({
7
38
  root: process.env.BLOBY_WORKSPACE
8
39
  ? path.join(process.env.BLOBY_WORKSPACE, 'client')
@@ -64,5 +95,5 @@ export default defineConfig({
64
95
  'use-sync-external-store/shim',
65
96
  ],
66
97
  },
67
- plugins: [react(), tailwindcss()],
98
+ plugins: [react(), tailwindcss(), blobyFeStamp()],
68
99
  });
@@ -19,6 +19,15 @@ export type ConditionResult = false | true | Record<string, string>;
19
19
 
20
20
  export const conditions: Record<string, () => Promise<ConditionResult>> = {
21
21
 
22
+ /**
23
+ * Shell mode: the workspace renders inside a same-origin iframe under the
24
+ * supervisor's shell page. BLOBY_NO_SHELL=1 is the kill switch back to
25
+ * legacy top-level serving — the iframe note must disappear with it.
26
+ */
27
+ 'workspace-iframe-shell': async () => {
28
+ return process.env.BLOBY_NO_SHELL !== '1';
29
+ },
30
+
22
31
  /**
23
32
  * Official Workspace Lock is active.
24
33
  * Calls GET http://localhost:{backendPort}/api/lock/status
@@ -1,4 +1,12 @@
1
1
  [
2
+ {
3
+ "id": "workspace-iframe-shell",
4
+ "description": "Shell mode active (BLOBY_NO_SHELL != 1): workspace renders inside a same-origin iframe under Bloby's shell. Priority 0 is load-bearing — must run before the workspace-lock replace fragments consume the workspace-security marker this append anchors on.",
5
+ "target": "workspace-security",
6
+ "action": "append",
7
+ "priority": 0,
8
+ "content": "## Iframe Shell\n\nThe dashboard your human sees renders inside a same-origin iframe under Bloby's shell page — the workspace app is never the top-level browsing context. This is transparent for normal code, with a few hard rules:\n\n- **Redirect-based auth (Google/GitHub OAuth, Stripe Checkout, etc.):** never navigate the current window to a third-party page — providers refuse to render inside iframes. Open a popup (`window.open`) or put `target=\"_top\"` on the redirect link. Prefer popup-based SDK flows when the provider offers one.\n- **`window.top` and `window.parent` belong to Bloby's shell.** Never navigate, reload, or postMessage them from workspace code.\n- **Browser permissions work normally** — camera, microphone, geolocation, clipboard, fullscreen, autoplay, and display-capture are all granted to the iframe by the shell.\n- **The browser tab title syncs automatically** from `document.title`. Nothing to do."
9
+ },
2
10
  {
3
11
  "id": "workspace-lock-official",
4
12
  "description": "Official marketplace Workspace Lock is active (PIN or password)",
@@ -8,7 +8,7 @@
8
8
  // build errors, never a stale cached shell. Cache is a pure offline fallback. (Mirror of the
9
9
  // supervisor's SW_JS in supervisor/index.ts — keep in sync.)
10
10
 
11
- const CACHE = 'bloby-v17';
11
+ const CACHE = 'bloby-v24';
12
12
 
13
13
  // Precache the HTML shell on install so the cache is never empty.
14
14
  // Without this, the first navigation isn't intercepted (SW wasn't
@@ -28,80 +28,27 @@ export default function App() {
28
28
  const [showOnboard, setShowOnboard] = useState(false);
29
29
  const [userName, setUserName] = useState('');
30
30
  const [botName, setBotName] = useState('Bloby');
31
- const [rebuildState, setRebuildState] = useState<'idle' | 'rebuilding' | 'error'>('idle');
32
- const [buildError, setBuildError] = useState('');
33
31
 
34
- // ── Seamless reload: splash screen + freeze-thaw ──────────────────
35
- // Prevents the "white flash" and "delayed reload" jank that plagues PWAs.
36
- //
37
- // How it works:
38
- // 1. Any location.reload() shows the HTML splash BEFORE reloading
39
- // so the user sees: app → splash → splash app (no white gap).
40
- // 2. When returning from background (> 30s hidden), the splash shows
41
- // IMMEDIATELY — before Vite's delayed reconnect-reload can fire.
42
- // If no reload comes within 3s, the splash fades away.
43
- // 3. Vite full-reloads trigger the splash too (same mechanism).
32
+ // ── Seamless reload: splash before full-reloads ───────────────────
33
+ // Vite's reload-on-reconnect is suppressed by the supervisor-injected
34
+ // workspace-guard (which also owns location.reload wrapping), so the
35
+ // only reloads left are real ones: Vite full-reloads after frontend
36
+ // changes. Show the HTML splash before those so the user sees
37
+ // app → splash → app instead of a white flash.
44
38
  useEffect(() => {
45
39
  const splash = document.getElementById('splash');
46
- let hiddenAt = 0;
47
40
 
48
- // Show the dark background (used before reloads and on resume)
41
+ // Show the dark background before the reload unloads the page
49
42
  function showSplash() {
50
43
  if (!splash) return;
51
44
  splash.style.display = 'block';
52
45
  splash.style.opacity = '1';
53
46
  }
54
47
 
55
- // Hide the splash screen with a fade
56
- function hideSplash() {
57
- if (!splash || splash.style.display === 'none') return;
58
- splash.style.opacity = '0';
59
- splash.addEventListener('transitionend', () => { splash.style.display = 'none'; }, { once: true });
60
- }
61
-
62
- // Wrap location.reload: show splash, wait for paint, then reload.
63
- // This ensures the dark splash is visible during the brief unload→load gap.
64
- try {
65
- const origReload = location.reload.bind(location);
66
- Object.defineProperty(location, 'reload', {
67
- configurable: true,
68
- value: () => {
69
- showSplash();
70
- requestAnimationFrame(() => requestAnimationFrame(() => origReload()));
71
- },
72
- });
73
- } catch {
74
- // location.reload is non-configurable in some browsers — skip override
75
- }
76
-
77
48
  // Vite HMR: show splash before a full-reload
78
49
  if (import.meta.hot) {
79
50
  import.meta.hot.on('vite:beforeFullReload', () => showSplash());
80
51
  }
81
-
82
- // Freeze-thaw: when returning from background after > 30s,
83
- // show splash proactively so the user never sees the "working app
84
- // suddenly yank away" pattern. If Vite decides to full-reload,
85
- // the splash is already visible. If not, we fade it away after 3s.
86
- let thawTimer: ReturnType<typeof setTimeout>;
87
- const BACKGROUND_THRESHOLD = 30_000; // 30 seconds
88
-
89
- const onVisChange = () => {
90
- if (document.visibilityState === 'hidden') {
91
- hiddenAt = Date.now();
92
- } else if (hiddenAt && Date.now() - hiddenAt > BACKGROUND_THRESHOLD) {
93
- showSplash();
94
- // Give Vite 3s to trigger a reload; if it doesn't, hide splash
95
- clearTimeout(thawTimer);
96
- thawTimer = setTimeout(hideSplash, 3_000);
97
- }
98
- };
99
- document.addEventListener('visibilitychange', onVisChange);
100
-
101
- return () => {
102
- document.removeEventListener('visibilitychange', onVisChange);
103
- clearTimeout(thawTimer);
104
- };
105
52
  }, []);
106
53
 
107
54
  useEffect(() => {
@@ -119,39 +66,18 @@ export default function App() {
119
66
  .catch(() => {});
120
67
  }, []);
121
68
 
122
- // Listen for rebuild events from Bloby iframe via postMessage
69
+ // Listen for events from Bloby iframes via postMessage.
70
+ // 'bloby:hmr-update' is intentionally ignored: Vite HMR handles hot
71
+ // updates natively, and a manual location.reload() here would kill the
72
+ // chat iframe's WebSocket connection for nothing.
123
73
  useEffect(() => {
124
- let safetyTimer: ReturnType<typeof setTimeout>;
125
74
  const handler = (e: MessageEvent) => {
126
- if (e.data?.type === 'bloby:rebuilding') {
127
- setRebuildState('rebuilding');
128
- setBuildError('');
129
- // Safety: auto-reload after 60s in case app:rebuilt message is lost
130
- clearTimeout(safetyTimer);
131
- safetyTimer = setTimeout(() => location.reload(), 60_000);
132
- } else if (e.data?.type === 'bloby:rebuilt') {
133
- clearTimeout(safetyTimer);
134
- setRebuildState('idle');
135
- location.reload();
136
- } else if (e.data?.type === 'bloby:build-error') {
137
- clearTimeout(safetyTimer);
138
- setRebuildState('error');
139
- setBuildError(e.data.error || 'Build failed');
140
- setTimeout(() => setRebuildState('idle'), 5000);
141
- } else if (e.data?.type === 'bloby:onboard-complete') {
75
+ if (e.data?.type === 'bloby:onboard-complete') {
142
76
  setShowOnboard(false);
143
- } else if (e.data?.type === 'bloby:hmr-update') {
144
- // Vite HMR handles hot updates natively — no manual reload needed.
145
- // Manual location.reload() here was causing unnecessary full-page refreshes
146
- // that killed the chat iframe's WebSocket connection.
147
-
148
77
  }
149
78
  };
150
79
  window.addEventListener('message', handler);
151
- return () => {
152
- window.removeEventListener('message', handler);
153
- clearTimeout(safetyTimer);
154
- };
80
+ return () => window.removeEventListener('message', handler);
155
81
  }, []);
156
82
 
157
83
  return (
@@ -172,22 +98,6 @@ export default function App() {
172
98
  style={{ position: 'fixed', inset: 0, width: '100vw', height: '100dvh', border: 'none', zIndex: 200 }}
173
99
  />
174
100
  )}
175
-
176
- {rebuildState !== 'idle' && (
177
- <div className="fixed inset-0 z-[49] flex flex-col items-center justify-center bg-background/90">
178
- <video
179
- src="/bloby_tilts.webm"
180
- autoPlay
181
- loop
182
- muted
183
- playsInline
184
- className="h-24 w-24 rounded-full object-cover"
185
- />
186
- <p className="mt-4 text-sm text-muted-foreground">
187
- {rebuildState === 'rebuilding' ? 'Rebuilding app...' : buildError}
188
- </p>
189
- </div>
190
- )}
191
101
  </>
192
102
  );
193
103
  }