import React, { useEffect, useRef, useState } from 'react'; import ReactDOM from 'react-dom/client'; import { ArrowLeft, EllipsisVertical, WandSparkles, Download, X, Share, Bell, BellRing, BellOff, MessageSquarePlus } from 'lucide-react'; import { WsClient } from './src/lib/ws-client'; import { useBlobyChat } from './src/hooks/useBlobyChat'; import OnboardWizard from './OnboardWizard'; import LoginScreen from './src/components/LoginScreen'; import { getAuthToken, setAuthToken, clearAuthToken, authFetch, onAuthFailure } from './src/lib/auth'; import MessageList from './src/components/Chat/MessageList'; import InputBar from './src/components/Chat/InputBar'; import HeadphonesAnimation from './src/components/Chat/HeadphonesAnimation'; import './src/styles/globals.css'; function BlobyApp() { const clientRef = useRef(null); const [connected, setConnected] = useState(false); // True between the supervisor's 'agent:updating' broadcast (self-update launching) and the // post-restart reconnect — the header shows "Updating…" instead of a mystery "Offline". const [updating, setUpdating] = useState(false); // Don't flash the offline input-block during the initial connect (~1s) on every open. const [connectGraceOver, setConnectGraceOver] = useState(false); const [botName, setBotName] = useState('Bloby'); const [whisperEnabled, setWhisperEnabled] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const [confirmReset, setConfirmReset] = useState(false); const [showWizard, setShowWizard] = useState(false); const [reloadTrigger, setReloadTrigger] = useState(0); const menuRef = useRef(null); const wasConnected = useRef(false); // Backend health const [backendHealthy, setBackendHealthy] = useState(true); // Wallet balances (per network) const [walletTempo, setWalletTempo] = useState(null); const [walletBase, setWalletBase] = useState(null); const [walletOpen, setWalletOpen] = useState(false); // Recording state (for header animation) const [chatRecording, setChatRecording] = useState(false); // Push notifications const [pushState, setPushState] = useState<'loading' | 'unsupported' | 'denied' | 'subscribed' | 'unsubscribed'>('loading'); function urlBase64ToUint8Array(base64String: string) { const padding = '='.repeat((4 - (base64String.length % 4)) % 4); const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); const raw = atob(base64); const arr = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i); return arr; } async function subscribePush() { try { const permission = await Notification.requestPermission(); if (permission !== 'granted') { setPushState('denied'); return; } const reg = await navigator.serviceWorker.ready; const res = await fetch('/api/push/vapid-public-key'); const { publicKey } = await res.json(); const subscription = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(publicKey), }); const sub = subscription.toJSON(); const client = clientRef.current; if (client) { client.send('push:subscribe', { endpoint: sub.endpoint, keys: sub.keys }); } setPushState('subscribed'); } catch (err) { console.error('[push] Subscribe failed:', err); } } async function unsubscribePush() { try { const reg = await navigator.serviceWorker.ready; const subscription = await reg.pushManager.getSubscription(); if (subscription) { const endpoint = subscription.endpoint; await subscription.unsubscribe(); const client = clientRef.current; if (client) { client.send('push:unsubscribe', { endpoint }); } } setPushState('unsubscribed'); } catch (err) { console.error('[push] Unsubscribe failed:', err); } } // Install App (PWA) const [showIosModal, setShowIosModal] = useState(false); const isIos = /iPad|iPhone|iPod/.test(navigator.userAgent); const isStandalone = window.matchMedia('(display-mode: standalone)').matches || (navigator as any).standalone; const isMobile = /Android|iPhone|iPad|iPod|webOS|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); const isInIframe = window.self !== window.top; // Listen for install result from parent (when in iframe) useEffect(() => { const handler = (e: MessageEvent) => { if (e.data?.type === 'bloby:show-ios-install') { setShowIosModal(true); } }; window.addEventListener('message', handler); return () => window.removeEventListener('message', handler); }, []); // Auth state const [authChecked, setAuthChecked] = useState(false); const [authRequired, setAuthRequired] = useState(false); const [authenticated, setAuthenticated] = useState(false); const [totpEnabled, setTotpEnabled] = useState(false); // Check auth on mount useEffect(() => { (async () => { try { const res = await fetch('/api/onboard/status'); const data = await res.json(); if (data.totpEnabled) setTotpEnabled(true); if (!data.portalConfigured) { // No password set — skip auth entirely setAuthRequired(false); setAuthenticated(true); setAuthChecked(true); return; } setAuthRequired(true); // Check if we have a valid token in localStorage const token = getAuthToken(); if (token) { const vRes = await fetch(`/api/portal/validate-token?token=${encodeURIComponent(token)}`); const vData = await vRes.json(); if (vData.valid) { setAuthenticated(true); setAuthChecked(true); return; } clearAuthToken(); } setAuthenticated(false); setAuthChecked(true); } catch { // Worker not ready — skip auth, let it retry later setAuthenticated(true); setAuthChecked(true); } })(); }, []); const handleLogin = (token: string) => { setAuthToken(token); setAuthenticated(true); }; // Handle mid-session token expiry (authFetch gets 401) useEffect(() => { onAuthFailure(() => { setAuthenticated(false); setAuthRequired(true); }); }, []); // Backend health check useEffect(() => { if (!authenticated) return; const check = () => { fetch('/app/api/health') .then((r) => setBackendHealthy(r.ok)) .catch(() => setBackendHealthy(false)); }; check(); const id = setInterval(check, 10_000); return () => clearInterval(id); }, [authenticated]); // Check push state on mount useEffect(() => { if (!authenticated) return; (async () => { if (!('PushManager' in window) || !('serviceWorker' in navigator)) { setPushState('unsupported'); return; } if (Notification.permission === 'denied') { setPushState('denied'); return; } try { const reg = await navigator.serviceWorker.ready; const subscription = await reg.pushManager.getSubscription(); if (subscription) { const res = await authFetch(`/api/push/status?endpoint=${encodeURIComponent(subscription.endpoint)}`); const data = await res.json(); setPushState(data.subscribed ? 'subscribed' : 'unsubscribed'); } else { setPushState('unsubscribed'); } } catch { setPushState('unsupported'); } })(); }, [authenticated]); // Connect WebSocket only when authenticated useEffect(() => { if (!authenticated) return; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const host = location.host; const client = new WsClient(`${proto}//${host}/bloby/ws`, getAuthToken); clientRef.current = client; // Version handshake — this chat may be running inside the immortal shell, which (by // design) survives self-updates. After a reconnect, ask the only question that matters: // "is the bundle I'm running the one the server currently serves?" — by comparing THIS // module's hashed filename (import.meta.url) against the entry referenced by the served // /bloby/ HTML. NOT a baked version string: that goes stale whenever a release is // published without rebuilding dist-bloby, which made every fresh tab/PWA launch reload // once (0.69.3-baked bundle vs 0.70.x server, throttle keyed to per-tab sessionStorage). const checkPlatformVersion = () => { let mine = ''; try { mine = new URL(import.meta.url).pathname.split('/').pop() || ''; } catch { return; } if (!mine.startsWith('bloby-')) return; // dev server / unexpected layout — never risk a loop fetch('/bloby/', { cache: 'no-store' }) .then((r) => (r.ok ? r.text() : '')) .then((html) => { const served = html.match(/assets\/(bloby-[A-Za-z0-9_-]+\.js)/)?.[1]; if (!served || served === mine) return; try { // Throttle: one auto-reload per 5 min per tab — if something upstream keeps // serving a stale bundle, never loop. const last = Number(sessionStorage.getItem('bloby_version_reload') || 0); if (Date.now() - last < 5 * 60_000) return; sessionStorage.setItem('bloby_version_reload', String(Date.now())); } catch {} // Ask the shell (top window) to reload everything; fall back to reloading just // this chat iframe if the surrounding widget predates the handler. if (window.parent !== window) { window.parent.postMessage({ type: 'bloby:version-changed' }, location.origin); setTimeout(() => location.reload(), 1500); } else { location.reload(); } }) .catch(() => {}); }; const unsub = client.onStatus((isConnected) => { setConnected(isConnected); // A (re)connect means the supervisor is back — the update (if any) is over. if (isConnected) { setUpdating(false); checkPlatformVersion(); } // On reconnect, trigger a reload from DB to catch missed messages if (isConnected && wasConnected.current) { setReloadTrigger((n) => n + 1); } wasConnected.current = isConnected; }); // Self-update launching: the supervisor broadcasts this right before the npm install + // process restart, so the upcoming disconnect reads as "Updating…" instead of "Offline" // (and input is blocked — the agent can't receive messages mid-update). const unsubUpdating = client.on('agent:updating', () => setUpdating(true)); // Forward rebuild/HMR events to parent (dashboard) via postMessage const unsubRebuilding = client.on('app:rebuilding', () => { window.parent?.postMessage({ type: 'bloby:rebuilding' }, '*'); }); const unsubRebuilt = client.on('app:rebuilt', () => { window.parent?.postMessage({ type: 'bloby:rebuilt' }, '*'); }); const unsubBuildError = client.on('app:build-error', (data: { error: string }) => { window.parent?.postMessage({ type: 'bloby:build-error', error: data.error }, '*'); }); const unsubHmr = client.on('app:hmr-update', () => { window.parent?.postMessage({ type: 'bloby:hmr-update' }, '*'); }); // Notify parent when agent finishes a response or a cross-device message arrives const unsubBotMsg = client.on('bot:response', () => { window.parent?.postMessage({ type: 'bloby:new-message' }, '*'); }); const unsubSyncMsg = client.on('chat:sync', () => { window.parent?.postMessage({ type: 'bloby:new-message' }, '*'); }); client.connect(); return () => { unsub(); unsubUpdating(); unsubRebuilding(); unsubRebuilt(); unsubBuildError(); unsubHmr(); unsubBotMsg(); unsubSyncMsg(); client.disconnect(); }; }, [authenticated]); // Updating-state safety valve: a normal self-update reconnects within ~1-2 minutes. If the // update fails and the daemon never comes back, stop claiming "Updating…" — fall back to the // honest "Offline" so the user isn't told to wait forever. useEffect(() => { if (!updating) return; const t = setTimeout(() => setUpdating(false), 4 * 60_000); return () => clearTimeout(t); }, [updating]); // Grace window so the brief pre-connect moment on open doesn't flash the offline block. useEffect(() => { if (!authenticated) return; const t = setTimeout(() => setConnectGraceOver(true), 3000); return () => clearTimeout(t); }, [authenticated]); // Try to load settings (will work when worker is up, fail silently when down) useEffect(() => { if (!authenticated) return; authFetch('/api/settings') .then((r) => r.json()) .then((s) => { if (s.agent_name) setBotName(s.agent_name); if (s.whisper_enabled === 'true') setWhisperEnabled(true); }) .catch(() => {}); // Fetch wallet balances (Tempo + Base) authFetch('/api/wallet/balance') .then((r) => r.json()) .then((w) => { if (!w.address) return; setWalletTempo(w.tempo ?? '0.00'); setWalletBase(w.base ?? '0.00'); }) .catch(() => {}); }, [authenticated]); // Close menu on outside click useEffect(() => { if (!menuOpen) return; const handler = (e: MouseEvent) => { if (menuRef.current && !menuRef.current.contains(e.target as Node)) { setMenuOpen(false); setConfirmReset(false); } }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); }, [menuOpen]); const { messages, streaming, streamBuffer, tools, hasMore, loadOlder, sendMessage, addPendingAudio, stopStreaming, clearContext } = useBlobyChat(clientRef.current, reloadTrigger, authenticated); // Block input while the agent can't actually receive: mid-update, or disconnected (after the // initial connect grace so opening the chat doesn't flash the block for a moment). const chatBlocked = updating || (!connected && connectGraceOver); // Handle voice recordings sent from the widget bubble (long-press mic) useEffect(() => { const handler = async (e: MessageEvent) => { if (e.data?.type !== 'bloby:voice-record') return; if (chatBlocked) return; // bubble long-press mic bypasses the InputBar — block it too const { audio, transcript } = e.data as { audio?: string; transcript?: string }; if (audio && whisperEnabled) { // Whisper path: transcribe via WebSocket, then send message const client = clientRef.current; if (!client?.connected) return; try { const data = await new Promise<{ transcript?: string }>((resolve, reject) => { const unsub = client.on('whisper:result', (d: { transcript?: string }) => { unsub(); clearTimeout(timer); resolve(d); }); const timer = setTimeout(() => { unsub(); reject(new Error('Timeout')); }, 30000); client.send('whisper:transcribe', { audio }); }); if (data.transcript?.trim()) { sendMessage(data.transcript.trim(), undefined, `data:audio/webm;base64,${audio}`); } } catch (err) { console.error('[BlobyApp] widget voice transcription error:', err); } } else if (transcript?.trim()) { // Web Speech path: send transcript directly sendMessage(transcript.trim()); } }; window.addEventListener('message', handler); return () => window.removeEventListener('message', handler); }, [sendMessage, whisperEnabled, chatBlocked]); // Auth gate: show spinner while checking, login screen if needed if (!authChecked) { return (
); } if (authRequired && !authenticated) { return ; } return (
{/* Header */}
{/* Back */} {/* Mascot / headphones animation (nudged up 5px so it sits visually centered with the name) */}
{/* Name + status line */}
{botName}
{updating ? 'Updating…' : connected ? 'Online' : 'Offline'} {/* Workspace indicator — uncomment when needed */} {/* Workspace */}
{/* Wallet */} {(walletTempo !== null || walletBase !== null) && (
setWalletOpen(true)} onMouseLeave={() => setWalletOpen(false)} >
My Wallet: ${(Number(walletTempo ?? 0) + Number(walletBase ?? 0)).toFixed(2)} {walletOpen && (
Balances
USDC ${Number(walletTempo ?? 0).toFixed(2)}
on Tempo
USDC ${Number(walletBase ?? 0).toFixed(2)}
on Base
)}
)} {/* Right actions */}
{pushState !== 'loading' && pushState !== 'unsupported' && ( )}
{menuOpen && (
{/* New conversation — resets the live agent session so heavy/long chats don't hit the context wall. Non-destructive: past messages stay saved under the old conversation; this just starts a fresh one. */} {!confirmReset ? ( ) : (

Start fresh? This clears the agent's context — your past messages stay saved.

)} {isMobile && !isStandalone && ( )}
)}
{/* Chat body */}
{ if (!chatBlocked) sendMessage(msg, attachments, audioData); }} onStop={stopStreaming} streaming={streaming} whisperEnabled={whisperEnabled} onTranscribe={(audio) => { return new Promise((resolve, reject) => { const client = clientRef.current; if (!client?.connected) { reject(new Error('WebSocket not connected')); return; } const unsub = client.on('whisper:result', (data) => { unsub(); clearTimeout(timer); resolve(data); }); const timer = setTimeout(() => { unsub(); reject(new Error('Transcription timeout')); }, 30000); client.send('whisper:transcribe', { audio }); }); }} onAudioReady={addPendingAudio} onRecordingChange={setChatRecording} /> {/* Input block — the agent can't receive messages mid-update / while disconnected. Overlay (not prop-threading through InputBar) so drafts/recordings underneath are preserved and usable the moment the agent is back. */} {chatBlocked && (
{updating ? 'Morphy is Updating…' : 'Morphy is Offline'}
)}
{/* Setup Wizard overlay */} {showWizard && ( { return new Promise((resolve, reject) => { const client = clientRef.current; if (!client?.connected) { reject(new Error('WebSocket not connected')); return; } const unsub = client.on('settings:saved', (data) => { unsub(); clearTimeout(timer); resolve(data); }); const unsubErr = client.on('settings:save-error', (data) => { unsubErr(); clearTimeout(timer); reject(new Error(data.error || 'Save failed')); }); const timer = setTimeout(() => { unsub(); unsubErr(); reject(new Error('Save timeout')); }, 10000); client.send('settings:save', payload); }); }} onTunnelSwitch={(newMode) => { return new Promise((resolve, reject) => { const client = clientRef.current; if (!client?.connected) { reject(new Error('Not connected')); return; } const unsub = client.on('tunnel:switched', (data) => { unsub(); unsubErr(); clearTimeout(t); resolve(data); }); const unsubErr = client.on('tunnel:switch-error', (data) => { unsub(); unsubErr(); clearTimeout(t); reject(new Error(data.error)); }); const t = setTimeout(() => { unsub(); unsubErr(); reject(new Error('Timeout')); }, 30000); client.send('tunnel:switch', { mode: newMode }); }); }} onComplete={() => { setShowWizard(false); // Reload settings (bot name, whisper, etc.) authFetch('/api/settings') .then((r) => r.json()) .then((s) => { if (s.agent_name) setBotName(s.agent_name); setWhisperEnabled(s.whisper_enabled === 'true'); }) .catch(() => {}); // Notify dashboard so it can refresh window.parent?.postMessage({ type: 'bloby:onboard-complete' }, '*'); }} /> )} {/* iOS Install Instructions Modal */} {showIosModal && (
setShowIosModal(false)} />

Install App

Add Bloby to your home screen for a full-screen app experience.

{isIos ? (
1

Tap the Share button

The icon at the bottom of Safari

2

Scroll down and tap

"Add to Home Screen"

3

Tap "Add"

Bloby will appear on your home screen

) : (
1

Open browser menu

Tap the three-dot menu in your browser

2

Tap "Install app" or "Add to Home screen"

Bloby will be installed as a standalone app

)}
)}
); } ReactDOM.createRoot(document.getElementById('root')!).render( , );