299649738e
- Add safe-top to all full-screen overlays (PlanMode, DiffViewer, ChatView PlanViewer) - Add safe-top to SessionsView drill-down header + swipe-back via pushState - Move safe-top to ChatView outer container (persists when header hides) - Add skipWaiting + clients.claim for immediate SW updates - Create monochrome 96x96 badge icon for Android notifications - Add -webkit-tap-highlight-color: transparent for dark theme - Show SW update banner on all views, not just SessionsView - Fix precache duplicates with specific glob patterns (18→16 entries) - Add safe-bottom to ChatView saveToast - Fix stale poll interval comment (10s→3s) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
307 lines
10 KiB
TypeScript
307 lines
10 KiB
TypeScript
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
import { STORAGE } from './lib/storage-keys';
|
|
import { isAuthenticated, clearToken } from './lib/api';
|
|
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<void>;
|
|
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) {
|
|
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';
|
|
}
|
|
window.history.pushState({ view }, '', url);
|
|
}
|
|
|
|
export function App() {
|
|
const [authed, setAuthed] = useState(isAuthenticated());
|
|
const [view, setView] = useState<View>(loadView);
|
|
const [serverOnline, setServerOnline] = useState<boolean | null>(null);
|
|
const [deviceOnline, setDeviceOnline] = useState(navigator.onLine);
|
|
const consecutiveFails = useRef(0);
|
|
const initialized = useRef(false);
|
|
|
|
const [installPrompt, setInstallPrompt] = useState<BeforeInstallPromptEvent | null>(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);
|
|
}, []);
|
|
|
|
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
|
|
useEffect(() => {
|
|
const handleControllerChange = () => setSwUpdateAvailable(true);
|
|
navigator.serviceWorker?.addEventListener('controllerchange', handleControllerChange);
|
|
return () => navigator.serviceWorker?.removeEventListener('controllerchange', handleControllerChange);
|
|
}, []);
|
|
|
|
// 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);
|
|
setView(v);
|
|
}, [view]);
|
|
|
|
const isOffline = !deviceOnline || serverOnline === false;
|
|
|
|
const updateBanner = swUpdateAvailable && (
|
|
<div className="fixed bottom-6 left-4 right-4 bg-surface border border-accent/30 rounded-md px-4 py-3 flex items-center justify-between z-50 shadow-lg safe-bottom">
|
|
<span className="text-sm text-text font-mono">New version available</span>
|
|
<div className="flex gap-2">
|
|
<button onClick={() => window.location.reload()} className="text-sm font-medium text-accent hover:text-accent-light cursor-pointer">Refresh</button>
|
|
<button onClick={() => setSwUpdateAvailable(false)} className="text-sm text-text-dim hover:text-text cursor-pointer">Later</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// Splash screen while first health check is pending
|
|
if (serverOnline === null) {
|
|
return (
|
|
<>
|
|
<div className="min-h-screen bg-bg flex items-center justify-center">
|
|
<LoadingAnimation size="lg" label="Connecting..." />
|
|
</div>
|
|
{updateBanner}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Offline screen
|
|
if (isOffline) {
|
|
return <><OfflineView onRetry={checkHealth} />{updateBanner}</>;
|
|
}
|
|
|
|
if (!authed) {
|
|
return <><LoginView onLogin={handleLogin} />{updateBanner}</>;
|
|
}
|
|
|
|
if (view.name === 'newchat') {
|
|
return (
|
|
<>
|
|
<NewChatView cwd={view.cwd} onStartChat={startChat} onBack={backToSessions} />
|
|
{updateBanner}
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (view.name === 'settings') {
|
|
return <><SettingsView onBack={() => setView({ name: 'sessions' })} />{updateBanner}</>;
|
|
}
|
|
|
|
if (view.name === 'chat') {
|
|
return (
|
|
<>
|
|
<ChatView
|
|
sessionId={view.sessionId}
|
|
cwd={view.cwd}
|
|
initialPrompt={view.initialPrompt}
|
|
adapter={view.adapter}
|
|
onBack={backToSessions}
|
|
/>
|
|
{updateBanner}
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<SessionsView
|
|
onOpenChat={openChat}
|
|
onLogout={handleLogout}
|
|
onOpenSettings={() => setView({ name: 'settings' })}
|
|
installPrompt={!installDismissed ? installPrompt : null}
|
|
onInstall={handleInstall}
|
|
onDismissInstall={dismissInstall}
|
|
/>
|
|
{updateBanner}
|
|
</>
|
|
);
|
|
}
|