import { useEffect, useState, useCallback, Suspense, lazy } from "react" import { Link, Outlet, useNavigate, useRouterState } from "@tanstack/react-router" import { Command as CommandIcon, ChevronLeft, ChevronRight, Loader2, Menu, X } from "lucide-react" import { NAV, navFuerPfad } from "@/nav" import { ExpertToggle } from "@/components/ExpertToggle" import { useHealth, useSystemStatus } from "@/lib/queries" import type { Health } from "@/lib/api" import { useMetricsFeeder } from "@/lib/metricsStore" import { useEventStream } from "@/lib/events" import { useMC, usePaletteOffen, useSchieneEingeklappt } from "@/app/store" import { Statusleiste } from "@/app/shell/Statusleiste" import { Meldungen } from "@/app/shell/Meldungen" import { cn } from "@/lib/utils" // Beide sind beim Start UNSICHTBAR und gehoeren deshalb nicht ins Start-Buendel (P2): // die Schublade zieht Wartung, Logs, Einstellungen und den Update-Dialog mit, die Palette // bringt cmdk mit. Zusammen waren das rund 30 kB gzip, die niemand beim ersten Bild braucht. // Der Router kostete P2 ungefaehr dasselbe — so bleibt das Buendel-Budget der Ampel scharf, // statt es einfach hochzusetzen. const SystemDrawer = lazy(() => import("@/components/SystemDrawer").then((m) => ({ default: m.SystemDrawer }))) const CommandPalette = lazy(() => import("@/components/CommandPalette").then((m) => ({ default: m.CommandPalette }))) // Dezenter Lade-Zustand während eine View nachgeladen wird (lokales Netz: kaum sichtbar). function ViewLoading() { return (
) } // Tastatur-Hinweis passend zum Betriebssystem (Palette hört auf Ctrl UND Cmd). const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform) /** * Die Shell: Schiene, Kopfzeile, System-Schublade — alles, was über JEDER Ansicht liegt. * * v3-Umbau P2 (28.08.2026): Hier stand vorher der Ansichts-Umschalter * (`{view === "ideen" && }`), gespeist aus einem `useState`, das am URL-Hash * hing. Das funktionierte für Reload und Lesezeichen — aber nur auf View-Ebene. Jetzt * rendert der Router in den ``, und der Zustand DARUNTER (offene Idee, Reiter, * Schublade) liegt in echten Suchparametern. Siehe app/router.tsx. */ export function Shell() { useMetricsFeeder() // sammelt Live-Verlauf global, unabhängig vom aktiven Tab useEventStream() // SSE-Invalidation-Bus (P3a): sofortige Updates, Poller nur Netz const navigate = useNavigate() const pfad = useRouterState({ select: (s) => s.location.pathname }) // Die Schublade liegt über allem und gehört deshalb in die Wurzel-Suche (?system=). // Vorher war sie lokaler State und wurde per CustomEvent von außen aufgestoßen — // nicht teilbar, nicht reload-fest. const drawerTab = useRouterState({ select: (s) => (s.location.search as { system?: "wartung" | "logs" }).system, }) // v3-Umbau P3: Schienen-Zustand und Palette liegen jetzt im Store (Befund B-08/B-09). // Vorher: ein localStorage-Zugriff mitten in useState plus ein GEFAELSCHTES // KeyboardEvent, das der Kopfzeilen-Knopf aufs Dokument feuerte. const schieneEingeklappt = useSchieneEingeklappt() const schieneUmschalten = useMC((st) => st.schieneUmschalten) const paletteOffen = usePaletteOffen() const palette = useMC((st) => st.palette) const [mobileNavOpen, setMobileNavOpen] = useState(false) // Die Palette wird beim ERSTEN Ruf geladen und bleibt dann gemountet — sie haelt ihren // Inhalt und soll nicht bei jedem Strg+K neu nachgeladen werden. const [paletteBereit, setPaletteBereit] = useState(false) useEffect(() => { if (paletteOffen) setPaletteBereit(true) }, [paletteOffen]) useEffect(() => { const taste = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault() palette(!useMC.getState().ui.paletteOffen) } } document.addEventListener("keydown", taste) return () => document.removeEventListener("keydown", taste) }, [palette]) const schubladeSetzen = useCallback((tab: "wartung" | "logs" | undefined) => { navigate({ to: ".", search: (alt) => ({ ...alt, system: tab }), replace: true }) }, [navigate]) // isError mitnehmen: React Query behält bei Fehlern die letzten Daten — ohne den // Fehler-Blick zeigte die Sidebar bei totem Backend ewig den letzten grünen Stand. const { data: health, isError: backendDown } = useHealth() useEffect(() => { document.documentElement.classList.add("dark") }, []) // Hier hingen bis P2 zwei CustomEvent-Zuhörer (`open-system-drawer`, `mc-navigate`), // über die andere Ansichten die Shell fernsteuerten. In P3 sind sie ersatzlos weg: // Wer die Schublade öffnen will, setzt `?system=`; wer springen will, navigiert. Der // Zustand liegt damit da, wo man ihn sehen und teilen kann (Befund B-08). // Mobile-Navigation schließt nach jedem View-Wechsel (ein Tipp = ein Ziel). useEffect(() => { setMobileNavOpen(false) }, [pfad]) const active = navFuerPfad(pfad) return (
{/* Ruhiger Ambient-Hintergrund: statische radial-gradients statt animierter Fullscreen-Blur-Filter — gleicher Look, praktisch keine GPU-Last. */}
{/* Beilaeufige Rueckmeldungen — EINE Stelle, und endlich mit aria-live (B-11). */} {/* Erst laden, wenn wirklich gebraucht — ohne Platzhalter, beide sind Overlays. */} {paletteBereit && } {drawerTab && ( schubladeSetzen(undefined)} defaultTab={drawerTab === "logs" ? "logs" : "maintenance"} /> )} {/* Sidebar (Desktop, ab md) */} {/* Sidebar (Mobile, Off-Canvas mit Backdrop) */}
setMobileNavOpen(false)} />
{/* Main */}
{active?.hint}
Hilfe
{/* Bahnbreiten-Deckel (Befund B-10): Ohne ihn zieht sich das Cockpit auf einem 3440-px-Ultrawide auf ueber 3 000 px Breite; die Wurzel-Schriftgroesse waechst dabei mit und macht die Zeilen GROESSER statt lesbarer. 1600 px sind die Grenze, ab der eine Zeile nicht mehr am Stueck ueberblickt wird — der Rest bleibt Rand. */}
}>
{/* Zeigt den Zustand der Box IMMER, ohne den Arbeitsbereich zu blockieren (§4.1). */}
) } // Sidebar-Inhalt (Logo, Navigation, Status-Fuß) — geteilt zwischen Desktop-Leiste // und Mobile-Off-Canvas, damit beide immer identisch bleiben. function SidebarContent({ collapsed, onToggleCollapse, onClose, pfad, health, backendDown, }: { collapsed: boolean onToggleCollapse?: () => void onClose?: () => void pfad: string health: Health | undefined backendDown: boolean }) { const aktiv = navFuerPfad(pfad) // Kein Prop-Drilling mehr: derselbe Query-Key, also dieselbe Anfrage — TanStack Query // dedupliziert. Die Versionen stehen bewusst hier unten und nicht in der Statusleiste: // Man schaut sie selten an, und die Leiste soll knapp bleiben. const { data: sysStatus } = useSystemStatus() return ( <>
{!collapsed && (
Mission Control
2.0
)}
{onToggleCollapse && ( )} {onClose && ( )}
{collapsed ? (
) : (
{backendDown ? ( Zentrale antwortet nicht ) : health ? ( <> Engine {health.engine_reachable ? "online" : "offline"} {health.brain && !health.brain.ready && ( Hirn offline{health.brain.model ? ` (${health.brain.model})` : ""} )} ) : ( Backend offline )} {sysStatus?.versions && (
MC2: {sysStatus.versions.mc2 ? `${sysStatus.versions.mc2.hash}${sysStatus.versions.mc2.dirty ? "*" : ""}` : "—"}
Engine: {sysStatus.versions.engine?.type === "git" ? `${sysStatus.versions.engine.hash}${sysStatus.versions.engine.dirty ? "*" : ""}` : (sysStatus.versions.engine?.version_text?.split(" ").pop() || "—")}
Hermes Agent: {sysStatus.versions.hermes_agent ? `${sysStatus.versions.hermes_agent.hash}${sysStatus.versions.hermes_agent.dirty ? "*" : ""}` : "—"}
)}
)}
) }