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>
)
})
+187
View File
@@ -0,0 +1,187 @@
import { lazy } from "react"
import {
createRootRoute,
createRoute,
createRouter,
redirect,
useNavigate,
} from "@tanstack/react-router"
import { Shell } from "@/App"
import { NAV } from "@/nav"
import { RouteFehler } from "@/components/RouteFehler"
import { CockpitView } from "@/views/cockpit/CockpitView"
// Routenbaum (v3-Umbau P2).
//
// Bewusst code-based statt dateibasiert: Es sind zwölf Routen, und sie stehen dadurch
// alle an EINER Stelle — inklusive dem, was in den Suchparametern erlaubt ist. Eine
// Datei-Konvention würde diese Übersicht auf zwölf Dateien verteilen, ohne dass jemand
// etwas davon hätte.
//
// Code-Splitting bleibt exakt wie bisher: die Views hängen an React.lazy, das Cockpit als
// Startseite lädt eifrig. Kein Chunk verschiebt sich durch den Router.
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 })))
// ── Suchparameter ───────────────────────────────────────────────────────────
// Regel für das, was hier steht: Alles, was ein Kollege per Link teilen können soll und
// was einen Reload überleben muss. Nichts Flüchtiges (Hover, Entwürfe im Eingabefeld).
/** Wurzel: die System-Schublade. Gehört hierher, weil sie über JEDER Ansicht liegt. */
export interface WurzelSuche {
system?: "wartung" | "logs"
}
const wurzelSuche = (roh: Record<string, unknown>): WurzelSuche => {
const s = roh.system
return s === "wartung" || s === "logs" ? { system: s } : {}
}
export const rootRoute = createRootRoute({
component: Shell,
validateSearch: wurzelSuche,
notFoundComponent: () => (
<RouteFehler
titel="Diese Seite gibt es nicht"
text="Der Link zeigt auf eine Ansicht, die Mission Control nicht kennt. Vielleicht stammt er aus einer älteren Fassung."
/>
),
})
// Wurzelpfad → Cockpit. `redirect` statt einer Kopie der Startseite: sonst gäbe es
// zwei URLs für denselben Inhalt und die aktive Markierung in der Sidebar flackert.
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
beforeLoad: () => {
throw redirect({ to: "/cockpit" })
},
})
/** Eine Route je Ansicht — kurze Schreibweise, weil sich nur Pfad und Bauteil ändern.
* Generisch über den Pfad, damit TanStack Router den Literal-Typ behält: mit
* `path: string` verlöre `navigate({ to: "/cockpit" })` seine Typprüfung. */
function ansicht<P extends string>(path: P, component: () => React.ReactNode) {
return createRoute({
getParentRoute: () => rootRoute,
path,
component,
// Fehlergrenze PRO Route (Befund B-13): Vorher hing eine einzige Grenze in main.tsx
// um die ganze App — ein Renderfehler in den Modellen nahm Cockpit, Konsole und
// Chronik mit. Jetzt bleiben Schiene, Kopfzeile und Statusanzeige stehen.
errorComponent: ({ error, reset }) => (
<RouteFehler
titel="Diese Ansicht ist abgestürzt"
text="Der Rest von Mission Control läuft weiter. Erneut versuchen — oder in eine andere Ansicht wechseln."
fehler={error}
onErneut={reset}
/>
),
})
}
// Das Cockpit verlinkt in andere Bereiche und bekam den Sprung bisher als Prop von der
// App gereicht. Jetzt kommt er aus dem Router — die Ansicht selbst bleibt unverändert.
function CockpitSeite() {
const navigate = useNavigate()
return <CockpitView onNavigate={(v) => {
const ziel = NAV.find((n) => n.id === v)
if (ziel) navigate({ to: ziel.pfad })
}} />
}
const cockpitRoute = ansicht("/cockpit", () => <CockpitSeite />)
const auftraegeRoute = ansicht("/auftraege", () => <AuftragsbuchView />)
const skillsRoute = ansicht("/skills", () => <SkillsView />)
const chronikRoute = ansicht("/chronik", () => <ChronikView />)
const verbindenRoute = ansicht("/verbinden", () => <ConnectView />)
const konsoleRoute = ansicht("/konsole", () => <KonsoleView />)
const anleitungRoute = ansicht("/anleitung", () => <GuideView />)
const agentRoute = ansicht("/agent", () => <AgentView />)
/** Ideen: `?offen=<id>` hält die aufgeklappte Karte fest (Befund B-05).
* Vorher lag das in `useState` — wer während eines laufenden Workers neu lud,
* verlor seinen Platz in der Liste. */
export interface IdeenSuche {
offen?: string
}
const ideenRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/ideen",
validateSearch: (roh: Record<string, unknown>): IdeenSuche =>
typeof roh.offen === "string" && roh.offen ? { offen: roh.offen } : {},
component: () => <IdeenView />,
errorComponent: ({ error, reset }) => (
<RouteFehler titel="Die Ideen-Ansicht ist abgestürzt" text="Der Rest läuft weiter." fehler={error} onErneut={reset} />
),
})
/** Wissen: `?datei=<pfad>` macht jede Vault-Notiz verlinkbar. */
export interface WissenSuche {
datei?: string
}
const wissenRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/wissen",
validateSearch: (roh: Record<string, unknown>): WissenSuche =>
typeof roh.datei === "string" && roh.datei ? { datei: roh.datei } : {},
component: () => <WissenView />,
errorComponent: ({ error, reset }) => (
<RouteFehler titel="Die Wissens-Ansicht ist abgestürzt" text="Der Rest läuft weiter." fehler={error} onErneut={reset} />
),
})
/** Modelle: `?reiter=` — der Reiter war lokaler State und damit nicht teilbar. */
export type ModellReiter = "werkbank" | "routing" | "discover"
export interface ModelleSuche {
reiter?: ModellReiter
}
const modelleRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/modelle",
validateSearch: (roh: Record<string, unknown>): ModelleSuche => {
const r = roh.reiter
return r === "werkbank" || r === "routing" || r === "discover" ? { reiter: r } : {}
},
component: () => <ModelsView />,
errorComponent: ({ error, reset }) => (
<RouteFehler titel="Der Modell-Manager ist abgestürzt" text="Der Rest läuft weiter." fehler={error} onErneut={reset} />
),
})
export const routeTree = rootRoute.addChildren([
indexRoute,
cockpitRoute,
ideenRoute,
auftraegeRoute,
skillsRoute,
chronikRoute,
wissenRoute,
verbindenRoute,
konsoleRoute,
anleitungRoute,
modelleRoute,
agentRoute,
])
export const router = createRouter({
routeTree,
// Ein LAN-Gerät: der Verlauf muss nicht scrollen-restaurieren, aber die Position
// oben zu haben ist beim Ansichtswechsel richtig.
scrollRestoration: true,
defaultPreload: false,
})
declare module "@tanstack/react-router" {
interface Register {
router: typeof router
}
}
+15 -3
View File
@@ -1,9 +1,11 @@
import { useEffect, useState } from "react"
import { Command } from "cmdk"
import { NAV, type ViewId } from "@/nav"
import { useNavigate } from "@tanstack/react-router"
import { NAV } from "@/nav"
export function CommandPalette({ onNavigate }: { onNavigate: (v: ViewId) => void }) {
export function CommandPalette() {
const [open, setOpen] = useState(false)
const navigate = useNavigate()
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -16,6 +18,16 @@ export function CommandPalette({ onNavigate }: { onNavigate: (v: ViewId) => void
return () => document.removeEventListener("keydown", onKey)
}, [])
// Der „Suchen"-Knopf in der Kopfzeile (v3-Umbau P2, Befund B-09).
// Er baute vorher ein GEFÄLSCHTES Tastatur-Ereignis (`new KeyboardEvent("keydown",
// { metaKey: true })`) und feuerte es aufs Dokument, damit der Zuhörer oben anspringt.
// Jetzt ein eigener, ehrlicher Kanal. In P3 löst der Store auch den ab.
useEffect(() => {
const onOpen = () => setOpen(true)
window.addEventListener("mc-palette-open", onOpen)
return () => window.removeEventListener("mc-palette-open", onOpen)
}, [])
return (
<Command.Dialog
open={open}
@@ -43,7 +55,7 @@ export function CommandPalette({ onNavigate }: { onNavigate: (v: ViewId) => void
key={item.id}
value={`${item.label} ${item.hint}`}
onSelect={() => {
onNavigate(item.id)
navigate({ to: item.pfad })
setOpen(false)
}}
className="flex cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-sm text-foreground aria-selected:bg-accent aria-selected:text-accent-foreground"
+61
View File
@@ -0,0 +1,61 @@
import { AlertTriangle, RotateCcw } from "lucide-react"
// Fehlerfläche EINER Ansicht (v3-Umbau P2, Befund B-13).
//
// Vorher hing genau eine Fehlergrenze in main.tsx um die gesamte App: Ein Renderfehler
// im Modell-Manager nahm Cockpit, Konsole und Chronik gleich mit — die Seite war weiß.
// Jetzt fängt jede Route für sich; Schiene, Kopfzeile und Statusanzeige bleiben stehen,
// und der Nutzer kann einfach woanders hin.
//
// Die Meldung sagt, was passiert ist und was man tun kann. Der technische Text steht
// aufklappbar darunter — nicht versteckt (man braucht ihn für einen Fehlerbericht),
// aber auch nicht als Erstes im Gesicht.
export function RouteFehler({ titel, text, fehler, onErneut }: {
titel: string
text: string
fehler?: unknown
onErneut?: () => void
}) {
const detail = fehler instanceof Error ? (fehler.stack || fehler.message) : fehler ? String(fehler) : null
return (
<div className="flex min-h-[50vh] items-center justify-center p-6">
<div className="mc-card max-w-lg space-y-4 p-6">
<div className="flex items-center gap-2.5">
<AlertTriangle className="h-5 w-5 shrink-0 text-amber-400" aria-hidden="true" />
<h1 className="font-space text-lg font-bold tracking-tight">{titel}</h1>
</div>
<p className="text-sm leading-relaxed text-muted-foreground">{text}</p>
<div className="flex flex-wrap gap-2 pt-1">
{onErneut && (
<button
onClick={onErneut}
className="flex h-9 cursor-pointer items-center gap-1.5 rounded-lg bg-primary px-4 text-xs font-semibold text-primary-foreground transition-colors hover:bg-primary/90"
>
<RotateCcw className="h-3.5 w-3.5" aria-hidden="true" /> Erneut versuchen
</button>
)}
<a
href="/cockpit"
className="flex h-9 items-center rounded-lg border border-border/60 bg-background/20 px-4 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent"
>
Zum Cockpit
</a>
</div>
{detail && (
<details className="pt-1">
<summary className="cursor-pointer text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 hover:text-foreground">
Technische Einzelheiten
</summary>
<pre className="scrollbar-thin mt-2 max-h-52 overflow-auto rounded-lg border border-border/40 bg-background/40 p-3 font-mono text-[10px] leading-relaxed text-muted-foreground">
{detail}
</pre>
</details>
)}
</div>
</div>
)
}
+3 -2
View File
@@ -311,9 +311,10 @@ export function SystemDrawer({ open, onClose, defaultTab = "maintenance" }: Syst
</div>
<button
onClick={onClose}
className="flex h-8 w-8 items-center justify-center rounded-lg border border-border/40 text-muted-foreground hover:bg-accent transition-colors"
aria-label="System-Schublade schließen"
className="flex h-8 w-8 items-center justify-center rounded-lg border border-border/40 text-muted-foreground hover:bg-accent transition-colors cursor-pointer"
>
<X className="h-4 w-4" />
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
+70
View File
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it } from "vitest"
import { hashUmleiten } from "./hashUmleitung"
import { NAV, navFuerPfad } from "@/nav"
function adresseSetzen(url: string) {
window.history.replaceState(null, "", url)
}
// Diese Brücke ist die einzige Verbindung zu Lesezeichen aus der Hash-Zeit. Sie sieht
// nach totem Code aus und wäre damit ein naheliegender Kandidat zum Wegräumen — die
// Tests machen sichtbar, was dabei kaputtginge.
describe("hashUmleiten()", () => {
beforeEach(() => adresseSetzen("/"))
it("biegt jedes alte Hash-Lesezeichen auf seinen neuen Pfad", () => {
for (const eintrag of NAV) {
adresseSetzen("/#" + eintrag.id)
hashUmleiten()
expect(window.location.pathname, `#${eintrag.id}`).toBe(eintrag.pfad)
}
})
it("verträgt die Schreibweise mit Schrägstrich (#/models)", () => {
adresseSetzen("/#/models")
hashUmleiten()
expect(window.location.pathname).toBe("/modelle")
})
it("behält vorhandene Suchparameter", () => {
adresseSetzen("/?system=logs#models")
hashUmleiten()
expect(window.location.pathname).toBe("/modelle")
expect(window.location.search).toBe("?system=logs")
})
it("fasst einen unbekannten Hash nicht an", () => {
adresseSetzen("/cockpit#irgendwas")
hashUmleiten()
expect(window.location.pathname).toBe("/cockpit")
})
it("tut ohne Hash gar nichts", () => {
adresseSetzen("/modelle?reiter=routing")
hashUmleiten()
expect(window.location.pathname + window.location.search).toBe("/modelle?reiter=routing")
})
})
describe("navFuerPfad()", () => {
it("findet den Eintrag zum exakten Pfad", () => {
expect(navFuerPfad("/modelle")?.id).toBe("models")
expect(navFuerPfad("/cockpit")?.id).toBe("dashboard")
})
// Damit /ideen/<id> die Sidebar weiterhin bei „Ideen" markiert und nicht ins Leere fällt.
it("ordnet Unterpfade dem übergeordneten Eintrag zu", () => {
expect(navFuerPfad("/ideen/id_9f3c")?.id).toBe("ideen")
})
it("liefert nichts für einen unbekannten Pfad", () => {
expect(navFuerPfad("/gibtsnicht")).toBeUndefined()
})
// Der Grund für die Längen-Sortierung: ein kurzer Pfad darf einen längeren nicht
// überstimmen, sonst gewinnt der erstbeste Präfix-Treffer.
it("nimmt bei mehreren Treffern den längsten Pfad", () => {
const treffer = navFuerPfad("/modelle")
expect(treffer?.pfad).toBe("/modelle")
})
})
+26
View File
@@ -0,0 +1,26 @@
// Alte Hash-Lesezeichen auf die neuen Pfade umbiegen (v3-Umbau P2).
//
// Bis zum 28.08.2026 lebte die Ansicht im URL-Hash (`#models`). Das war teilbar und
// reload-fest — aber nur auf View-Ebene; für den Zustand DARUNTER (offene Idee, Reiter,
// Drawer) war kein Platz. Mit dem Router zieht das in echte Pfade um.
//
// Wer ein altes Lesezeichen hat, soll nichts merken: Diese Funktion läuft EINMAL vor dem
// Router-Mount und ersetzt `#models` durch `/modelle` — per `replaceState`, damit die
// Zurück-Taste nicht in einer Umleitungs-Schleife landet.
//
// Die Tabelle darf mit der Zeit schrumpfen, aber nicht kommentarlos: Solange jemand ein
// Lesezeichen von früher haben könnte, ist sie die einzige Brücke dorthin.
import { NAV, type ViewId } from "@/nav"
const ALT_ZU_PFAD: Record<ViewId, string> = Object.fromEntries(
NAV.map((n) => [n.id, n.pfad]),
) as Record<ViewId, string>
export function hashUmleiten(): void {
const hash = window.location.hash.replace(/^#\/?/, "")
if (!hash) return
const ziel = ALT_ZU_PFAD[hash as ViewId]
if (!ziel) return
window.history.replaceState(null, "", ziel + window.location.search)
}
+13 -4
View File
@@ -1,13 +1,19 @@
import React from "react"
import ReactDOM from "react-dom/client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import App from "./App"
import { RouterProvider } from "@tanstack/react-router"
import { router } from "./app/router"
import { AppErrorBoundary } from "./components/AppErrorBoundary"
import { hashUmleiten } from "./lib/hashUmleitung"
import { veralteteVersionAbfangen } from "./lib/veralteteVersion"
import "./index.css"
// Muss VOR dem ersten Rendern hängen: Nach einem Deploy sucht ein offener Tab
// Nachlade-Chunks des alten Builds, die es nicht mehr gibt (siehe lib/veralteteVersion.ts).
// Beides MUSS vor dem ersten Rendern laufen:
// · hashUmleiten — alte Lesezeichen (#models) landen auf dem neuen Pfad (/modelle),
// bevor der Router die Adresse zum ersten Mal liest (P2).
// · veralteteVersionAbfangen — nach einem Deploy sucht ein offener Tab Nachlade-Chunks
// des alten Builds, die es nicht mehr gibt (P1).
hashUmleiten()
veralteteVersionAbfangen()
// Zentraler Daten-Layer: Caching, Dedup, Retry, Polling pro Query-Hook.
@@ -21,11 +27,14 @@ const queryClient = new QueryClient({
},
})
// AppErrorBoundary bleibt als LETZTES Netz stehen — für Fehler außerhalb jeder Route
// (Provider, Router selbst). Innerhalb der Routen fängt seit P2 jede Ansicht für sich
// (app/router.tsx → errorComponent), damit ein Absturz nicht die ganze Shell mitnimmt.
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<AppErrorBoundary>
<QueryClientProvider client={queryClient}>
<App />
<RouterProvider router={router} />
</QueryClientProvider>
</AppErrorBoundary>
</React.StrictMode>,
+55 -46
View File
@@ -1,46 +1,55 @@
import {
LayoutDashboard,
Boxes,
Inbox,
History,
Library,
Plug,
Bot,
SquareTerminal,
HelpCircle,
Lightbulb,
type LucideIcon,
} from "lucide-react"
export type ViewId = "dashboard" | "ideen" | "auftraege" | "skills" | "models" | "wissen" | "chronik" | "connect" | "agent" | "konsole" | "guide"
export type NavGroup = "Operativ" | "Wissen" | "Werkzeuge" | "Wartung"
export interface NavItem {
id: ViewId
label: string
hint: string
icon: LucideIcon
group: NavGroup
}
// Eine Quelle der Wahrheit für Sidebar UND Command-Palette.
export const NAV: NavItem[] = [
{ id: "dashboard", label: "Cockpit", hint: "Deine Box auf einen Blick", icon: LayoutDashboard, group: "Operativ" },
// Der Haupteingang: Idee → Konzept → Repo → Zed. Steht bewusst direkt unter dem
// Cockpit — hier fängt jede Arbeit an (eigene Seite seit 25.07.2026).
// Hinweis bewusst kurz: die App zeigt ihn oben in Großbuchstaben, direkt über der
// Seitenüberschrift „Ideen" — ein langer Satz stand dort dreifach gedoppelt.
{ id: "ideen", label: "Ideen", hint: "Schreib hin, was entstehen soll", icon: Lightbulb, group: "Operativ" },
{ id: "auftraege", label: "Auftragsbuch", hint: "Vorschläge der Box — annehmen oder ablehnen", icon: Inbox, group: "Operativ" },
{ id: "skills", label: "Skills & Jobs", hint: "Autonome Skills manuell auslösen", icon: Bot, group: "Operativ" },
{ id: "chronik", label: "Chronik", hint: "Was die Box von allein getan hat + Zeitmaschine", icon: History, group: "Operativ" },
{ id: "wissen", label: "Wissen", hint: "Lucys Wissens-Vault (Traum-Notizen)", icon: Library, group: "Wissen" },
{ id: "connect", label: "Verbinden", hint: "IDE-/Agent-Configs erzeugen", icon: Plug, group: "Werkzeuge" },
{ id: "konsole", label: "Konsole", hint: "Direkte Box-Shell (SSH-artig)", icon: SquareTerminal, group: "Werkzeuge" },
{ id: "guide", label: "Anleitung", hint: "Einrichten & Vibe-Coding", icon: HelpCircle, group: "Werkzeuge" },
{ id: "models", label: "Modelle", hint: "Speicher, laden & Rollen", icon: Boxes, group: "Wartung" },
{ id: "agent", label: "Hermes", hint: "Agent-Status & Verdrahtung", icon: Bot, group: "Wartung" },
]
import {
LayoutDashboard,
Boxes,
Inbox,
History,
Library,
Plug,
Bot,
SquareTerminal,
HelpCircle,
Lightbulb,
type LucideIcon,
} from "lucide-react"
export type ViewId = "dashboard" | "ideen" | "auftraege" | "skills" | "models" | "wissen" | "chronik" | "connect" | "agent" | "konsole" | "guide"
export type NavGroup = "Operativ" | "Wissen" | "Werkzeuge" | "Wartung"
export interface NavItem {
id: ViewId
/** Echter URL-Pfad (v3-Umbau P2). Vorher war die Ansicht nur ein Hash (#models) —
* teilbar auf View-Ebene, aber ohne Platz für den Zustand DARUNTER. */
pfad: string
label: string
hint: string
icon: LucideIcon
group: NavGroup
}
// Eine Quelle der Wahrheit für Sidebar UND Command-Palette.
export const NAV: NavItem[] = [
{ id: "dashboard", pfad: "/cockpit", label: "Cockpit", hint: "Deine Box auf einen Blick", icon: LayoutDashboard, group: "Operativ" },
// Der Haupteingang: Idee → Konzept → Repo → IDE. Steht bewusst direkt unter dem
// Cockpit — hier fängt jede Arbeit an (eigene Seite seit 25.07.2026).
// Hinweis bewusst kurz: die App zeigt ihn oben in Großbuchstaben, direkt über der
// Seitenüberschrift „Ideen" — ein langer Satz stand dort dreifach gedoppelt.
{ id: "ideen", pfad: "/ideen", label: "Ideen", hint: "Schreib hin, was entstehen soll", icon: Lightbulb, group: "Operativ" },
{ id: "auftraege", pfad: "/auftraege", label: "Auftragsbuch", hint: "Vorschläge der Box — annehmen oder ablehnen", icon: Inbox, group: "Operativ" },
{ id: "skills", pfad: "/skills", label: "Skills & Jobs", hint: "Autonome Skills manuell auslösen", icon: Bot, group: "Operativ" },
{ id: "chronik", pfad: "/chronik", label: "Chronik", hint: "Was die Box von allein getan hat + Zeitmaschine", icon: History, group: "Operativ" },
{ id: "wissen", pfad: "/wissen", label: "Wissen", hint: "Lucys Wissens-Vault (Traum-Notizen)", icon: Library, group: "Wissen" },
{ id: "connect", pfad: "/verbinden", label: "Verbinden", hint: "IDE-/Agent-Configs erzeugen", icon: Plug, group: "Werkzeuge" },
{ id: "konsole", pfad: "/konsole", label: "Konsole", hint: "Direkte Box-Shell (SSH-artig)", icon: SquareTerminal, group: "Werkzeuge" },
{ id: "guide", pfad: "/anleitung", label: "Anleitung", hint: "Einrichten & Vibe-Coding", icon: HelpCircle, group: "Werkzeuge" },
{ id: "models", pfad: "/modelle", label: "Modelle", hint: "Speicher, laden & Rollen", icon: Boxes, group: "Wartung" },
{ id: "agent", pfad: "/agent", label: "Hermes", hint: "Agent-Status & Verdrahtung", icon: Bot, group: "Wartung" },
]
/** Nav-Eintrag zu einem Pfad — für Kopfzeile und aktive Markierung.
* Längster Treffer gewinnt, damit /ideen/<id> beim Eintrag „Ideen" landet. */
export function navFuerPfad(pfad: string): NavItem | undefined {
return [...NAV]
.sort((a, b) => b.pfad.length - a.pfad.length)
.find((n) => pfad === n.pfad || pfad.startsWith(n.pfad + "/"))
}
+9 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react"
import { useNavigate, useSearch } from "@tanstack/react-router"
import {
Loader2, AlertTriangle, ChevronDown, ChevronRight, Clock, Hammer, Lightbulb, Send,
HelpCircle, RotateCcw, Square, ArrowUp, ArrowDown, Code2, FileText, GitFork, X, Layers,
@@ -114,7 +115,14 @@ function IdeenSection() {
const [ziel, setZiel] = useState<"lxc" | "docker">("lxc")
const [sending, setSending] = useState(false)
const [busy, setBusy] = useState<Record<string, boolean>>({})
const [openLog, setOpenLog] = useState<string | null>(null)
// v3-Umbau P2 (Befund B-05): Die aufgeklappte Karte lag in useState. Wer waehrend
// eines laufenden Workers neu lud — und genau dann schaut man hin — verlor seinen
// Platz in der Liste. Jetzt steht sie in der URL (/ideen?offen=<id>) und ist teilbar.
const navigate = useNavigate()
const suche = useSearch({ from: "/ideen" })
const openLog = suche.offen ?? null
const setOpenLog = (id: string | null) =>
navigate({ to: "/ideen", search: (alt) => ({ ...alt, offen: id ?? undefined }), replace: true })
const [openKonzept, setOpenKonzept] = useState<string | null>(null)
const [antworten, setAntworten] = useState<Record<string, string>>({})
+9 -2
View File
@@ -1,4 +1,4 @@
import { useState } from "react"
import { useNavigate, useSearch } from "@tanstack/react-router"
import { cn } from "@/lib/utils"
import { JobsBar } from "@/components/models/JobsBar"
import { LaneEditor } from "@/components/models/LaneEditor"
@@ -16,7 +16,14 @@ const TABS = [
type TabId = (typeof TABS)[number]["id"]
export function ModelsView() {
const [tab, setTab] = useState<TabId>("werkbank")
// v3-Umbau P2: Der Reiter lag in useState und war damit nicht teilbar und nicht
// reload-fest — wer jemandem „schau dir das Routing an" schicken wollte, schickte
// die Werkbank. Jetzt steht er in der URL (/modelle?reiter=routing).
const navigate = useNavigate()
const { reiter } = useSearch({ from: "/modelle" })
const tab: TabId = reiter ?? "werkbank"
const setTab = (t: TabId) =>
navigate({ to: "/modelle", search: (alt) => ({ ...alt, reiter: t }), replace: true })
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row justify-between sm:items-center gap-4">