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. */} {text.trim().length >= 3 && ( 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" ? : } {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."} {/* Nur wenn die Box wirklich gelesen hat. Ohne Einordnung (Text noch zu kurz oder Aufruf gescheitert) steht hier die Voreinstellung — und die darf sich nicht als Verständnis ausgeben. */} {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 arbeitet es aus. Je nach Modus baut sie es selbst, denkt es nur durch, oder legt dir ein startklares Repo fuer Zed an.

    ) }