Files
mission-control-v2/frontend/src/app/router.tsx
T
HitonabiandClaude Opus 5 24a0694b80 feat(palette): P6 — die Palette wird zum Bedienwerkzeug (§4.6)
Bisher listete Strg+K die elf Navigations-Eintraege: ein Sprungbrett. Jetzt tippt
man, WAS man will, nicht wo es liegt.

  Gehe zu   die 11 Ziele — direkt aus NAV, damit es nie zwei Listen gibt
  Tun       Wartung/Logs oeffnen, Updates suchen, Snapshot, Motor neu starten
  Finden    das Getippte in Chronik oder Wissens-Vault suchen
  An Lucy   das Getippte als Idee erfassen

## Die Sicherheitsregel ist verdrahtet, nicht nur beschrieben

Die Palette ist schnell genug, dass "Strg+K, mot, Enter" den Motor neu startet,
bevor man es zu Ende gedacht hat. Jeder eingreifende Befehl traegt darum
`rueckfrage`; die Oberflaeche haelt dann an und sagt erst, was passieren WIRD:

  "Alle geladenen Modelle fallen dabei aus dem Speicher und muessen neu aufwaermen —
   das dauert je nach Modell ueber 20 Sekunden. Laufende Antworten brechen ab."

Ein Test prueft die Regel generisch: Jeder Befehl, dessen Label auf "neu starten",
"loeschen", "deploy", "reboot" o. AE. passt, MUSS eine Rueckfrage haben. Wer kuenftig
einen eingreifenden Befehl ohne Rueckfrage ergaenzt, macht die Ampel rot.

"An Lucy" erfasst mit `art: "idee"` — durchdenken, nicht bauen. `"projekt"` wuerde
sofort ein Repo anlegen; das darf aus einem Tastendruck nie passieren. Auch das ist
getestet.

## Die "Finden"-Eintraege fuehren nicht ins Leere

Sie brauchten erst ein Ziel — sonst haette ich genau die leere Leitung gebaut, die ich
beim Werkzeug-Verlauf vermieden habe:

  ChronikView   neues Suchfeld, filtert ueber Betreff/Text/Quelle, Wort in der URL
                (/chronik?q=…), Trefferzahl in der Ueberschrift, "Filter aufheben"
                im Leer-Zustand
  WissenView    hatte das Suchfeld schon, aber nur lokal — jetzt aus /wissen?suche=
                gespeist und damit teilbar

## Verifiziert im Browser

  Strg+K oeffnet, "Gehe zu" (11) + "Tun" (5); leere Gruppen bleiben verborgen
  Text tippen -> "Finden" (2) und "An Lucy" (1) erscheinen
  "Motor neu starten" traegt den Chip "fragt nach"; Auswahl zeigt die Rueckfrage
    mit vollem Folgentext — es wird NICHTS gestartet
  "Abbrechen" fuehrt zurueck in die Liste, keine Meldung, kein Aufruf

54 Tests gruen (10 neue) · ESLint 0 Fehler · Einstieg 118 762 B gzip / Budget 125 000.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 09:56:26 +02:00

205 lines
8.1 KiB
TypeScript

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("@/features/ideen/IdeenAnsicht").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 />)
/** Chronik: `?q=` filtert den Verlauf — Ziel der „Finden"-Einträge der Palette (P6). */
export interface ChronikSuche {
q?: string
}
const chronikRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/chronik",
validateSearch: (roh: Record<string, unknown>): ChronikSuche =>
typeof roh.q === "string" && roh.q ? { q: roh.q } : {},
component: () => <ChronikView />,
errorComponent: ({ error, reset }) => (
<RouteFehler titel="Die Chronik ist abgestürzt" text="Der Rest läuft weiter." fehler={error} onErneut={reset} />
),
})
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=` macht jede Notiz verlinkbar, `?suche=` trägt das Suchwort
* aus der Befehlspalette herein (P6). */
export interface WissenSuche {
datei?: string
suche?: string
}
const wissenRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/wissen",
validateSearch: (roh: Record<string, unknown>): WissenSuche => ({
...(typeof roh.datei === "string" && roh.datei ? { datei: roh.datei } : {}),
...(typeof roh.suche === "string" && roh.suche ? { suche: roh.suche } : {}),
}),
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
}
}