import { useState, useCallback, useEffect, useRef } from 'react'; import { STORAGE } from './lib/storage-keys'; import { isAuthenticated, clearToken, AUTH_EXPIRED_EVENT } from './lib/api'; import { usePushNotifications } from './hooks/usePushNotifications'; import { LoginView } from './components/LoginView'; import { SessionsView } from './components/SessionsView'; import { ChatView } from './components/ChatView'; import { SettingsView } from './components/SettingsView'; import { NewChatView } from './components/NewChatView'; import { OfflineView } from './components/OfflineView'; import { LoadingAnimation } from './components/ui/LoadingAnimation'; interface BeforeInstallPromptEvent extends Event { prompt(): Promise; userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>; } type View = | { name: 'sessions' } | { name: 'newchat'; cwd: string } | { name: 'chat'; sessionId?: string; cwd?: string; initialPrompt?: string; adapter?: string } | { name: 'settings' }; function loadView(): View { try { const saved = sessionStorage.getItem('currentView'); if (saved) { const parsed = JSON.parse(saved); if (parsed.name === 'chat' && parsed.sessionId) return parsed; if (parsed.name === 'newchat' && parsed.cwd) return parsed; } } catch {} return { name: 'sessions' }; } function persistView(view: View) { sessionStorage.setItem('currentView', JSON.stringify(view)); } function navigateTo(view: View, replace = false) { persistView(view); let url = '/'; if (view.name === 'chat' && view.sessionId) { url = `/?view=chat&session=${view.sessionId}`; if (view.adapter) url += `&adapter=${view.adapter}`; } else if (view.name === 'settings') { url = '/?view=settings'; } if (replace) { window.history.replaceState({ view }, '', url); } else { window.history.pushState({ view }, '', url); } } export function App() { const [authed, setAuthed] = useState(isAuthenticated()); const [view, setView] = useState(loadView); const [serverOnline, setServerOnline] = useState(null); const [deviceOnline, setDeviceOnline] = useState(navigator.onLine); const consecutiveFails = useRef(0); const initialized = useRef(false); const { supported: pushSupported, subscribed: pushSubscribed, subscribe: pushSubscribe } = usePushNotifications(); const [installPrompt, setInstallPrompt] = useState(null); const [installDismissed, setInstallDismissed] = useState( () => localStorage.getItem(STORAGE.INSTALL_DISMISSED) === 'true' ); const dismissInstall = useCallback(() => { setInstallPrompt(null); setInstallDismissed(true); localStorage.setItem(STORAGE.INSTALL_DISMISSED, 'true'); }, []); const [swUpdateAvailable, setSwUpdateAvailable] = useState(false); const handleInstall = useCallback(async () => { if (!installPrompt) return; installPrompt.prompt(); const result = await installPrompt.userChoice; if (result.outcome === 'accepted') dismissInstall(); }, [installPrompt, dismissInstall]); const handleLogin = useCallback(() => setAuthed(true), []); const handleLogout = useCallback(() => { clearToken(); sessionStorage.removeItem('currentView'); setAuthed(false); }, []); // Server rejected our token (expired/invalid) — drop back to login useEffect(() => { const handler = () => handleLogout(); window.addEventListener(AUTH_EXPIRED_EVENT, handler); return () => window.removeEventListener(AUTH_EXPIRED_EVENT, handler); }, [handleLogout]); const openChat = useCallback((sessionId?: string, cwd?: string, adapter?: string) => { if (!sessionId && cwd) { const v: View = { name: 'newchat', cwd }; navigateTo(v); setView(v); } else { const v: View = { name: 'chat', sessionId, cwd, adapter }; navigateTo(v); setView(v); } }, []); // PWA: Android install prompt useEffect(() => { const handler = (e: Event) => { e.preventDefault(); setInstallPrompt(e as BeforeInstallPromptEvent); }; window.addEventListener('beforeinstallprompt', handler); window.addEventListener('appinstalled', dismissInstall); return () => { window.removeEventListener('beforeinstallprompt', handler); window.removeEventListener('appinstalled', dismissInstall); }; }, [dismissInstall]); // PWA: Service worker update notification // Only show banner when replacing an existing controller (real update), // not on first registration when clients.claim() fires controllerchange // on a previously uncontrolled page. useEffect(() => { if (!navigator.serviceWorker) return; let hadController = !!navigator.serviceWorker.controller; const handleControllerChange = () => { if (hadController) setSwUpdateAvailable(true); hadController = true; }; navigator.serviceWorker.addEventListener('controllerchange', handleControllerChange); return () => navigator.serviceWorker.removeEventListener('controllerchange', handleControllerChange); }, []); // PWA: actively poll for service worker updates. // iOS standalone PWAs don't reliably perform the browser's normal // "check sw.js for byte changes on navigation" — without this, an // installed app can stay on a stale bundle indefinitely. Forcing // registration.update() on focus/interval triggers that check. useEffect(() => { if (!navigator.serviceWorker) return; const checkForUpdate = () => { navigator.serviceWorker.getRegistration().then(reg => reg?.update()).catch(() => {}); }; const handleVisibility = () => { if (document.visibilityState === 'visible') checkForUpdate(); }; checkForUpdate(); document.addEventListener('visibilitychange', handleVisibility); const interval = setInterval(checkForUpdate, 60 * 60 * 1000); return () => { document.removeEventListener('visibilitychange', handleVisibility); clearInterval(interval); }; }, []); // PWA iOS: Sync --app-height CSS variable to the true viewport height. // WebKit bug: after a keyboard open/close cycle, viewport-fit=cover's layout // extension is lost, causing 100dvh to shrink permanently. We track the max // observed innerHeight and lock it as the app height. useEffect(() => { let maxHeight = window.innerHeight; const sync = () => { const h = window.innerHeight; if (h > maxHeight) maxHeight = h; document.documentElement.style.setProperty('--app-height', `${maxHeight}px`); }; sync(); window.visualViewport?.addEventListener('resize', sync); window.addEventListener('resize', sync); return () => { window.visualViewport?.removeEventListener('resize', sync); window.removeEventListener('resize', sync); }; }, []); // PWA: Auto-prompt notification permission on first login in standalone mode useEffect(() => { if (!authed || !pushSupported || pushSubscribed) return; if (localStorage.getItem(STORAGE.PUSH_PROMPTED)) return; const isStandalone = window.matchMedia('(display-mode: standalone)').matches || (navigator as any).standalone === true; if (!isStandalone) return; if (typeof Notification !== 'undefined' && Notification.permission === 'denied') return; // Small delay so the UI has time to settle after login const timer = setTimeout(() => { localStorage.setItem(STORAGE.PUSH_PROMPTED, '1'); pushSubscribe().catch(() => {}); }, 1500); return () => clearTimeout(timer); }, [authed, pushSupported, pushSubscribed, pushSubscribe]); // PWA: Clear app badge on focus useEffect(() => { const handleVisibility = () => { if (document.visibilityState === 'visible') { navigator.clearAppBadge?.(); } }; document.addEventListener('visibilitychange', handleVisibility); return () => document.removeEventListener('visibilitychange', handleVisibility); }, []); // Handle browser back/forward navigation useEffect(() => { const handlePopState = (event: PopStateEvent) => { if (event.state?.view) { setView(event.state.view); persistView(event.state.view); } else { setView({ name: 'sessions' }); persistView({ name: 'sessions' }); } }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); }, []); // Set initial history state (replaceState, not pushState, to avoid double entry) useEffect(() => { window.history.replaceState({ view }, '', window.location.pathname + window.location.search); }, []); const backToSessions = useCallback(() => { const v: View = { name: 'sessions' }; navigateTo(v); setView(v); }, []); // Layer 1: Device network (instant) useEffect(() => { const goOnline = () => setDeviceOnline(true); const goOffline = () => setDeviceOnline(false); window.addEventListener('online', goOnline); window.addEventListener('offline', goOffline); return () => { window.removeEventListener('online', goOnline); window.removeEventListener('offline', goOffline); }; }, []); // Layer 2: Server health check const checkHealth = useCallback(() => { fetch('/health', { signal: AbortSignal.timeout(5000) }) .then(res => { if (res.ok) { consecutiveFails.current = 0; setServerOnline(true); } else { consecutiveFails.current++; if (!initialized.current || consecutiveFails.current >= 2) setServerOnline(false); } }) .catch(() => { consecutiveFails.current++; if (!initialized.current || consecutiveFails.current >= 2) setServerOnline(false); }); }, []); useEffect(() => { checkHealth(); initialized.current = true; const interval = setInterval(checkHealth, 15000); return () => clearInterval(interval); }, [checkHealth]); // Handle OPEN_SESSION messages from service worker (push notification clicks) useEffect(() => { const handler = (event: MessageEvent) => { if (event.data?.type === 'OPEN_SESSION' && event.data.sessionId) { openChat(event.data.sessionId); } }; navigator.serviceWorker?.addEventListener('message', handler); return () => navigator.serviceWorker?.removeEventListener('message', handler); }, [openChat]); // Handle URL parameters (?session= from notification click, ?action=newchat from PWA shortcut) const urlParamsHandled = useRef(false); useEffect(() => { if (urlParamsHandled.current || !authed) return; const params = new URLSearchParams(window.location.search); const sessionId = params.get('session'); const action = params.get('action'); if (sessionId) { urlParamsHandled.current = true; const adapter = params.get('adapter'); openChat(sessionId, undefined, adapter || undefined); window.history.replaceState({}, '', '/'); } else if (action === 'newchat') { urlParamsHandled.current = true; window.history.replaceState({}, '', '/'); } }, [authed, openChat]); const startChat = useCallback((options: { adapter: string; model: string; permissionMode: string; effort: string; prompt: string }) => { // NewChatView.handleSend already saved adapter prefs via saveAdapterPrefs — just set the active adapter localStorage.setItem(STORAGE.ADAPTER, options.adapter); // Navigate to chat view with cwd — ChatView will pick up globals and send the prompt const chatCwd = view.name === 'newchat' ? view.cwd : undefined; const v: View = { name: 'chat', cwd: chatCwd, initialPrompt: options.prompt, adapter: options.adapter }; navigateTo(v, true); // replace newchat → chat so back goes to session list setView(v); }, [view]); const isOffline = !deviceOnline || serverOnline === false; const updateBanner = swUpdateAvailable && (
New version available
); // Splash screen while first health check is pending if (serverOnline === null) { return ( <>
{updateBanner} ); } // Offline screen if (isOffline) { return <>{updateBanner}; } if (!authed) { return <>{updateBanner}; } if (view.name === 'newchat') { return ( <> {updateBanner} ); } if (view.name === 'settings') { return <> setView({ name: 'sessions' })} />{updateBanner}; } if (view.name === 'chat') { return ( <> {updateBanner} ); } return ( <> setView({ name: 'settings' })} installPrompt={!installDismissed ? installPrompt : null} onInstall={handleInstall} onDismissInstall={dismissInstall} /> {updateBanner} ); }