Files
clawtap/src/App.tsx
T
izackp 2ded310472 feat: plan-approval overlay, auth-expiry handling, cert download, login reset
- Add a dedicated plan-approval bottom sheet (interactive-prompt) driven by
  ExitPlanMode's now-4-option layout (Claude Code v2.1.177), with
  approve/YOLO/reject/feedback wired through respondPlan(requestId).
- PermissionManager: stop auto-expiring AskUserQuestion/plan prompts and stop
  releasing pending prompts on client disconnect — they reflect real CLI
  state and must survive reconnects.
- Detect server-rejected tokens (401/403 or failed WS handshake) and bounce
  back to the login screen via a new AUTH_EXPIRED_EVENT.
- Add `/cert` endpoint + Settings entry so phones can download/trust the
  self-signed HTTPS cert directly; fix cert IP detection on non-macOS hosts.
- Add `clawtap reset-login` command and `/api/auth/reset-attempts` endpoint
  to clear login rate-limit lockouts.
- Codex adapter: only forward recognized keystrokes/option indices to tmux,
  never the synthetic "deny" dismissal signal.
- PWA: actively poll for service-worker updates (iOS standalone apps don't
  reliably check on navigation).
- update-service.sh: install tmux if missing, prompt to create the env file
  instead of failing, add --skip-firewall, and improve logging.
2026-06-15 16:30:24 -04:00

387 lines
14 KiB
TypeScript

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<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, 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<View>(loadView);
const [serverOnline, setServerOnline] = useState<boolean | null>(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<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);
}, []);
// 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 && (
<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}
</>
);
}