2ded310472
- 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.
387 lines
14 KiB
TypeScript
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}
|
|
</>
|
|
);
|
|
}
|