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>
1032 lines
56 KiB
TypeScript
1032 lines
56 KiB
TypeScript
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,
|
||
RefreshCw, ClipboardCheck, Check,
|
||
} from "lucide-react"
|
||
import { api, type IdeenErgebnis, type IdeenItem, type IdeenKonzept, type IdeenProjekt } from "@/lib/api"
|
||
import { Markdown } from "@/lib/markdown"
|
||
import { useIdeen, useIdeenLog, useQueryClient, qk } from "@/lib/queries"
|
||
import { useDialog } from "@/lib/useDialog"
|
||
import { cn } from "@/lib/utils"
|
||
import { fmtWhen } from "@/lib/format"
|
||
import { SectionLabel } from "@/components/ui/SectionLabel"
|
||
|
||
// Ideen-Queue (natives Hermes-Kanban) — eigene Seite seit 25.07.2026.
|
||
// Vorher steckte sie als 703-Zeilen-Abschnitt in der Auftragsbuch-Ansicht und war damit
|
||
// die MEHRHEIT einer Seite, die nach etwas anderem benannt war. Die beiden laufen in
|
||
// entgegengesetzte Richtungen: Auftragsbuch = die Box schlaegt DIR vor (Eingang),
|
||
// Ideen = DU beauftragst die Box (Ausgang). Seit dem Umbau ist dieser Weg der
|
||
// Haupteingang des Systems (Idee -> Konzept -> Repo -> Agentic IDE) und gehoert nach vorn.
|
||
|
||
// ── Ideen-Queue (natives Hermes-Kanban) ──────────────────────────────────────
|
||
// Eine Queue, drei Türen (Zentrale/Telegram/Lucy). Idee rein → die Box arbeitet sie
|
||
// selbst aus (Specifier) und baut (Werkstatt-Profil) → Code kommt als Karte zurück.
|
||
|
||
const IDEEN_STATE: Record<string, { label: string; cls: string; spin?: boolean }> = {
|
||
triage: { label: "wird ausgearbeitet …", cls: "text-violet-300 border-violet-500/40 bg-violet-500/10", spin: true },
|
||
todo: { label: "wartet", cls: "text-muted-foreground border-border/60 bg-background/30" },
|
||
scheduled: { label: "geplant", cls: "text-muted-foreground border-border/60 bg-background/30" },
|
||
ready: { label: "gleich dran", cls: "text-sky-300/80 border-sky-500/30 bg-sky-500/5" },
|
||
running: { label: "in Arbeit …", cls: "text-sky-300 border-sky-500/40 bg-sky-500/10", spin: true },
|
||
review: { label: "im Review", cls: "text-amber-300 border-amber-500/40 bg-amber-500/10" },
|
||
blocked: { label: "hängt — braucht dich", cls: "text-red-300 border-red-500/40 bg-red-500/10" },
|
||
done: { label: "fertig ✓", cls: "text-emerald-300 border-emerald-500/40 bg-emerald-500/10" },
|
||
}
|
||
|
||
// Welche Welt hat die Karte durchlaufen? Vorher stand auf Idee UND IDE-Projekt dasselbe
|
||
// „IDE" — man sah der fertigen Karte nicht an, ob sie nur durchdacht oder als Repo
|
||
// vorbereitet wurde (User 21.07.). Jetzt drei ehrliche Marken.
|
||
const ART_UI: Record<string, { label: string; cls: string; icon: typeof Hammer; title: string }> = {
|
||
box: {
|
||
label: "BOX", icon: Hammer,
|
||
cls: "border-border/60 bg-background/40 text-muted-foreground",
|
||
title: "Box-Welt: die Werkstatt baut das selbst. Das Ergebnis kommt als Patch-Vorschlag zurück — nichts geht ohne deinen Klick live.",
|
||
},
|
||
idee: {
|
||
label: "IDEE", icon: Lightbulb,
|
||
cls: "border-sky-500/40 bg-sky-500/10 text-sky-300",
|
||
title: "Nur durchdacht: die Box hat die Produkt-Idee mit voller Denk-Kaskade zu einem Konzept ausgearbeitet. Kein Gerüst, kein Bau — du liest es und entscheidest, ob ein Projekt draus wird.",
|
||
},
|
||
projekt: {
|
||
label: "IDE", icon: Code2,
|
||
cls: "border-violet-500/40 bg-violet-500/10 text-violet-300",
|
||
title: "IDE-Welt: die Box bereitet nur vor (Konzept + Repo + Doku, kein Code). Gebaut wird von dir in der Agentic IDE.",
|
||
},
|
||
}
|
||
|
||
// Wie die Frage einer hängenden Karte eingeleitet wird — je nach Block-Grund des Workers.
|
||
const FRAGE_HINT: Record<string, string> = {
|
||
needs_input: "Die Box hat eine Frage:",
|
||
capability: "Die Box kam hier nicht weiter:",
|
||
transient: "Die Aufgabe hakt:",
|
||
}
|
||
|
||
// Wie oft lag die Einordnung daneben? Der ganze zugeklappte Entwurf steht und fällt damit.
|
||
// Bewusst im Browser statt im Backend: zwei Zahlen brauchen keine Tabelle, keinen Endpunkt
|
||
// und keine Migration — und es geht niemanden ausser dem Nutzer etwas an. Sichtbar wird die
|
||
// Quote nur beim Aufklappen, damit sie beweisbar ist statt behauptet.
|
||
const QUOTE_KEY = "mc_ideen_quote"
|
||
|
||
function quoteLesen(): { gesendet: number; geaendert: number } {
|
||
try {
|
||
const d = JSON.parse(localStorage.getItem(QUOTE_KEY) || "{}")
|
||
return { gesendet: Number(d.gesendet) || 0, geaendert: Number(d.geaendert) || 0 }
|
||
} catch { return { gesendet: 0, geaendert: 0 } }
|
||
}
|
||
|
||
function quoteZaehlen(feld: "gesendet" | "geaendert"): void {
|
||
try {
|
||
const q = quoteLesen()
|
||
q[feld] += 1
|
||
localStorage.setItem(QUOTE_KEY, JSON.stringify(q))
|
||
} catch { /* privater Modus o. ä. — die Zählung ist Beiwerk, nie ein Grund zu scheitern */ }
|
||
}
|
||
|
||
function IdeenSection() {
|
||
const { data, isLoading } = useIdeen()
|
||
const qc = useQueryClient()
|
||
const { showAlert, showConfirm, dialogElement } = useDialog()
|
||
const [text, setText] = useState("")
|
||
// GENAU EINE Auswahl aus drei, in zwei Bereichen (26.07.2026):
|
||
// denken → du baust selbst, aber erst ein Konzept. Auf „gefällt mir" wird daraus ein Repo.
|
||
// repo → du baust selbst, Konzept + Gitea-Repo sofort.
|
||
// box → die Box baut, Ergebnis kommt als Patch-Vorschlag.
|
||
//
|
||
// Vorher (25.07.) waren das zwei angeblich unabhängige Dimensionen: „wer baut" plus ein
|
||
// Haken „nur denken". Das war FALSCH modelliert — `weiterfuehren` macht aus einer
|
||
// durchdachten Idee genau die IDE-Projekt-Karte, die `repo` direkt anlegt. Es sind also
|
||
// zwei Stufen EINES Weges, nicht zwei Achsen. Und die Kombination box+denken schob einen
|
||
// still in die IDE-Welt (submit setzte welt:"ide"), ohne das irgendwo zu sagen.
|
||
const [modus, setModus] = useState<"denken" | "repo" | "box">("denken")
|
||
const [tiefe, setTiefe] = useState<"schlank" | "gruendlich">("schlank")
|
||
// Zugeklappt statt Assistent (26.07.2026): der Entscheidungsbaum EXISTIERT — Wer baut das?,
|
||
// und nur im IDE-Ast: wie startest du? — aber du läufst ihn nur, wenn die Einordnung
|
||
// danebenlag. Im Normalfall steht da eine Zeile und du drückst Enter.
|
||
const [offen, setOffen] = useState(false)
|
||
// Hat die Box den Text wirklich gelesen? Steuert nur das Etikett „gelesen" in der
|
||
// zugeklappten Zeile. Ohne Einordnung (Text zu kurz, Aufruf gescheitert) steht dort die
|
||
// Voreinstellung — und die darf sich nicht als Verständnis ausgeben.
|
||
const [vorgeschlagen, setVorgeschlagen] = useState(false)
|
||
const isIde = modus !== "box" // denken UND repo laufen über das projektstart-Profil
|
||
const isProjekt = modus === "repo" // nur DANN sind Tiefe und Ziel jetzt schon nötig
|
||
// Ziel-Plattform des Projekts (nur IDE-Welt): läuft es als LXC-Dienst oder in Docker?
|
||
const [ziel, setZiel] = useState<"lxc" | "docker">("lxc")
|
||
const [sending, setSending] = useState(false)
|
||
const [busy, setBusy] = useState<Record<string, boolean>>({})
|
||
// 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>>({})
|
||
|
||
const items = data?.items ?? []
|
||
const reload = () => qc.invalidateQueries({ queryKey: qk.ideen })
|
||
|
||
// Die Box liest mit und wählt vor. Gemessen ~500 ms auf dem dauerwarmen Hirn, deshalb
|
||
// reicht eine kurze Tipp-Pause. Bewusst ein VORSCHLAG: er setzt nur die Auswahl, die du
|
||
// ohnehin siehst und mit einem Klick überschreibst. Scheitert er, passiert schlicht nichts
|
||
// — kein Ladebalken, keine Fehlermeldung, das Tippen wird nie blockiert.
|
||
useEffect(() => {
|
||
const t = text.trim()
|
||
if (t.length < 12) { setVorgeschlagen(false); return }
|
||
let verworfen = false
|
||
const timer = setTimeout(async () => {
|
||
try {
|
||
const r = await api<{ ok: boolean; wer?: "box" | "ide"; tiefe?: "schlank" | "gruendlich"; nur_denken?: boolean }>(
|
||
"/api/ideen/einordnen", { method: "POST", body: JSON.stringify({ text: t }) })
|
||
if (verworfen || !r?.ok) return
|
||
// Die drei Karten aus der Einordnung ableiten: eine Frage („lohnt sich…?") wird zum
|
||
// Durchdenken, ein eigenständiges Programm zum Repo, alles am Heimlabor zur Box.
|
||
setModus(r.nur_denken ? "denken" : r.wer === "ide" ? "repo" : "box")
|
||
if (r.tiefe) setTiefe(r.tiefe)
|
||
setVorgeschlagen(true)
|
||
} catch { /* stumm — die Voreinstellung bleibt einfach stehen */ }
|
||
}, 700)
|
||
return () => { verworfen = true; clearTimeout(timer) }
|
||
}, [text])
|
||
|
||
// Polling-Log nur aktivieren, wenn Idee triage/running und geöffnet
|
||
const logEnabled = openLog !== null && (data?.items.find((i) => i.id === openLog)?.status === "triage" || data?.items.find((i) => i.id === openLog)?.status === "running")
|
||
const logData = useIdeenLog(openLog ?? "", logEnabled)
|
||
|
||
async function submit() {
|
||
const t = text.trim()
|
||
if (t.length < 3 || sending) return
|
||
setSending(true)
|
||
try {
|
||
// Lange Eingaben: Kurzfassung als Titel, alles als Notiz — der Specifier der Box
|
||
// macht daraus ohnehin einen sauberen Auftrag.
|
||
const titel = t.length > 160 ? t.slice(0, 157) + "…" : t
|
||
await api("/api/ideen", { method: "POST", body: JSON.stringify({
|
||
titel, notiz: t.length > 160 ? t : "",
|
||
// Abbildung auf die unveränderte API. Tiefe und Ziel gehen NUR beim Repo-Weg mit —
|
||
// beim Durchdenken fragt die Weiter-Leiste sie später beim „gefällt mir", und dann
|
||
// weiss man mehr als jetzt.
|
||
welt: isIde ? "ide" : "box",
|
||
tiefe: isProjekt ? (tiefe === "gruendlich" ? "gruendlich" : "simpel") : "",
|
||
ziel: isProjekt ? ziel : "",
|
||
art: modus === "denken" ? "idee" : "projekt",
|
||
}) })
|
||
quoteZaehlen("gesendet")
|
||
setText("")
|
||
setVorgeschlagen(false)
|
||
setOffen(false)
|
||
reload()
|
||
} catch (e: any) {
|
||
showAlert("Das hat nicht geklappt", String(e?.message || e))
|
||
} finally {
|
||
setSending(false)
|
||
}
|
||
}
|
||
|
||
// Sammel-Aufräumen: fertige Karten wegräumen, ohne 18-mal auf das X zu klicken.
|
||
// Fragt vorher nach — und sagt dabei ehrlich, dass nichts gelöscht wird, sondern
|
||
// ins Kanban-Archiv wandert. Das Backend fasst ausschliesslich `done` an.
|
||
function archiveAlle(projekt: string, anzahl: number, was: string) {
|
||
showConfirm(
|
||
`${anzahl} fertige Karte${anzahl === 1 ? "" : "n"} wegräumen?`,
|
||
`${was} verschwindet aus der Liste. Gelöscht wird nichts — die Karten wandern ins ` +
|
||
`Archiv der Queue und bleiben dort lesbar. Laufende, hängende und wartende Karten ` +
|
||
`bleiben unberührt.`,
|
||
async () => {
|
||
const key = "arch:" + (projekt || "*")
|
||
setBusy((b) => ({ ...b, [key]: true }))
|
||
try {
|
||
const r = await api<{ archiviert: number; fehlgeschlagen: { titel: string }[] }>(
|
||
"/api/ideen/archivieren-alle", { method: "POST", body: JSON.stringify({ projekt }) })
|
||
if (r.fehlgeschlagen?.length) {
|
||
showAlert("Teilweise erledigt",
|
||
`${r.archiviert} weggeräumt, ${r.fehlgeschlagen.length} nicht: ` +
|
||
r.fehlgeschlagen.map((f) => f.titel).join(", "))
|
||
}
|
||
reload()
|
||
} catch (e: any) {
|
||
showAlert("Das hat nicht geklappt", String(e?.message || e))
|
||
} finally {
|
||
setBusy((b) => ({ ...b, [key]: false }))
|
||
}
|
||
},
|
||
)
|
||
}
|
||
|
||
async function archive(id: string) {
|
||
setBusy((b) => ({ ...b, [id]: true }))
|
||
try {
|
||
await api("/api/ideen/archivieren", { method: "POST", body: JSON.stringify({ id }) })
|
||
reload()
|
||
} catch (e: any) {
|
||
showAlert("Das hat nicht geklappt", String(e?.message || e))
|
||
} finally {
|
||
setBusy((b) => ({ ...b, [id]: false }))
|
||
}
|
||
}
|
||
|
||
// Karten-Steuerung (Stopp / Neuer Versuch / Prio) — ein Helfer für alle drei Endpunkte.
|
||
async function steuer(prefix: string, id: string, pfad: string, body: object) {
|
||
setBusy((b) => ({ ...b, [prefix + id]: true }))
|
||
try {
|
||
await api(pfad, { method: "POST", body: JSON.stringify(body) })
|
||
reload()
|
||
} catch (e: any) {
|
||
showAlert("Das hat nicht geklappt", String(e?.message || e))
|
||
} finally {
|
||
setBusy((b) => ({ ...b, [prefix + id]: false }))
|
||
}
|
||
}
|
||
|
||
// Hängende Aufgabe beantworten: die Antwort geht als Kommentar an die Karte, der Worker
|
||
// macht damit weiter (Ausweg aus der Sackgasse „fragt, aber niemand kann antworten").
|
||
async function answer(id: string) {
|
||
const t = (antworten[id] || "").trim()
|
||
if (t.length < 1 || busy["ans:" + id]) return
|
||
setBusy((b) => ({ ...b, ["ans:" + id]: true }))
|
||
try {
|
||
await api("/api/ideen/antwort", { method: "POST", body: JSON.stringify({ id, antwort: t }) })
|
||
setAntworten((a) => ({ ...a, [id]: "" }))
|
||
reload()
|
||
} catch (e: any) {
|
||
showAlert("Das hat nicht geklappt", String(e?.message || e))
|
||
} finally {
|
||
setBusy((b) => ({ ...b, ["ans:" + id]: false }))
|
||
}
|
||
}
|
||
|
||
// EINE Zeilen-Darstellung für alle Sichten (Projekt-Gruppen + Einzelkarten) — damit
|
||
// Antwortfeld, Live-Log und Ergebnis-Panel überall identisch funktionieren.
|
||
const zeile = (it: IdeenItem) => {
|
||
const ui = IDEEN_STATE[it.status] ?? { label: it.status, cls: "text-muted-foreground border-border/60 bg-background/30" }
|
||
const isOpen = openLog === it.id
|
||
// Aufklappbar: laufende Karten zeigen das Live-Log, FERTIGE das Ergebnis
|
||
// (User-Wunsch 15.07.: „Dinge werden getestet, ich sehe das Ergebnis nicht").
|
||
const canOpen = it.status === "triage" || it.status === "running" || it.status === "done"
|
||
const blocked = it.status === "blocked"
|
||
const answering = !!busy["ans:" + it.id]
|
||
// Hänger-Verdacht: läuft, aber seit >10 min kein Lebenszeichen (Heartbeat) — der
|
||
// Prozess kann trotzdem „leben" (wartet z. B. auf einen selbst gestarteten Server).
|
||
const haengt = it.status === "running" && (it.puls_alter ?? 0) > 600
|
||
const wartend = it.status === "todo" || it.status === "ready" || it.status === "scheduled"
|
||
return (
|
||
<li key={it.id} className={cn("flex flex-col gap-2 rounded-xl border bg-background/30 px-3 py-2", blocked ? "border-red-500/30" : haengt ? "border-amber-500/40" : "border-border/40")}>
|
||
<div className="flex items-center gap-2">
|
||
<span className={cn("flex shrink-0 items-center gap-1.5 rounded-lg border px-2 py-0.5 text-[10px] font-bold", ui.cls)}>
|
||
{ui.spin && <Loader2 className="h-3 w-3 animate-spin" />}
|
||
{ui.label}
|
||
</span>
|
||
{haengt && (
|
||
<span className="flex shrink-0 items-center gap-1 rounded-lg border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-bold text-amber-300"
|
||
title="Der Worker lebt, gibt aber kein Lebenszeichen mehr — vermutlich wartet er auf etwas, das nie kommt. „Neuer Versuch“ startet die Karte frisch.">
|
||
<AlertTriangle className="h-3 w-3" /> {Math.round((it.puls_alter ?? 0) / 60)} min ohne Regung
|
||
</span>
|
||
)}
|
||
{(it.prio ?? 0) !== 0 && (
|
||
<span className="shrink-0 rounded bg-background/50 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground"
|
||
title="Priorität — höher kommt zuerst dran (bei gleicher Prio: Erstellzeit)">P{it.prio}</span>
|
||
)}
|
||
{(() => {
|
||
const a = ART_UI[it.art || (it.welt === "ide" ? "projekt" : "box")]
|
||
if (!a) return null
|
||
const Icon = a.icon
|
||
return (
|
||
<span className={cn("flex shrink-0 items-center gap-1 rounded-lg border px-2 py-0.5 text-[10px] font-bold", a.cls)} title={a.title}>
|
||
<Icon className="h-3 w-3" /> {a.label}
|
||
</span>
|
||
)
|
||
})()}
|
||
{/* Tooltip = der volle TITEL (der ist abgeschnitten), nicht der Auftragstext —
|
||
der beginnt mit Vorspann-Textbausteinen und half beim Lesen nie. */}
|
||
<span className="min-w-0 flex-1 truncate text-xs text-foreground" title={it.titel}>{it.titel}</span>
|
||
<span className="flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground/70"><Clock className="h-3 w-3" />{fmtWhen(it.erstellt)}</span>
|
||
{wartend && (
|
||
<span className="flex shrink-0 items-center">
|
||
<button
|
||
disabled={!!busy["pr:" + it.id]}
|
||
onClick={() => steuer("pr:", it.id, "/api/ideen/prio", { id: it.id, richtung: "hoch" })}
|
||
title="Vorziehen (Prio +1)"
|
||
className="rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer disabled:opacity-50">
|
||
<ArrowUp className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button
|
||
disabled={!!busy["pr:" + it.id]}
|
||
onClick={() => steuer("pr:", it.id, "/api/ideen/prio", { id: it.id, richtung: "runter" })}
|
||
title="Zurückstellen (Prio −1)"
|
||
className="rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer disabled:opacity-50">
|
||
<ArrowDown className="h-3.5 w-3.5" />
|
||
</button>
|
||
</span>
|
||
)}
|
||
{(it.status === "running" || blocked) && (
|
||
<button
|
||
disabled={!!busy["rt:" + it.id]}
|
||
onClick={() =>
|
||
showConfirm("Neuer Versuch?",
|
||
it.status === "running"
|
||
? `Der laufende Worker von '${it.titel.slice(0, 60)}' wird beendet (samt gestarteter Test-Prozesse) und die Karte frisch gestartet. Bisheriger Zwischenstand im Workspace bleibt liegen.`
|
||
: `Die Karte '${it.titel.slice(0, 60)}' wird entsperrt und läuft weiter, sobald ein Platz frei ist.`,
|
||
() => steuer("rt:", it.id, "/api/ideen/retry", { id: it.id }))}
|
||
title="Neuer Versuch — Karte frisch starten"
|
||
className={cn("shrink-0 rounded-md p-1 transition-colors cursor-pointer disabled:opacity-50",
|
||
haengt ? "text-amber-300 hover:text-amber-200" : "text-muted-foreground/60 hover:text-foreground")}>
|
||
{busy["rt:" + it.id] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RotateCcw className="h-3.5 w-3.5" />}
|
||
</button>
|
||
)}
|
||
{(it.status === "running" || wartend || it.status === "triage") && (
|
||
<button
|
||
disabled={!!busy["st:" + it.id]}
|
||
onClick={() =>
|
||
showConfirm("Karte stoppen?",
|
||
`'${it.titel.slice(0, 60)}' wird angehalten${it.status === "running" ? " und der laufende Worker beendet" : ""}. Sie bleibt als gestoppt im Board und kann jederzeit per „Neuer Versuch“ weiterlaufen.`,
|
||
() => steuer("st:", it.id, "/api/ideen/stopp", { id: it.id }))}
|
||
title="Stoppen — Karte anhalten"
|
||
className="shrink-0 rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-red-300 cursor-pointer disabled:opacity-50">
|
||
{busy["st:" + it.id] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Square className="h-3.5 w-3.5" />}
|
||
</button>
|
||
)}
|
||
{(it.status === "done" || blocked) && (
|
||
<button
|
||
disabled={!!busy[it.id]}
|
||
onClick={() => archive(it.id)}
|
||
title={blocked ? "Aufgeben und aus der Liste räumen (wandert ins Kanban-Archiv)" : "Aus der Liste räumen (wandert ins Kanban-Archiv)"}
|
||
className="shrink-0 rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer disabled:opacity-50">
|
||
{busy[it.id] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
|
||
</button>
|
||
)}
|
||
{it.welt === "ide" && (it.status === "running" || it.status === "done") && (
|
||
<button
|
||
onClick={() => setOpenKonzept(openKonzept === it.id ? null : it.id)}
|
||
title="Konzept ansehen (das ausgearbeitete KONZEPT.md)"
|
||
className={cn("flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[10px] font-bold transition-colors cursor-pointer",
|
||
openKonzept === it.id ? "text-violet-300" : "text-violet-300/70 hover:text-violet-200")}>
|
||
<FileText className="h-3.5 w-3.5" /> Konzept
|
||
</button>
|
||
)}
|
||
{canOpen && (
|
||
<button
|
||
onClick={() => setOpenLog(isOpen ? null : it.id)}
|
||
title={it.status === "done" ? "Ergebnis anzeigen" : "Worker-Log anzeigen"}
|
||
className="shrink-0 rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer">
|
||
{isOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{openKonzept === it.id && (
|
||
<KonzeptPanel
|
||
id={it.id}
|
||
istIdee={it.art === "idee" && it.status === "done"}
|
||
onWeiter={reload}
|
||
/>
|
||
)}
|
||
|
||
{it.status === "running" && it.notiz && (
|
||
<p className="-mt-1 ml-1 text-[11px] italic leading-relaxed text-sky-200/80" title="Jüngste Zwischenmeldung des Workers (Heartbeat)">
|
||
„{it.notiz}“
|
||
</p>
|
||
)}
|
||
{(it.status === "todo" || it.status === "scheduled" || it.status === "ready") && (it.wartet_auf?.length ?? 0) > 0 && (
|
||
<p className="-mt-1 ml-1 text-[10px] text-muted-foreground/70">
|
||
→ wartet auf: {it.wartet_auf!.join(" · ")}
|
||
</p>
|
||
)}
|
||
|
||
{blocked && (
|
||
<div className="space-y-2 border-t border-red-500/20 pt-2">
|
||
<p className="flex items-start gap-1.5 text-[11px] leading-relaxed text-red-200/90">
|
||
<HelpCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-300" />
|
||
<span>
|
||
<b>{FRAGE_HINT[it.frage_kind || ""] ?? "Die Aufgabe hängt und wartet auf dich:"}</b>{" "}
|
||
{it.frage
|
||
? <span className="text-foreground/90">{it.frage}</span>
|
||
: <span className="text-muted-foreground italic">kein genauer Grund hinterlegt — antworte einfach mit einer Entscheidung, dann läuft sie weiter.</span>}
|
||
</span>
|
||
</p>
|
||
<div className="flex gap-2">
|
||
<input
|
||
value={antworten[it.id] || ""}
|
||
onChange={(e) => setAntworten((a) => ({ ...a, [it.id]: e.target.value }))}
|
||
onKeyDown={(e) => { if (e.key === "Enter") answer(it.id) }}
|
||
placeholder="Deine Antwort — die Box macht damit weiter"
|
||
maxLength={2000}
|
||
className="h-8 min-w-0 flex-1 rounded-lg border border-border/60 bg-background/60 px-2.5 text-xs text-foreground placeholder:text-muted-foreground/50 focus:border-red-400/50 focus:outline-none"
|
||
/>
|
||
<button
|
||
onClick={() => answer(it.id)}
|
||
disabled={answering || (antworten[it.id] || "").trim().length < 1}
|
||
className="flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 text-[10px] font-bold uppercase tracking-wide text-emerald-300 transition-all hover:bg-emerald-500/20 cursor-pointer disabled:opacity-50">
|
||
{answering ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />} Antworten & weiter
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{isOpen && canOpen && it.status !== "done" && (
|
||
<div className="ml-2 -mt-1">
|
||
<div className="rounded-lg border border-border/50 bg-background/50 p-2">
|
||
{logEnabled && logData.isLoading ? (
|
||
<p className="text-[10px] text-muted-foreground animate-pulse">Log wird geladen …</p>
|
||
) : logData.error ? (
|
||
<p className="text-[10px] text-red-400">Log nicht ladbar</p>
|
||
) : logData.data?.available && logData.data.lines.length > 0 ? (
|
||
<pre className="max-h-64 overflow-auto font-mono text-[10px] leading-relaxed text-foreground/80 whitespace-pre-wrap">
|
||
{logData.data.lines.join("\n")}
|
||
</pre>
|
||
) : (
|
||
<p className="text-[10px] text-muted-foreground">Kein Log verfügbar</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{isOpen && it.status === "done" && <ErgebnisPanel id={it.id} />}
|
||
</li>
|
||
)
|
||
}
|
||
|
||
// Windows-Dev / Queue nicht verfügbar → Sektion still weglassen (wie der Rest der Seite).
|
||
if (data && !data.available) return null
|
||
|
||
return (
|
||
<section className="space-y-3">
|
||
{/* Kopfzeile sagt in einem Blick, was DICH betrifft — vorher stand hier nur eine
|
||
Gesamtzahl „offen", die nichts darüber verriet, ob etwas auf dich wartet.
|
||
Rechts daneben das Sammel-Aufräumen, das nur erscheint, wenn es etwas zu räumen gibt. */}
|
||
<div className="flex items-center justify-between gap-3">
|
||
<SectionLabel icon={Lightbulb}>
|
||
{(() => {
|
||
if (!data) return "Ideen-Queue"
|
||
const n = (s: string[]) => items.filter((i) => s.includes(i.status)).length
|
||
const teile = [
|
||
n(["blocked"]) > 0 ? `${n(["blocked"])} braucht dich` : "",
|
||
n(["running", "triage", "review"]) > 0 ? `${n(["running", "triage", "review"])} in Arbeit` : "",
|
||
n(["todo", "scheduled", "ready"]) > 0 ? `${n(["todo", "scheduled", "ready"])} wartet` : "",
|
||
n(["done"]) > 0 ? `${n(["done"])} fertig` : "",
|
||
].filter(Boolean)
|
||
// Nur der Zustand, nicht nochmal der Seitenname: „Ideen" steht bereits als
|
||
// Überschrift darüber und ein drittes Mal in der Kopfzeile der App.
|
||
return teile.length ? teile.join(" · ") : "noch nichts in der Queue"
|
||
})()}
|
||
</SectionLabel>
|
||
{(() => {
|
||
const fertig = items.filter((i) => i.status === "done").length
|
||
if (fertig === 0) return null
|
||
return (
|
||
<button
|
||
disabled={!!busy["arch:*"]}
|
||
onClick={() => archiveAlle("", fertig, `Alle ${fertig} fertigen Karten`)}
|
||
title="Räumt ALLE fertigen Karten weg — auch die in abgeschlossenen Projekt-Ketten. Laufende, hängende und wartende bleiben unberührt."
|
||
className="flex shrink-0 items-center gap-1.5 rounded-lg border border-border/60 px-2.5 py-1 text-[11px] font-semibold text-muted-foreground transition-all hover:border-primary/40 hover:text-foreground disabled:opacity-50 cursor-pointer">
|
||
{busy["arch:*"] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
|
||
{fertig} fertige wegräumen
|
||
</button>
|
||
)
|
||
})()}
|
||
</div>
|
||
<div className="mc-card p-4 space-y-4">
|
||
{/* Schreiben zuerst. Alles darunter ist eine Entscheidung ZU dem, was hier steht —
|
||
nicht davor. Der Knopf heisst immer gleich; was passiert, steht bei den Karten. */}
|
||
<div className="flex gap-2">
|
||
<input
|
||
value={text}
|
||
onChange={(e) => { setText(e.target.value); setVorgeschlagen(false) }}
|
||
onKeyDown={(e) => { if (e.key === "Enter") submit() }}
|
||
placeholder="Was soll entstehen?"
|
||
className="h-10 min-w-0 flex-1 rounded-lg border border-border/60 bg-background/50 px-3 text-sm text-foreground placeholder:text-muted-foreground/50 focus:border-primary/50 focus:outline-none"
|
||
/>
|
||
<button
|
||
onClick={submit}
|
||
disabled={sending || text.trim().length < 3}
|
||
className="flex h-10 shrink-0 items-center gap-1.5 rounded-lg border border-primary/40 bg-primary/10 px-4 text-xs font-semibold text-primary transition-all hover:bg-primary/20 disabled:opacity-40 cursor-pointer">
|
||
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||
Los
|
||
</button>
|
||
</div>
|
||
|
||
{/* ZUGEKLAPPT STATT ASSISTENT. Der Entscheidungsbaum ist derselbe, den ein Wizard
|
||
abfragen wuerde — Wer baut das?, und NUR im IDE-Ast: wie startest du? Nur laeufst
|
||
du ihn erst, wenn die Einordnung danebenlag. Ein Assistent wuerde dich fragen, was
|
||
die Box in ~500 ms schon weiss; das hier fragt nur im Zweifelsfall.
|
||
IMMER sichtbar (26.07., User-Befund): vorher hing der ganze Block an
|
||
`text.length >= 3` — bei leerem Feld gab es also keinerlei Hinweis, dass es
|
||
ueberhaupt eine Wahl gibt. Ein Mechanismus, den man nicht sieht, existiert fuer
|
||
den Nutzer nicht. Ohne Text steht dort die Voreinstellung, klar als solche
|
||
benannt, und laesst sich schon vorher aendern. */}
|
||
{(
|
||
offen ? (
|
||
<div className="space-y-3 rounded-xl border border-border/40 bg-background/20 p-3">
|
||
<div>
|
||
<p className="mb-1.5 text-[11px] text-muted-foreground/70">Wer baut das?</p>
|
||
<div className="grid gap-2 sm:grid-cols-2">
|
||
<Wahl aktiv={modus !== "box"}
|
||
onWahl={() => setModus(modus === "box" ? "denken" : modus)}
|
||
k={{ id: "ide", icon: Code2, titel: "Ich, in der Agentic IDE",
|
||
text: "Du bekommst Konzept und Repo — den Code schreibst du selbst." }} />
|
||
<Wahl aktiv={modus === "box"}
|
||
onWahl={() => setModus("box")}
|
||
k={{ id: "box", icon: Hammer, titel: "Die Box, allein",
|
||
text: "Für dein Heimlabor. Das Ergebnis kommt als Patch-Vorschlag ins Auftragsbuch." }} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Die zweite Frage gibt es NUR im IDE-Ast — eingerückt, damit die Verschachtelung
|
||
sichtbar ist. Bei „die Box" folgt keine Rückfrage: dann geht es direkt los. */}
|
||
{modus !== "box" && (
|
||
<div className="border-l-2 border-primary/40 pl-3">
|
||
<p className="mb-1.5 text-[11px] text-muted-foreground/70">Und wie startest du?</p>
|
||
<div className="grid gap-2 sm:grid-cols-2">
|
||
<Wahl aktiv={modus === "denken"} onWahl={() => setModus("denken")}
|
||
k={{ id: "denken", icon: Lightbulb, titel: "Erst durchdenken",
|
||
text: "Konzept zuerst — auf dein Ja wird ein Repo daraus." }} />
|
||
<Wahl aktiv={modus === "repo"} onWahl={() => setModus("repo")}
|
||
k={{ id: "repo", icon: GitFork, titel: "Direkt ein Repo",
|
||
text: "Konzept und Gitea-Repo in einem Zug." }} />
|
||
</div>
|
||
{modus === "repo" ? (
|
||
<div className="mt-2.5 flex flex-wrap items-center gap-x-3 gap-y-2">
|
||
<span className="text-[11px] text-muted-foreground/70">Wie gründlich?</span>
|
||
<div className="flex rounded-lg border border-border/60 p-0.5">
|
||
{([["schlank", "schlank"], ["gruendlich", "gründlich"]] as const).map(([id, label]) => (
|
||
<button key={id} onClick={() => setTiefe(id)}
|
||
className={cn("rounded px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide transition-all cursor-pointer",
|
||
tiefe === id ? "bg-primary/15 text-primary" : "text-muted-foreground/60 hover:text-foreground")}>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<span className="ml-auto flex items-center gap-1.5">
|
||
<span className="text-[11px] text-muted-foreground/70">Läuft als</span>
|
||
<div className="flex rounded-lg border border-border/40 p-0.5">
|
||
{([["lxc", "LXC"], ["docker", "Docker"]] as const).map(([id, label]) => (
|
||
<button key={id} onClick={() => setZiel(id)}
|
||
title={id === "lxc"
|
||
? "Dienst in einem eigenen Proxmox-LXC (systemd, kein Docker). Wird vom Backup-Server mitgesichert."
|
||
: "Natives Docker: Dockerfile + compose, portabel und pipeline-tauglich."}
|
||
className={cn("rounded px-2 py-0.5 text-[10px] font-semibold transition-all cursor-pointer",
|
||
ziel === id ? "bg-foreground/10 text-foreground" : "text-muted-foreground/50 hover:text-foreground/80")}>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<p className="mt-2 flex items-start gap-1.5 text-[10px] leading-relaxed text-muted-foreground/60">
|
||
<HelpCircle className="mt-px h-3 w-3 shrink-0" />
|
||
Wie gründlich und wo es läuft, fragt die Box erst beim „gefällt mir" — dann weißt du mehr.
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Beweisbar statt behauptet: die Quote, an der dieser ganze Entwurf haengt. */}
|
||
{(() => {
|
||
const q = quoteLesen()
|
||
if (q.gesendet < 3) return null
|
||
return (
|
||
<p className="border-t border-border/30 pt-2 text-[10px] text-muted-foreground/50">
|
||
Du hast {q.geaendert} von {q.gesendet} Vorschlägen geändert. Wird das die Regel,
|
||
bauen wir hier einen richtigen Schritt-für-Schritt-Assistenten.
|
||
</p>
|
||
)
|
||
})()}
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-border/40 bg-background/20 px-3 py-2.5 text-xs">
|
||
{modus === "box" ? <Hammer className="h-4 w-4 shrink-0 text-primary" />
|
||
: modus === "repo" ? <GitFork className="h-4 w-4 shrink-0 text-primary" />
|
||
: <Lightbulb className="h-4 w-4 shrink-0 text-primary" />}
|
||
{/* Ohne Einordnung ist der Satz eine EINSTELLUNG, keine Aussage über deinen
|
||
Text — das muss dranstehen, sonst behauptet die Zeile ein Verständnis,
|
||
das es (noch) nicht gibt. */}
|
||
{!vorgeschlagen && (
|
||
<span className="shrink-0 text-[10px] uppercase tracking-wider text-muted-foreground/50">
|
||
Voreingestellt
|
||
</span>
|
||
)}
|
||
<span className="text-muted-foreground">
|
||
{modus === "box" ? "Ich baue das selbst und lege dir das Ergebnis als Vorschlag hin."
|
||
: modus === "repo" ? "Du baust selbst — ich lege dir Konzept und Repo an."
|
||
: "Du baust selbst — ich arbeite erst ein Konzept aus."}
|
||
</span>
|
||
{vorgeschlagen && (
|
||
<span className="shrink-0 rounded border border-primary/40 bg-primary/10 px-1.5 py-0.5 text-[10px] font-bold text-primary">
|
||
gelesen
|
||
</span>
|
||
)}
|
||
<button
|
||
onClick={() => { setOffen(true); quoteZaehlen("geaendert") }}
|
||
className="ml-auto shrink-0 text-[11px] text-muted-foreground/60 underline underline-offset-2 transition-colors hover:text-foreground cursor-pointer">
|
||
anders
|
||
</button>
|
||
</div>
|
||
)
|
||
)}
|
||
{isLoading && (
|
||
<p className="flex items-center gap-2 text-xs text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin" /> Queue wird geladen …</p>
|
||
)}
|
||
{data?.fehler && (
|
||
<p className="text-xs text-amber-300">Queue gerade nicht lesbar — neuer Versuch läuft.</p>
|
||
)}
|
||
|
||
{items.length > 0 && (
|
||
<div className="space-y-3">
|
||
{(data?.projekte ?? []).map((p) => (
|
||
<ProjektGruppe key={p.key} projekt={p} karten={items.filter((i) => i.projekt === p.key)}
|
||
zeile={zeile} onArchivAlle={archiveAlle} raeumt={!!busy["arch:" + p.key]} />
|
||
))}
|
||
{(() => {
|
||
// Einzelkarten (keine Kette). Bis 25.07. eine flache Erstellzeit-Liste — bei
|
||
// 19 Karten, davon 16 fertig, gingen die drei relevanten darin unter. Jetzt
|
||
// dieselbe Ordnung, die ProjektGruppe für Ketten längst benutzt: aktiv zuerst
|
||
// nach AKTIV_RANG, Wartende darunter, Fertige eingeklappt.
|
||
const keys = new Set((data?.projekte ?? []).map((p) => p.key))
|
||
const einzeln = items.filter((i) => !i.projekt || !keys.has(i.projekt))
|
||
if (einzeln.length === 0) return null
|
||
const aktiv = einzeln
|
||
.filter((i) => AKTIV_RANG[i.status] !== undefined)
|
||
.sort((a, b) => (AKTIV_RANG[a.status] - AKTIV_RANG[b.status]) || ((a.erstellt ?? 0) - (b.erstellt ?? 0)))
|
||
const wartend = einzeln.filter((i) => ["todo", "scheduled"].includes(i.status))
|
||
const fertig = einzeln.filter((i) => i.status === "done")
|
||
return (
|
||
<div className="space-y-2">
|
||
{aktiv.length > 0 && <ul className="space-y-1.5">{aktiv.map((it) => zeile(it))}</ul>}
|
||
{wartend.length > 0 && (
|
||
<details className="group">
|
||
<summary className="flex cursor-pointer list-none items-center gap-1.5 py-1 text-[11px] text-muted-foreground/70 hover:text-foreground">
|
||
<ChevronRight className="h-3 w-3 transition-transform group-open:rotate-90" />
|
||
<Clock className="h-3 w-3" /> Wartet ({wartend.length})
|
||
</summary>
|
||
<ul className="mt-1.5 space-y-1.5">{wartend.map((it) => zeile(it))}</ul>
|
||
</details>
|
||
)}
|
||
{fertig.length > 0 && (
|
||
<details className="group">
|
||
<summary className="flex cursor-pointer list-none items-center gap-1.5 py-1 text-[11px] text-muted-foreground/70 hover:text-foreground">
|
||
<ChevronRight className="h-3 w-3 transition-transform group-open:rotate-90" />
|
||
<Check className="h-3 w-3 text-emerald-400/70" /> Fertig ({fertig.length})
|
||
</summary>
|
||
<ul className="mt-1.5 space-y-1.5">{fertig.map((it) => zeile(it))}</ul>
|
||
</details>
|
||
)}
|
||
</div>
|
||
)
|
||
})()}
|
||
</div>
|
||
)}
|
||
{data?.available && !isLoading && items.length === 0 && (
|
||
// Leerer Zustand als Einladung statt als Feststellung: genau jetzt möchte man
|
||
// wissen, was man überhaupt hineinschreiben kann. Ein Klick füllt das Feld —
|
||
// die Box ordnet den Beispieltext dann selbst ein.
|
||
<div className="space-y-2">
|
||
<p className="text-[11px] text-muted-foreground/70">Noch nichts hier. Zum Beispiel:</p>
|
||
{[
|
||
"Ein kleines Werkzeug, das meine Fotos nach Aufnahmedatum in Ordner sortiert",
|
||
"Rippy soll die Untertitel-Spur automatisch auswählen statt zu fragen",
|
||
"Lohnt sich ein Dashboard für die Stromzähler, oder reicht Home Assistant?",
|
||
].map((b) => (
|
||
<button key={b} onClick={() => setText(b)}
|
||
className="flex w-full items-center gap-2 rounded-lg border border-border/40 bg-background/20 px-3 py-2 text-left text-xs text-muted-foreground transition-all hover:border-primary/40 hover:text-foreground cursor-pointer">
|
||
<Lightbulb className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
|
||
{b}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{dialogElement}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
// Eine der drei Auswahl-Karten. Der Text steht AN der Option, nicht in einer Legende
|
||
// darunter — man liest nur, was zu dem gehört, was man gerade anschaut.
|
||
function Wahl({ k, aktiv, onWahl }: {
|
||
k: { id: string; icon: typeof Hammer; titel: string; text: string }
|
||
aktiv: boolean
|
||
onWahl: () => void
|
||
}) {
|
||
return (
|
||
<button
|
||
onClick={onWahl}
|
||
className={cn("rounded-xl border p-3 text-left transition-all cursor-pointer",
|
||
aktiv ? "border-primary/60 bg-primary/5" : "border-border/50 hover:border-border")}>
|
||
<div className="flex items-center gap-2">
|
||
<k.icon className={cn("h-4 w-4 shrink-0", aktiv ? "text-primary" : "text-muted-foreground")} />
|
||
<span className="text-xs font-semibold text-foreground">{k.titel}</span>
|
||
</div>
|
||
<p className="mt-1 text-[11px] leading-relaxed text-muted-foreground">{k.text}</p>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
// Karten-Familie (verkettete Karten = ein Projekt) als Block: Fortschritt oben, dann die
|
||
// AKTIVEN Karten in Ausführungs-Reihenfolge, Wartende und Fertige zusammengeklappt.
|
||
// Ersetzt für Ketten die flache Erstellzeit-Liste (User: „Reihenfolge komplett durcheinander").
|
||
const AKTIV_RANG: Record<string, number> = { blocked: 0, running: 1, review: 2, ready: 3, triage: 4 }
|
||
|
||
function ProjektGruppe({ projekt, karten, zeile, onArchivAlle, raeumt }: {
|
||
projekt: IdeenProjekt
|
||
karten: IdeenItem[]
|
||
zeile: (it: IdeenItem) => React.ReactNode
|
||
onArchivAlle?: (projekt: string, anzahl: number, was: string) => void
|
||
raeumt?: boolean
|
||
}) {
|
||
const [zeigWartende, setZeigWartende] = useState(false)
|
||
const [zeigFertige, setZeigFertige] = useState(false)
|
||
const aktiv = karten
|
||
.filter((i) => AKTIV_RANG[i.status] !== undefined)
|
||
.sort((a, b) => (AKTIV_RANG[a.status] - AKTIV_RANG[b.status]) || ((a.erstellt ?? 0) - (b.erstellt ?? 0)))
|
||
const wartend = karten
|
||
.filter((i) => i.status === "todo" || i.status === "scheduled")
|
||
.sort((a, b) => ((a.wartet_auf?.length ? 1 : 0) - (b.wartet_auf?.length ? 1 : 0)) || ((a.erstellt ?? 0) - (b.erstellt ?? 0)))
|
||
const fertig = karten
|
||
.filter((i) => i.status === "done")
|
||
.sort((a, b) => (b.fertig ?? 0) - (a.fertig ?? 0))
|
||
const anteil = projekt.gesamt > 0 ? Math.round((projekt.fertig / projekt.gesamt) * 100) : 0
|
||
return (
|
||
<div className="space-y-2 rounded-xl border border-border/50 bg-background/20 p-3">
|
||
<div className="flex items-center gap-2">
|
||
<Layers className="h-4 w-4 shrink-0 text-primary/70" />
|
||
<p className="min-w-0 flex-1 truncate text-xs font-semibold text-foreground" title={projekt.titel}>{projekt.titel}</p>
|
||
<span className="shrink-0 text-[10px] text-muted-foreground">{projekt.fertig} von {projekt.gesamt} fertig</span>
|
||
</div>
|
||
<div className="h-1 overflow-hidden rounded-full bg-background/60" title={`${anteil}%`}>
|
||
<div className="h-1 rounded-full bg-primary/70 transition-all" style={{ width: `${anteil}%` }} />
|
||
</div>
|
||
{aktiv.length > 0 && <ul className="space-y-1.5">{aktiv.map((it) => zeile(it))}</ul>}
|
||
{wartend.length > 0 && (
|
||
<div>
|
||
<button
|
||
onClick={() => setZeigWartende((v) => !v)}
|
||
className="flex items-center gap-1.5 text-[11px] font-semibold text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
|
||
{zeigWartende ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||
{wartend.length} Karte{wartend.length === 1 ? "" : "n"} in der Kette — laufen automatisch der Reihe nach
|
||
</button>
|
||
{zeigWartende && <ul className="mt-1.5 space-y-1.5">{wartend.map((it) => zeile(it))}</ul>}
|
||
</div>
|
||
)}
|
||
{fertig.length > 0 && (
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => setZeigFertige((v) => !v)}
|
||
className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-300/70 hover:text-emerald-300 transition-colors cursor-pointer">
|
||
{zeigFertige ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||
{fertig.length} fertig
|
||
</button>
|
||
{/* Ganze abgeschlossene Kette in einem Zug wegräumen — bei fünf fertigen
|
||
Projekten waren das sonst ~18 Einzelklicks auf das X. */}
|
||
{onArchivAlle && (
|
||
<button
|
||
disabled={raeumt}
|
||
onClick={() => onArchivAlle(projekt.key, fertig.length, `„${projekt.titel}"`)}
|
||
title="Räumt die fertigen Karten dieser Kette weg (Archiv, nichts wird gelöscht)."
|
||
className="flex items-center gap-1 text-[11px] text-muted-foreground/60 transition-colors hover:text-foreground disabled:opacity-50 cursor-pointer">
|
||
{raeumt ? <Loader2 className="h-3 w-3 animate-spin" /> : <X className="h-3 w-3" />}
|
||
wegräumen
|
||
</button>
|
||
)}
|
||
</div>
|
||
{zeigFertige && <ul className="mt-1.5 space-y-1.5">{fertig.map((it) => zeile(it))}</ul>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Konzept einer IDE-Karte: holt es aus dem Gitea-Repo — oder, wenn der Worker keins
|
||
// angelegt hat, aus seiner Datei in ~/konzepte (Bug-Fund 21.07.: dann stand hier vorher
|
||
// „noch kein Repo hinterlegt", obwohl das Konzept längst fertig war).
|
||
// Bei einer fertig durchdachten IDEE hängt unten der Weg nach vorn: „Gefällt mir".
|
||
function KonzeptPanel({ id, istIdee, onWeiter }: {
|
||
id: string
|
||
istIdee?: boolean
|
||
onWeiter?: () => void
|
||
}) {
|
||
const [data, setData] = useState<IdeenKonzept | null>(null)
|
||
const [fehler, setFehler] = useState(false)
|
||
const [versuch, setVersuch] = useState(0)
|
||
useEffect(() => {
|
||
let weg = false
|
||
setData(null); setFehler(false)
|
||
api<IdeenKonzept>(`/api/ideen/${id}/konzept`)
|
||
.then((r) => { if (!weg) setData(r) })
|
||
.catch(() => { if (!weg) setFehler(true) })
|
||
return () => { weg = true }
|
||
}, [id, versuch])
|
||
return (
|
||
<div className="ml-2 -mt-1">
|
||
<div className="rounded-lg border border-violet-500/25 bg-violet-500/[0.04] p-3">
|
||
<div className="mb-2 flex items-center gap-2">
|
||
<p className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wide text-violet-300">
|
||
<FileText className="h-3 w-3" /> Konzept
|
||
</p>
|
||
{data?.repo && (
|
||
<a href={`https://git.tobisniceshomelab.ddnsfree.com/${data.repo}`}
|
||
className="flex items-center gap-1 text-[10px] text-violet-300/70 hover:text-violet-200">
|
||
<GitFork className="h-3 w-3" /> {data.repo}
|
||
</a>
|
||
)}
|
||
{!data?.repo && data?.quelle === "datei" && (
|
||
<span className="truncate font-mono text-[10px] text-muted-foreground/70"
|
||
title="Der Worker hat kein Repo angelegt — das Konzept liegt als Datei auf der Box.">
|
||
{data.pfad}
|
||
</span>
|
||
)}
|
||
<button onClick={() => setVersuch((v) => v + 1)} title="Neu laden"
|
||
className="ml-auto rounded p-0.5 text-muted-foreground/50 hover:text-foreground cursor-pointer">
|
||
<RefreshCw className="h-3 w-3" />
|
||
</button>
|
||
</div>
|
||
{fehler ? (
|
||
<p className="text-[11px] text-red-400">Konzept nicht ladbar — später nochmal versuchen.</p>
|
||
) : data === null ? (
|
||
<p className="text-[10px] text-muted-foreground animate-pulse">Konzept wird geladen …</p>
|
||
) : data.konzept ? (
|
||
<div className="max-h-[28rem] overflow-y-auto pr-1">
|
||
<Markdown text={data.konzept} />
|
||
</div>
|
||
) : (
|
||
<p className="text-[11px] text-muted-foreground italic">
|
||
{data.error || "Noch kein Konzept — das Projekt wird gerade vorbereitet."}
|
||
</p>
|
||
)}
|
||
{istIdee && data?.konzept && <WeiterLeiste id={id} onFertig={onWeiter} />}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Was nach dem Lesen passieren kann. Zwei Wege, bewusst nebeneinander statt hintereinander:
|
||
// nachschärfen (ein Punkt passt nicht — häufiger Fall, darum oben und offen) oder annehmen.
|
||
function WeiterLeiste({ id, onFertig }: { id: string; onFertig?: () => void }) {
|
||
const [ziel, setZiel] = useState<"lxc" | "docker">("lxc")
|
||
const [tiefe, setTiefe] = useState<"simpel" | "gruendlich">("simpel")
|
||
const [busy, setBusy] = useState(false)
|
||
const [neu, setNeu] = useState<{ id: string; art: "projekt" | "runde" } | null>(null)
|
||
const [fehler, setFehler] = useState("")
|
||
const [hinweis, setHinweis] = useState("")
|
||
const ruf = async (pfad: string, rumpf: object, art: "projekt" | "runde") => {
|
||
setBusy(true); setFehler("")
|
||
try {
|
||
const r = await api<{ id: string }>(pfad, { method: "POST", body: JSON.stringify(rumpf) })
|
||
setNeu({ id: r.id, art })
|
||
onFertig?.()
|
||
} catch (e: any) {
|
||
setFehler(String(e?.message || e))
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
if (neu) {
|
||
return (
|
||
<p className="mt-3 border-t border-violet-500/20 pt-2 text-[11px] text-emerald-300">
|
||
{neu.art === "projekt" ? (
|
||
<>Projekt-Karte <span className="font-mono">{neu.id}</span> ist angelegt — die Box bereitet
|
||
das Repo vor (Konzept übernimmt sie, sie denkt es nicht neu). Danach öffnest du es in der Agentic IDE.</>
|
||
) : (
|
||
<>Überarbeitung <span className="font-mono">{neu.id}</span> läuft — die Box arbeitet deine
|
||
Rückmeldung ein und schreibt die neue Fassung an dieselbe Stelle. Danach hier wieder
|
||
„Konzept" öffnen.</>
|
||
)}
|
||
</p>
|
||
)
|
||
}
|
||
return (
|
||
<div className="mt-3 space-y-3 border-t border-violet-500/20 pt-2.5">
|
||
<div className="space-y-1.5">
|
||
<p className="text-[11px] text-muted-foreground">
|
||
<b className="text-sky-300/90">Passt etwas nicht?</b> Schreib hin, was anders werden soll —
|
||
die Box arbeitet es ein und lässt den Rest stehen. Beliebig oft wiederholbar.
|
||
</p>
|
||
<div className="flex gap-2">
|
||
<input
|
||
value={hinweis}
|
||
onChange={(e) => setHinweis(e.target.value)}
|
||
onKeyDown={(e) => { if (e.key === "Enter" && hinweis.trim().length >= 3 && !busy) ruf("/api/ideen/nachschaerfen", { id, hinweis }, "runde") }}
|
||
placeholder="z. B. „Punkt 3: kein WebSocket, das ist mir zu fragil — nimm Polling“"
|
||
maxLength={2000}
|
||
className="h-8 min-w-0 flex-1 rounded-lg border border-border/60 bg-background/60 px-2.5 text-xs text-foreground placeholder:text-muted-foreground/50 focus:border-sky-400/50 focus:outline-none"
|
||
/>
|
||
<button
|
||
onClick={() => ruf("/api/ideen/nachschaerfen", { id, hinweis }, "runde")}
|
||
disabled={busy || hinweis.trim().length < 3}
|
||
className="flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-sky-500/40 bg-sky-500/10 px-3 text-[10px] font-bold uppercase tracking-wide text-sky-300 transition-all hover:bg-sky-500/20 cursor-pointer disabled:opacity-50">
|
||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||
Nachschärfen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<p className="text-[11px] text-muted-foreground">
|
||
<b className="text-violet-300/90">Passt es so?</b> Dann macht die Box daraus ein startklares
|
||
Repo — Konzept wird übernommen (nicht neu gedacht), dazu Roadmap, AGENTS.md & Co.
|
||
Gebaut wird danach von dir in der Agentic IDE.
|
||
</p>
|
||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||
<span className="text-[11px] text-muted-foreground/70">Ziel</span>
|
||
<div className="flex rounded-lg border border-border/40 p-0.5">
|
||
{(["lxc", "docker"] as const).map((z) => (
|
||
<button key={z} onClick={() => setZiel(z)}
|
||
title={z === "lxc" ? "Dienst in einem eigenen Proxmox-LXC (systemd, kein Docker) — wird automatisch mitgesichert."
|
||
: "Natives Docker: Dockerfile + compose, portabel und pipeline-tauglich."}
|
||
className={cn("rounded px-2 py-0.5 text-[10px] font-semibold transition-all cursor-pointer",
|
||
ziel === z ? "bg-foreground/10 text-foreground" : "text-muted-foreground/50 hover:text-foreground/80")}>
|
||
{z === "lxc" ? "LXC" : "Docker"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<span className="text-[11px] text-muted-foreground/70">Aufwand</span>
|
||
<div className="flex rounded-lg border border-border/40 p-0.5">
|
||
{(["simpel", "gruendlich"] as const).map((t) => (
|
||
<button key={t} onClick={() => setTiefe(t)}
|
||
title={t === "simpel" ? "Konzept ist schon gehärtet — nur kurz auf die Bau-Sicht nachschärfen (empfohlen)."
|
||
: "Nochmal volle Kaskade über die Bau-Sicht: mehrere Rollen, Härtetests. Dauert entsprechend."}
|
||
className={cn("rounded px-2 py-0.5 text-[10px] font-semibold transition-all cursor-pointer",
|
||
tiefe === t ? "bg-foreground/10 text-foreground" : "text-muted-foreground/50 hover:text-foreground/80")}>
|
||
{t === "simpel" ? "schlank" : "gründlich"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<button
|
||
onClick={() => ruf("/api/ideen/weiterfuehren", { id, ziel, tiefe }, "projekt")}
|
||
disabled={busy}
|
||
className="ml-auto flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-violet-500/40 bg-violet-500/10 px-3 text-[10px] font-bold uppercase tracking-wide text-violet-300 transition-all hover:bg-violet-500/20 cursor-pointer disabled:opacity-50">
|
||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Code2 className="h-3.5 w-3.5" />}
|
||
Gefällt mir — Projekt daraus machen
|
||
</button>
|
||
</div>
|
||
{fehler && <p className="text-[11px] text-red-400">{fehler}</p>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Ergebnis einer FERTIGEN Queue-Aufgabe: die Abschluss-Zusammenfassung des Workers
|
||
// (kanban_complete). Lazy — wird erst beim Aufklappen geladen, ändert sich nie mehr.
|
||
function ErgebnisPanel({ id }: { id: string }) {
|
||
const [erg, setErg] = useState<IdeenErgebnis | null>(null)
|
||
const [fehler, setFehler] = useState(false)
|
||
// Fehler darf nicht kleben: fällt der erste Versuch in ein Deploy-Fenster
|
||
// (Zentrale startet gerade neu), muss ein Klick neu laden können (Vorfall 16.07.).
|
||
const [versuch, setVersuch] = useState(0)
|
||
useEffect(() => {
|
||
let weg = false
|
||
setFehler(false)
|
||
setErg(null)
|
||
api<IdeenErgebnis>(`/api/ideen/${id}/ergebnis`)
|
||
.then((r) => { if (!weg) setErg(r) })
|
||
.catch(() => { if (!weg) setFehler(true) })
|
||
return () => { weg = true }
|
||
}, [id, versuch])
|
||
const text = erg ? (erg.summary || erg.result) : ""
|
||
return (
|
||
<div className="ml-2 -mt-1">
|
||
<div className="rounded-lg border border-emerald-500/25 bg-emerald-500/5 p-2.5">
|
||
<p className="mb-1 flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wide text-emerald-300">
|
||
<ClipboardCheck className="h-3 w-3" /> Ergebnis des Workers
|
||
</p>
|
||
{fehler ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => setVersuch((v) => v + 1)}
|
||
className="text-[10px] text-red-400 underline decoration-dotted underline-offset-2 hover:text-red-300"
|
||
>
|
||
Ergebnis nicht ladbar — nochmal versuchen
|
||
</button>
|
||
) : erg === null ? (
|
||
<p className="text-[10px] text-muted-foreground animate-pulse">Wird geladen …</p>
|
||
) : text ? (
|
||
<p className="max-h-64 overflow-y-auto whitespace-pre-wrap text-xs leading-relaxed text-foreground/85">{text}</p>
|
||
) : (
|
||
<p className="text-[10px] text-muted-foreground italic">
|
||
Der Worker hat keine Abschluss-Zusammenfassung hinterlassen (ältere Karte oder Protokollverstoß).
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Die Seite selbst: Kopf + die Queue. Bewusst duenn — die Logik lebt unveraendert in
|
||
// IdeenSection, damit dieser Umzug reines Verschieben bleibt und nichts still kippt.
|
||
export function IdeenView() {
|
||
return (
|
||
<div className="space-y-6">
|
||
<div>
|
||
<h1 className="bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent">
|
||
Ideen
|
||
</h1>
|
||
<p className="text-sm text-muted-foreground">
|
||
Schreib hin, was entstehen soll. Die Box liest mit und schlägt vor, wie es weitergeht —
|
||
ändern kannst du das immer.
|
||
</p>
|
||
</div>
|
||
<IdeenSection />
|
||
</div>
|
||
)
|
||
}
|