import { useEffect, useState } from "react" 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 -> Zed) 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 = { 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 = { 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 Zed.", }, } // Wie die Frage einer hängenden Karte eingeleitet wird — je nach Block-Grund des Workers. const FRAGE_HINT: Record = { 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 Zed-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>({}) const [openLog, setOpenLog] = useState(null) const [openKonzept, setOpenKonzept] = useState(null) const [antworten, setAntworten] = useState>({}) 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" | "zed"; 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 === "zed" ? "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 (
  • {ui.spin && } {ui.label} {haengt && ( {Math.round((it.puls_alter ?? 0) / 60)} min ohne Regung )} {(it.prio ?? 0) !== 0 && ( P{it.prio} )} {(() => { const a = ART_UI[it.art || (it.welt === "ide" ? "projekt" : "box")] if (!a) return null const Icon = a.icon return ( {a.label} ) })()} {/* Tooltip = der volle TITEL (der ist abgeschnitten), nicht der Auftragstext — der beginnt mit Vorspann-Textbausteinen und half beim Lesen nie. */} {it.titel} {fmtWhen(it.erstellt)} {wartend && ( )} {(it.status === "running" || blocked) && ( )} {(it.status === "running" || wartend || it.status === "triage") && ( )} {(it.status === "done" || blocked) && ( )} {it.welt === "ide" && (it.status === "running" || it.status === "done") && ( )} {canOpen && ( )}
    {openKonzept === it.id && ( )} {it.status === "running" && it.notiz && (

    „{it.notiz}“

    )} {(it.status === "todo" || it.status === "scheduled" || it.status === "ready") && (it.wartet_auf?.length ?? 0) > 0 && (

    → wartet auf: {it.wartet_auf!.join(" · ")}

    )} {blocked && (

    {FRAGE_HINT[it.frage_kind || ""] ?? "Die Aufgabe hängt und wartet auf dich:"}{" "} {it.frage ? {it.frage} : kein genauer Grund hinterlegt — antworte einfach mit einer Entscheidung, dann läuft sie weiter.}

    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" />
    )} {isOpen && canOpen && it.status !== "done" && (
    {logEnabled && logData.isLoading ? (

    Log wird geladen …

    ) : logData.error ? (

    Log nicht ladbar

    ) : logData.data?.available && logData.data.lines.length > 0 ? (
                      {logData.data.lines.join("\n")}
                    
    ) : (

    Kein Log verfügbar

    )}
    )} {isOpen && it.status === "done" && }
  • ) } // Windows-Dev / Queue nicht verfügbar → Sektion still weglassen (wie der Rest der Seite). if (data && !data.available) return null return (
    {/* 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. */}
    {(() => { 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" })()} {(() => { const fertig = items.filter((i) => i.status === "done").length if (fertig === 0) return null return ( ) })()}
    {/* 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. */}
    { 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" />
    {/* ZUGEKLAPPT STATT ASSISTENT. Der Entscheidungsbaum ist derselbe, den ein Wizard abfragen wuerde — Wer baut das?, und NUR im Zed-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 ? (

    Wer baut das?

    setModus(modus === "box" ? "denken" : modus)} k={{ id: "zed", icon: Code2, titel: "Ich, in Zed", text: "Du bekommst Konzept und Repo — den Code schreibst du selbst." }} /> setModus("box")} k={{ id: "box", icon: Hammer, titel: "Die Box, allein", text: "Für dein Heimlabor. Das Ergebnis kommt als Patch-Vorschlag ins Auftragsbuch." }} />
    {/* Die zweite Frage gibt es NUR im Zed-Ast — eingerückt, damit die Verschachtelung sichtbar ist. Bei „die Box" folgt keine Rückfrage: dann geht es direkt los. */} {modus !== "box" && (

    Und wie startest du?

    setModus("denken")} k={{ id: "denken", icon: Lightbulb, titel: "Erst durchdenken", text: "Konzept zuerst — auf dein Ja wird ein Repo daraus." }} /> setModus("repo")} k={{ id: "repo", icon: GitFork, titel: "Direkt ein Repo", text: "Konzept und Gitea-Repo in einem Zug." }} />
    {modus === "repo" ? (
    Wie gründlich?
    {([["schlank", "schlank"], ["gruendlich", "gründlich"]] as const).map(([id, label]) => ( ))}
    Läuft als
    {([["lxc", "LXC"], ["docker", "Docker"]] as const).map(([id, label]) => ( ))}
    ) : (

    Wie gründlich und wo es läuft, fragt die Box erst beim „gefällt mir" — dann weißt du mehr.

    )}
    )} {/* Beweisbar statt behauptet: die Quote, an der dieser ganze Entwurf haengt. */} {(() => { const q = quoteLesen() if (q.gesendet < 3) return null return (

    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.

    ) })()}
    ) : (
    {modus === "box" ? : modus === "repo" ? : } {/* 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 && ( Voreingestellt )} {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."} {vorgeschlagen && ( gelesen )}
    ) )} {isLoading && (

    Queue wird geladen …

    )} {data?.fehler && (

    Queue gerade nicht lesbar — neuer Versuch läuft.

    )} {items.length > 0 && (
    {(data?.projekte ?? []).map((p) => ( 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 (
    {aktiv.length > 0 &&
      {aktiv.map((it) => zeile(it))}
    } {wartend.length > 0 && (
    Wartet ({wartend.length})
      {wartend.map((it) => zeile(it))}
    )} {fertig.length > 0 && (
    Fertig ({fertig.length})
      {fertig.map((it) => zeile(it))}
    )}
    ) })()}
    )} {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.

    Noch nichts hier. Zum Beispiel:

    {[ "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) => ( ))}
    )}
    {dialogElement}
    ) } // 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 ( ) } // 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 = { 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 (

    {projekt.titel}

    {projekt.fertig} von {projekt.gesamt} fertig
    {aktiv.length > 0 &&
      {aktiv.map((it) => zeile(it))}
    } {wartend.length > 0 && (
    {zeigWartende &&
      {wartend.map((it) => zeile(it))}
    }
    )} {fertig.length > 0 && (
    {/* Ganze abgeschlossene Kette in einem Zug wegräumen — bei fünf fertigen Projekten waren das sonst ~18 Einzelklicks auf das X. */} {onArchivAlle && ( )}
    {zeigFertige &&
      {fertig.map((it) => zeile(it))}
    }
    )}
    ) } // 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(null) const [fehler, setFehler] = useState(false) const [versuch, setVersuch] = useState(0) useEffect(() => { let weg = false setData(null); setFehler(false) api(`/api/ideen/${id}/konzept`) .then((r) => { if (!weg) setData(r) }) .catch(() => { if (!weg) setFehler(true) }) return () => { weg = true } }, [id, versuch]) return (

    Konzept

    {data?.repo && ( {data.repo} )} {!data?.repo && data?.quelle === "datei" && ( {data.pfad} )}
    {fehler ? (

    Konzept nicht ladbar — später nochmal versuchen.

    ) : data === null ? (

    Konzept wird geladen …

    ) : data.konzept ? (
    ) : (

    {data.error || "Noch kein Konzept — das Projekt wird gerade vorbereitet."}

    )} {istIdee && data?.konzept && }
    ) } // 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 (

    {neu.art === "projekt" ? ( <>Projekt-Karte {neu.id} ist angelegt — die Box bereitet das Repo vor (Konzept übernimmt sie, sie denkt es nicht neu). Danach öffnest du es in Zed. ) : ( <>Überarbeitung {neu.id} läuft — die Box arbeitet deine Rückmeldung ein und schreibt die neue Fassung an dieselbe Stelle. Danach hier wieder „Konzept" öffnen. )}

    ) } return (

    Passt etwas nicht? Schreib hin, was anders werden soll — die Box arbeitet es ein und lässt den Rest stehen. Beliebig oft wiederholbar.

    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" />

    Passt es so? 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 Zed.

    Ziel
    {(["lxc", "docker"] as const).map((z) => ( ))}
    Aufwand
    {(["simpel", "gruendlich"] as const).map((t) => ( ))}
    {fehler &&

    {fehler}

    }
    ) } // 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(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(`/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 (

    Ergebnis des Workers

    {fehler ? ( ) : erg === null ? (

    Wird geladen …

    ) : text ? (

    {text}

    ) : (

    Der Worker hat keine Abschluss-Zusammenfassung hinterlassen (ältere Karte oder Protokollverstoß).

    )}
    ) } // 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 (

    Ideen

    Schreib hin, was entstehen soll. Die Box liest mit und schlägt vor, wie es weitergeht — ändern kannst du das immer.

    ) }