feat(frontend): v3-Umbau P2 — echtes Routing, Unterzustand in der URL

Dritte Etappe. Die Ansicht lag bisher im URL-Hash (`#models`), gespeist aus einem
useState in App.tsx. Das war fuer Reload und Lesezeichen brauchbar — aber nur auf
View-Ebene; fuer den Zustand DARUNTER war kein Platz.

## Was jetzt in der Adresse steht (Befund B-05)

  /modelle?reiter=routing     der Reiter war lokaler State, nicht teilbar
  /ideen?offen=<id>           die aufgeklappte Karte; wer waehrend eines
                              laufenden Workers neu lud, verlor seinen Platz
  /wissen?datei=<pfad>        jede Vault-Notiz verlinkbar
  <ueberall>?system=wartung|logs   die System-Schublade inkl. Reiter

Alle Suchparameter werden validiert (app/router.tsx) — ein Link mit Unsinn darin
faellt auf den Normalfall zurueck, statt die Ansicht in einen halben Zustand zu
bringen.

## Alte Lesezeichen brechen nicht

lib/hashUmleitung.ts biegt `#models` per replaceState auf `/modelle`, EINMAL vor
dem Router-Mount. Fuenf Tests decken das ab (jeder NAV-Eintrag, die Schreibweise
`#/models`, erhaltene Suchparameter, unbekannter Hash, kein Hash) — die Bruecke
sieht sonst wie toter Code aus und waere ein naheliegender Kandidat zum Wegraeumen.

Das Backend brauchte KEINE Zeile: der SPA-Catch-all in app.py:148-168 beantwortet
jede unbekannte Route schon immer mit index.html. Vorher geprueft, nicht gehofft.

## Fehlergrenze pro Route (Befund B-13)

Vorher hing eine einzige Grenze in main.tsx um alles — ein Renderfehler im
Modell-Manager nahm Cockpit, Konsole und Chronik mit. Jetzt faengt jede Route fuer
sich (components/RouteFehler.tsx: was passiert ist, was man tun kann, technische
Einzelheiten aufklappbar). AppErrorBoundary bleibt als letztes Netz fuer Fehler
ausserhalb jeder Route. Dazu eine 404-Seite, die Schiene und Kopfzeile stehen
laesst — im Browser geprueft.

## Budget: der Router kostete 29 kB, sie sind wieder drin

TanStack Router hob den Start-Chunk von 111 auf 141 kB gzip und riss damit das in
P0 gesetzte Ampel-Budget (125 kB). Statt das Budget hochzusetzen — was es als
Signal entwertet haette — den Platz zurueckgeholt: SystemDrawer und CommandPalette
sind beim Start UNSICHTBAR und liegen jetzt hinter lazy(). Die Palette laedt beim
ersten Strg+K und bleibt dann gemountet.

  Start-Chunk gzip  111 411 -> 114 978 B   (Budget 125 000, weiter scharf)
  dist gesamt                  1 565 521 B (Budget 3 145 728)

## Nebenbei

  · Befund B-09 erledigt: Der "Suchen"-Knopf baute ein GEFAELSCHTES KeyboardEvent
    (`new KeyboardEvent("keydown", { metaKey: true })`) und feuerte es aufs
    Dokument. Jetzt ein eigener Kanal; in P3 loest der Store auch den ab.
  · Der Schliessen-Knopf der System-Schublade hatte kein aria-label — ein reiner
    Icon-Knopf ohne Namen. Nachgeholt (beim Pruefen aufgefallen).
  · nav.ts fuehrt jetzt den Pfad je Eintrag + navFuerPfad() mit Laengen-Sortierung,
    damit /ideen/<id> die Sidebar bei "Ideen" markiert. Mit Tests.

## Verifiziert im Browser (lokales Backend, echtes Produktions-Buendel)

  /                        -> /cockpit, Sidebar markiert
  /#models                 -> /modelle, Sidebar markiert
  /modelle?reiter=routing  -> Reiter "Routing & Warm-Set" aktiv
  /cockpit?system=logs     -> Schublade offen, Reiter "System-Logs" aktiv;
                              Schliessen entfernt den Parameter wieder
  /gibtsnicht              -> 404-Seite, Schiene + Kopfzeile intakt
  Zurueck-Taste            -> voriger Pfad

26/26 Tests gruen · ESLint 0 Fehler · tsc sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-08-28 08:55:58 +02:00
co-authored by Claude Opus 5
parent ae69c5ce31
commit 464b0d20b8
50 changed files with 1143 additions and 607 deletions
+88 -74
View File
@@ -1,29 +1,22 @@
import { useEffect, useState, useCallback, lazy, Suspense } from "react"
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, type ViewId } from "@/nav"
import { CommandPalette } from "@/components/CommandPalette"
import { NAV, navFuerPfad } from "@/nav"
import { ExpertToggle } from "@/components/ExpertToggle"
import { SystemDrawer } from "@/components/SystemDrawer"
import { useHealth, useSystemStatus } from "@/lib/queries"
import type { Health, SystemStatus } from "@/lib/api"
import { useMetricsFeeder } from "@/lib/metricsStore"
import { useEventStream } from "@/lib/events"
import { cn } from "@/lib/utils"
import { CockpitView } from "@/views/cockpit/CockpitView"
// Code-Splitting: nur das Cockpit (Startseite) lädt sofort, jede weitere View
// kommt als eigenes Chunk erst beim ersten Aufruf — kleineres Start-Bundle.
const ModelsView = lazy(() => import("@/views/ModelsView").then((m) => ({ default: m.ModelsView })))
const ConnectView = lazy(() => import("@/views/ConnectView").then((m) => ({ default: m.ConnectView })))
const AgentView = lazy(() => import("@/views/AgentView").then((m) => ({ default: m.AgentView })))
const KonsoleView = lazy(() => import("@/views/KonsoleView").then((m) => ({ default: m.KonsoleView })))
const GuideView = lazy(() => import("@/views/GuideView").then((m) => ({ default: m.GuideView })))
const AuftragsbuchView = lazy(() => import("@/views/AuftragsbuchView").then((m) => ({ default: m.AuftragsbuchView })))
const IdeenView = lazy(() => import("@/views/IdeenView").then((m) => ({ default: m.IdeenView })))
const SkillsView = lazy(() => import("@/views/SkillsView").then((m) => ({ default: m.SkillsView })))
const ChronikView = lazy(() => import("@/views/ChronikView").then((m) => ({ default: m.ChronikView })))
const WissenView = lazy(() => import("@/views/WissenView").then((m) => ({ default: m.WissenView })))
// 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() {
@@ -37,22 +30,50 @@ function ViewLoading() {
// Tastatur-Hinweis passend zum Betriebssystem (Palette hört auf Ctrl UND Cmd).
const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform)
export default function App() {
/**
* 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" && <IdeenView />}`), 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 `<Outlet/>`, 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
// View ist im URL-Hash (#models) verankert → Reload/Teilen/Cmd-Klick funktionieren.
const [view, setView] = useState<ViewId>(() => {
const h = window.location.hash.slice(1) as ViewId
return NAV.some((n) => n.id === h) ? h : "dashboard"
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,
})
const navigate = useCallback((v: ViewId) => {
if (window.location.hash.slice(1) === v) setView(v)
else window.location.hash = v
}, [])
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem("mc_sidebar_collapsed") === "true")
const [mobileNavOpen, setMobileNavOpen] = useState(false)
const [drawerOpen, setDrawerOpen] = useState(false)
const [drawerTab, setDrawerTab] = useState<"maintenance" | "logs">("maintenance")
// Die Palette wird beim ERSTEN Ruf geladen und bleibt dann gemountet — sie haelt ihren
// eigenen Offen-Zustand und soll nicht bei jedem Strg+K neu nachgeladen werden.
const [paletteBereit, setPaletteBereit] = useState(false)
useEffect(() => {
const wecken = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") setPaletteBereit(true)
}
const rufen = () => setPaletteBereit(true)
document.addEventListener("keydown", wecken)
window.addEventListener("mc-palette-open", rufen)
return () => {
document.removeEventListener("keydown", wecken)
window.removeEventListener("mc-palette-open", rufen)
}
}, [])
const schubladeSetzen = useCallback((tab: "wartung" | "logs" | undefined) => {
navigate({ to: ".", search: (alt: Record<string, unknown>) => ({ ...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.
@@ -63,38 +84,33 @@ export default function App() {
document.documentElement.classList.add("dark")
}, [])
// Zwei Alt-Kanäle aus der Zeit vor dem Router. Sie bleiben in P2 bestehen, damit die
// Aufrufer (Cockpit-Kacheln, Agent-Ansicht, Auftragsbuch) unverändert weiterlaufen —
// gefüttert wird jetzt aber der Router, nicht mehr ein lokales useState.
// In P3 lösen Store und Palette diese Kanäle ganz ab (Befund B-08).
useEffect(() => {
const handleOpen = (e: Event) => {
const customEvent = e as CustomEvent
setDrawerTab(customEvent.detail?.tab || "maintenance")
setDrawerOpen(true)
const tab = (e as CustomEvent).detail?.tab
schubladeSetzen(tab === "logs" ? "logs" : "wartung")
}
window.addEventListener("open-system-drawer", handleOpen)
return () => window.removeEventListener("open-system-drawer", handleOpen)
}, [])
}, [schubladeSetzen])
useEffect(() => {
const handleNav = (e: Event) => {
const v = (e as CustomEvent).detail?.view as ViewId | undefined
if (v) navigate(v)
const v = (e as CustomEvent).detail?.view as string | undefined
const ziel = NAV.find((n) => n.id === v)
if (ziel) navigate({ to: ziel.pfad })
}
window.addEventListener("mc-navigate", handleNav)
return () => window.removeEventListener("mc-navigate", handleNav)
}, [navigate])
useEffect(() => {
const onHash = () => {
const h = window.location.hash.slice(1) as ViewId
if (NAV.some((n) => n.id === h)) setView(h)
}
window.addEventListener("hashchange", onHash)
return () => window.removeEventListener("hashchange", onHash)
}, [])
// Mobile-Navigation schließt nach jedem View-Wechsel (ein Tipp = ein Ziel).
useEffect(() => { setMobileNavOpen(false) }, [view])
useEffect(() => { setMobileNavOpen(false) }, [pfad])
const active = NAV.find((n) => n.id === view)!
const active = navFuerPfad(pfad)
return (
<div className="flex h-full relative">
@@ -112,8 +128,17 @@ export default function App() {
}}
/>
<CommandPalette onNavigate={navigate} />
<SystemDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} defaultTab={drawerTab} />
{/* Erst laden, wenn wirklich gebraucht — ohne Platzhalter, beide sind Overlays. */}
<Suspense fallback={null}>
{paletteBereit && <CommandPalette />}
{drawerTab && (
<SystemDrawer
open
onClose={() => schubladeSetzen(undefined)}
defaultTab={drawerTab === "logs" ? "logs" : "maintenance"}
/>
)}
</Suspense>
{/* Sidebar (Desktop, ab md) */}
<aside className={cn(
@@ -129,7 +154,7 @@ export default function App() {
return next
})
}}
view={view}
pfad={pfad}
health={health}
backendDown={backendDown}
sysStatus={sysStatus}
@@ -152,7 +177,7 @@ export default function App() {
<SidebarContent
collapsed={false}
onClose={() => setMobileNavOpen(false)}
view={view}
pfad={pfad}
health={health}
backendDown={backendDown}
sysStatus={sysStatus}
@@ -171,7 +196,7 @@ export default function App() {
>
<Menu className="h-5 w-5" aria-hidden="true" />
</button>
<div className="truncate text-xs font-medium text-muted-foreground tracking-wide uppercase font-sans">{active.hint}</div>
<div className="truncate text-xs font-medium text-muted-foreground tracking-wide uppercase font-sans">{active?.hint}</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<ExpertToggle />
@@ -186,10 +211,7 @@ export default function App() {
</a>
<button
onClick={() => {
const ev = new KeyboardEvent("keydown", { key: "k", metaKey: true })
document.dispatchEvent(ev)
}}
onClick={() => window.dispatchEvent(new CustomEvent("mc-palette-open"))}
className="flex items-center gap-2 rounded-md border border-border/40 bg-background/40 px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-all cursor-pointer"
>
<CommandIcon className="h-3.5 w-3.5" />
@@ -201,18 +223,7 @@ export default function App() {
<main className="flex-1 overflow-y-auto p-4 md:p-6 scrollbar-thin">
<Suspense fallback={<ViewLoading />}>
{view === "dashboard" && <CockpitView onNavigate={(v) => navigate(v as ViewId)} />}
{view === "ideen" && <IdeenView />}
{view === "auftraege" && <AuftragsbuchView />}
{view === "skills" && <SkillsView />}
{view === "models" && <ModelsView />}
{view === "connect" && <ConnectView />}
{view === "wissen" && <WissenView />}
{view === "chronik" && <ChronikView />}
{view === "agent" && <AgentView />}
{view === "konsole" && <KonsoleView />}
{view === "guide" && <GuideView />}
<Outlet />
</Suspense>
</main>
</div>
@@ -223,16 +234,18 @@ export default function App() {
// Sidebar-Inhalt (Logo, Navigation, Status-Fuß) — geteilt zwischen Desktop-Leiste
// und Mobile-Off-Canvas, damit beide immer identisch bleiben.
function SidebarContent({
collapsed, onToggleCollapse, onClose, view, health, backendDown, sysStatus,
collapsed, onToggleCollapse, onClose, pfad, health, backendDown, sysStatus,
}: {
collapsed: boolean
onToggleCollapse?: () => void
onClose?: () => void
view: ViewId
pfad: string
health: Health | undefined
backendDown: boolean
sysStatus: SystemStatus | undefined
}) {
const aktiv = navFuerPfad(pfad)
return (
<>
<div className={cn("flex items-center py-4 border-b border-border/40 shrink-0", collapsed ? "flex-col gap-3 px-2" : "justify-between px-5")}>
@@ -272,6 +285,7 @@ function SidebarContent({
return NAV.map((item) => {
const showGroupHeader = item.group !== lastGroup
lastGroup = item.group
const istAktiv = aktiv?.id === item.id
return (
<div key={item.id} className="space-y-1">
{showGroupHeader && (
@@ -283,14 +297,14 @@ function SidebarContent({
</div>
)
)}
<a
href={`#${item.id}`}
<Link
to={item.pfad}
onClick={onClose}
aria-current={view === item.id ? "page" : undefined}
aria-current={istAktiv ? "page" : undefined}
className={cn(
"flex w-full items-center rounded-md text-sm transition-all cursor-pointer",
collapsed ? "justify-center p-2.5" : "gap-3 px-3 py-2",
view === item.id
istAktiv
? (collapsed ? "nav-active-collapsed" : "nav-active")
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
@@ -299,7 +313,7 @@ function SidebarContent({
>
<item.icon className="h-4.5 w-4.5 shrink-0" aria-hidden="true" />
{!collapsed && <span className="truncate">{item.label}</span>}
</a>
</Link>
</div>
)
})