Repository Public
1'use client';2import { useEffect } from 'react';3import { useRouter } from 'next/navigation';4import { useQueryClient } from '@tanstack/react-query';5import { WifiOff } from 'lucide-react';6import { useAuthStatus, useSessions, 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 { useLayoutPersistence } from '@/hooks/use-layout-persistence';12import { useUi } from '@/stores/ui';13import { useConn } from '@/stores/conn';14import { useTerminals } from '@/stores/terminals';15import { Spinner } from '@/components/ui/misc';16import { Button } from '@/components/ui/button';17import { NewSessionDialog } from '@/components/sessions/new-session-dialog';18import { DesktopShell } from './desktop-shell';19import { MobileShell } from './mobile-shell';20import { CommandPalette } from './command-palette';21import { SessionSwitcher } from './session-switcher';22import { Logo } from './logo';23import { useBrand } from '@/stores/branding';2425/** Authenticated application frame: auth gate, live events, responsive shell, global overlays. */26export function AppFrame({ children }: { children: React.ReactNode }) {27 const brand = useBrand();28 const auth = useAuthStatus();29 const router = useRouter();30 const bp = useBreakpoint();31 const online = useConn((s) => s.online);32 useVisualViewport();3334 const status = auth.data;35 const authed = !!status?.authenticated;36 useEffect(() => {37 if (!status || status.authenticated) return;38 router.replace(status.setupRequired ? '/setup/' : `/login/?next=${encodeURIComponent(location.pathname + location.search)}`);39 }, [status, router]);4041 if (!authed || !bp) {42 const offline = auth.isError && !online;43 const limited = auth.error instanceof ApiRequestError && auth.error.status === 429 ? auth.error : null;44 return (45 <div className="app-viewport flex flex-col items-center justify-center gap-4 px-6 text-center" data-testid="boot">46 <Logo className="size-10" />47 {auth.isError ? (48 <>49 {offline && <WifiOff className="size-5 text-muted" aria-hidden />}50 <p className="text-sm font-medium" data-testid="offline-state">{offline ? 'Offline — the remote session keeps running on the server' : limited ? 'Too many requests' : 'The server is not reachable'}</p>51 <p className="max-w-sm text-xs text-text-2">{offline ? 'This device has no network connection. Your terminals are not affected; reconnect to attach again.' : limited ? `The server is rate-limiting this address${limited.retryAfterSec ? ` — try again in ${limited.retryAfterSec} s` : ''}. Running sessions are not affected.` : `${brand.product} could not contact its API. Sessions that were running keep running on the host.`}</p>52 <Button onClick={() => auth.refetch()} loading={auth.isFetching}>Retry</Button>53 </>54 ) : (55 <Spinner />56 )}57 </div>58 );59 }60 return <Authed mobile={bp === 'mobile'}>{children}</Authed>;61}6263function Authed({ mobile, children }: { mobile: boolean; children: React.ReactNode }) {64 const qc = useQueryClient();65 const setUi = useUi((s) => s.set);66 const sessions = useSessions().data;67 useLayoutPersistence();68 // The server-side theme applies only once it was chosen in this UI (older stored settings always said 'dark').69 const settings = useSettingsQuery().data;70 const hydrated = useUi((s) => s.hydrated);71 const themeSet = useUi((s) => s.themeSet);72 useEffect(() => {73 if (hydrated && themeSet && settings && settings.theme !== useTheme.getState().pref) useTheme.getState().setPref(settings.theme, { animate: false });74 }, [hydrated, themeSet, settings]);7576 useEffect(() => {77 startEvents(qc);78 return () => stopEvents();79 }, [qc]);8081 // Tabs restored from localStorage may point at sessions that no longer exist.82 useEffect(() => {83 if (sessions) useTerminals.getState().prune(new Set(sessions.map((s) => s.id)));84 }, [sessions]);8586 useEffect(() => {87 const onKey = (e: KeyboardEvent) => {88 const primary = e.metaKey || e.ctrlKey;89 const k = e.key.toLowerCase();90 if (primary && !e.altKey && !e.shiftKey && k === 'k') {91 e.preventDefault();92 setUi({ paletteOpen: !useUi.getState().paletteOpen, switcherOpen: false });93 } else if ((primary && !e.altKey && !e.shiftKey && k === 'j') || (e.ctrlKey && e.key === '`')) {94 e.preventDefault();95 setUi({ switcherOpen: !useUi.getState().switcherOpen, paletteOpen: false });96 }97 };98 window.addEventListener('keydown', onKey, true);99 return () => window.removeEventListener('keydown', onKey, true);100 }, [setUi]);101102 return (103 <>104 {mobile ? <MobileShell>{children}</MobileShell> : <DesktopShell>{children}</DesktopShell>}105 <CommandPalette />106 <SessionSwitcher />107 <NewSessionDialog />108 </>109 );110}111