Repository Public
1'use client';2import { useEffect, useRef } from 'react';3import { useRouter } from 'next/navigation';4import { useQueryClient } from '@tanstack/react-query';5import { WifiOff } from 'lucide-react';6import { useAuthStatus, useSettingsQuery } from '@/lib/queries';7import { useTheme } from '@/stores/theme';8import { ApiRequestError } from '@/lib/api';9import { startEvents, stopEvents } from '@/lib/events';10import { useBreakpoint, useVisualViewport } from '@/hooks/use-viewport';11import { useUi } from '@/stores/ui';12import { useConn } from '@/stores/conn';13import { Spinner } from '@/components/ui/misc';14import { Button } from '@/components/ui/button';15import { NewMissionDialog } from '@/components/missions/new-mission';16import { BRAND } from '@/lib/brand';17import { DesktopShell } from './desktop-shell';18import { MobileShell } from './mobile-shell';19import { CommandPalette } from './command-palette';20import { Logo } from './logo';2122/** Authenticated application frame: auth gate, live events, responsive shell, global overlays. */23export function AppFrame({ children }: { children: React.ReactNode }) {24 const auth = useAuthStatus();25 const router = useRouter();26 const bp = useBreakpoint();27 const online = useConn((s) => s.online);28 useVisualViewport();2930 const status = auth.data;31 const authed = !!status?.authenticated;32 useEffect(() => {33 if (!status || status.authenticated) return;34 router.replace(status.setupRequired ? '/setup/' : `/login/?next=${encodeURIComponent(location.pathname + location.search)}`);35 }, [status, router]);3637 if (!authed || !bp) {38 const offline = auth.isError && !online;39 const limited = auth.error instanceof ApiRequestError && auth.error.status === 429 ? auth.error : null;40 return (41 <div className="app-viewport flex flex-col items-center justify-center gap-4 px-6 text-center" data-testid="boot">42 <Logo className="size-10" />43 {auth.isError ? (44 <>45 {offline && <WifiOff className="size-5 text-muted" aria-hidden />}46 <p className="text-sm font-medium" data-testid="offline-state">{offline ? 'Offline — missions keep running on the server' : limited ? 'Too many requests' : 'The server is not reachable'}</p>47 <p className="max-w-sm text-xs text-text-2">48 {offline49 ? 'This device has no network connection. Agents are not affected; pending approvals will be waiting when you reconnect.'50 : limited51 ? `The server is rate-limiting this address${limited.retryAfterSec ? ` — try again in ${limited.retryAfterSec} s` : ''}. Running missions are not affected.`52 : `${BRAND.product} could not contact its API. Missions that were running keep running on the host.`}53 </p>54 <Button onClick={() => auth.refetch()} loading={auth.isFetching}>Retry</Button>55 </>56 ) : (57 <Spinner />58 )}59 </div>60 );61 }62 return <Authed mobile={bp === 'mobile'}>{children}</Authed>;63}6465function Authed({ mobile, children }: { mobile: boolean; children: React.ReactNode }) {66 const qc = useQueryClient();67 const setUi = useUi((s) => s.set);68 // The theme stored on the server follows the owner across devices: applied once per page load.69 const settings = useSettingsQuery().data;70 const themeApplied = useRef(false);71 useEffect(() => {72 if (!settings || themeApplied.current) return;73 themeApplied.current = true;74 if (settings.theme !== useTheme.getState().pref) useTheme.getState().setPref(settings.theme, { animate: false });75 }, [settings]);7677 useEffect(() => {78 startEvents(qc);79 return () => stopEvents();80 }, [qc]);8182 useEffect(() => {83 const onKey = (e: KeyboardEvent) => {84 if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === 'k') {85 e.preventDefault();86 setUi({ paletteOpen: !useUi.getState().paletteOpen });87 }88 };89 window.addEventListener('keydown', onKey, true);90 return () => window.removeEventListener('keydown', onKey, true);91 }, [setUi]);9293 return (94 <>95 {mobile ? <MobileShell>{children}</MobileShell> : <DesktopShell>{children}</DesktopShell>}96 <CommandPalette />97 <NewMissionDialog />98 </>99 );100}101