import { useEffect, useState } from "react" import { Inbox, Check, X, GitBranch, FileDiff, Loader2, AlertTriangle, Sparkles, ChevronDown, ChevronRight, Clock, ClipboardCheck, Hammer, RefreshCw, Layers, Lightbulb, Send, HelpCircle, RotateCcw, Square, ArrowUp, ArrowDown, Code2, FileText, GitFork, } from "lucide-react" import { api, type AuftragItem, type IdeenErgebnis, type IdeenItem, type IdeenKonzept, type IdeenProjekt, type SkillKandidat } from "@/lib/api" import { Markdown } from "@/lib/markdown" import { useAuftragsbuch, useIdeen, useIdeenLog, useQueryClient, qk } from "@/lib/queries" import { useDialog } from "@/lib/useDialog" import { cn } from "@/lib/utils" // Auftragsbuch = die Vorschlags-Inbox der Box. Werkstatt/Orchestrator liefern propose-only- // Branches, der Traum liefert Skill-Kandidaten — hier fällt die Entscheidung per Klick. // Annehmen (Branch) = Merge→Push→Deploy→Health mit Auto-Rollback; alles detached auf der Box. const KIND_LABEL: Record = { wartung: "Werkstatt", orchestrator: "Orchestrator", doku: "Doku", feature: "Feature", } const STATE_UI: Record = { laeuft: { label: "wird eingespielt …", cls: "text-sky-300 border-sky-500/40 bg-sky-500/10", spin: true }, rollback: { label: "Rollback läuft …", cls: "text-amber-300 border-amber-500/40 bg-amber-500/10", spin: true }, eingespielt: { label: "eingespielt ✓", cls: "text-emerald-300 border-emerald-500/40 bg-emerald-500/10" }, fehlgeschlagen: { label: "fehlgeschlagen", cls: "text-red-300 border-red-500/40 bg-red-500/10" }, zurueckgerollt: { label: "zurückgerollt", cls: "text-amber-300 border-amber-500/40 bg-amber-500/10" }, kritisch: { label: "KRITISCH — Box prüfen", cls: "text-red-200 border-red-500/60 bg-red-500/20" }, abgelehnt: { label: "abgelehnt", cls: "text-muted-foreground border-border/60 bg-background/30" }, } function fmtWhen(ts: number | null | undefined): string { if (!ts) return "—" return new Date(ts * 1000).toLocaleString("de-DE", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }) } export function AuftragsbuchView() { const { data, isLoading, refetch, isFetching } = useAuftragsbuch() // Geteilter Cache mit der IdeenSection — hier nur fürs ehrliche "Nichts zu entscheiden"- // Banner (eine hängende Karte direkt darunter widerspräche dem grünen Häkchen). const { data: ideenData } = useIdeen() const qc = useQueryClient() const { showAlert, showConfirm, dialogElement } = useDialog() const [busy, setBusy] = useState>({}) const reload = () => qc.invalidateQueries({ queryKey: qk.auftragsbuch }) async function act(key: string, path: string, body: object, done?: string) { setBusy((b) => ({ ...b, [key]: true })) try { await api(path, { method: "POST", body: JSON.stringify(body) }) if (done) showAlert("Erledigt", done) reload() } catch (e: any) { showAlert("Das hat nicht geklappt", String(e?.message || e)) } finally { setBusy((b) => ({ ...b, [key]: false })) } } const items = data?.items ?? [] const kandidaten = data?.skill_kandidaten ?? [] const blockedIdeen = ideenData?.available ? ideenData.items.filter((i) => i.status === "blocked").length : 0 const empty = items.length === 0 && kandidaten.length === 0 && blockedIdeen === 0 return (

Auftragsbuch

Was die Box gebaut oder sich ausgedacht hat — du entscheidest. Annehmen spielt es mit Fangnetz ein (Health-Check, automatischer Rollback).

{data && !data.available && (
Das Auftragsbuch lebt auf der Box (liest die Gitea-Branches dort). Im lokalen Dev-Modus ist hier nichts zu sehen.
)} {isLoading && (
Vorschläge werden geladen …
)} {data?.available && empty && (

Nichts zu entscheiden

Keine offenen Vorschläge. Werkstatt, Orchestrator und Traum melden sich hier, sobald es etwas gibt.

)} {items.length > 0 && (
Patch-Vorschläge ({items.length}) {items.map((it) => ( ))}
)} {kandidaten.length > 0 && (
Skill-Kandidaten aus dem Traum ({kandidaten.length}) {kandidaten.map((k) => ( ))}
)} {dialogElement}
) } function SectionLabel({ icon: Icon, children }: { icon: any; children: React.ReactNode }) { return (

{children}

) } // ── 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:", } function IdeenSection() { const { data, isLoading } = useIdeen() const qc = useQueryClient() const { showAlert, showConfirm, dialogElement } = useDialog() const [text, setText] = useState("") // Intake-Modus: Box baut · Idee (nur durchdenken) · IDE simpel/gründlich (Projekt vorbereiten). const [modus, setModus] = useState<"box" | "idee" | "simpel" | "gruendlich">("box") const isIdee = modus === "idee" // Produkt-Idee prüfen const isProjekt = modus === "simpel" || modus === "gruendlich" // Projekt für Zed const isIde = isIdee || isProjekt // beides → projektstart // 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 }) // 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 : "", welt: isIde ? "ide" : "box", tiefe: isProjekt ? modus : "", ziel: isProjekt ? ziel : "", // eine Idee braucht noch keine Plattform art: isIdee ? "idee" : "projekt", }) }) setText("") reload() } catch (e: any) { showAlert("Das hat nicht geklappt", String(e?.message || e)) } finally { setSending(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 (
    Ideen-Queue{data ? ` — ${data.offen} offen` : ""}
    setText(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") submit() }} placeholder="Neue Idee — einfach hinschreiben, die Box macht den Rest" className="h-9 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" />
    Modus
    {/* Immer sichtbare Orientierung — der Text darunter erklärt nur den GEWÄHLTEN Modus, die Wahl selbst muss man aber vorher treffen können (User 21.07.). */}

    Box, wenn die Box es selbst bauen soll (dein Heimlabor, MC2, Lucy — Ergebnis kommt als Patch-Vorschlag zurück). ·{" "} Idee, wenn du noch nicht weißt, ob es das überhaupt wert ist — erst durchdenken, entscheiden kannst du danach. ·{" "} IDE, wenn du es selbst in Zed bauen willst und dafür ein startklares Repo brauchst.

    {isProjekt && (
    Ziel
    {ziel === "lxc" ? "Dienst im Proxmox-Container (deine Heimat)" : "natives Docker, portabel & pipeline-tauglich"}
    )}

    {isIdee ? ( <> Idee durchdenken: Die Box arbeitet deine Produkt-Idee mit Produkt-Blick aus — was ist es genau, für wen, was gibt es dafür schon, was wäre der kleinste sinnvolle Wurf und was spricht dagegen. Volle Denk-Kaskade mit Kritiker, Ergebnis ist nur ein Konzept (kein Gerüst, keine Roadmap, kein Bau-Druck) — du liest es hier und entscheidest danach, ob ein Projekt draus wird. ) : isProjekt ? ( <> IDE-Welt{modus === "gruendlich" ? " · gründlich" : " · simpel"}:{" "} Die Box denkt das Konzept durch{modus === "gruendlich" ? " — volle Kaskade: mehrere Fach-Rollen, bis zu 3 Denk-Runden, mehrfacher Kritiker-Härtetest (für große/ambitionierte Projekte, dauert entsprechend)" : " — schlank: 2 Rollen, 1 Denk-Runde (für kleine, klar umrissene Projekte, ein paar Minuten)"}, legt ein leeres Gitea-Repo an und füllt es nur mit den wichtigen Dokumenten (Konzept, Roadmap, AGENTS.md, .aiexclude) — kein Code. Danach öffnest du das Repo in Zed und baust selbst. ) : ( <> Eine Queue, alle Türen: hier, per Telegram oder im Gespräch mit Lucy. Die Box arbeitet Ideen selbst aus und baut — Code-Ergebnisse erscheinen als Karte unter „Patch-Vorschläge“, nichts davon geht ohne deinen Klick live. Nur reine Doku-Bagatellen zieht die Box nachts selbst ein (mit Fangnetz und Meldung). )}

    {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} /> ))} {(() => { // Einzelkarten (keine Kette) — die klassische flache Liste wie bisher. const keys = new Set((data?.projekte ?? []).map((p) => p.key)) const einzeln = items.filter((i) => !i.projekt || !keys.has(i.projekt)) return einzeln.length > 0 ?
      {einzeln.map((it) => zeile(it))}
    : null })()}
    )} {data?.available && !isLoading && items.length === 0 && (

    Queue ist leer — wirf der Box eine Idee zu.

    )}
    {dialogElement}
    ) } // 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 }: { projekt: IdeenProjekt karten: IdeenItem[] zeile: (it: IdeenItem) => React.ReactNode }) { 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 && (
    {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 && }
    ) } // „Gefällt mir" — aus der geprüften Idee ein IDE-Projekt machen. Ohne das war die Idee // eine Sackgasse (Titel neu eintippen, Konzept von vorn denken lassen). Die neue Karte // bekommt das fertige Konzept als Vorlage und hängt als Kette an der Idee. 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(null) const [fehler, setFehler] = useState("") const los = async () => { setBusy(true); setFehler("") try { const r = await api<{ id: string }>("/api/ideen/weiterfuehren", { method: "POST", body: JSON.stringify({ id, ziel, tiefe }), }) setNeu(r.id) onFertig?.() } catch (e: any) { setFehler(String(e?.message || e)) } finally { setBusy(false) } } if (neu) { return (

    Projekt-Karte {neu} ist angelegt — die Box bereitet das Repo vor (Konzept übernimmt sie, sie denkt es nicht neu). Danach öffnest du es in Zed.

    ) } return (

    Gefällt dir das? 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ß).

    )}
    ) } function StateBadge({ item }: { item: AuftragItem }) { const st = item.status if (!st) return null const ui = STATE_UI[st.state] if (!ui) return null return ( {ui.spin && } {ui.label} ) } function ProposalCard({ item, busy, onAct, showConfirm, }: { item: AuftragItem busy: Record onAct: (key: string, path: string, body: object, done?: string) => void showConfirm: (title: string, msg: string, ok: () => void) => void }) { const [open, setOpen] = useState(false) const [diff, setDiff] = useState(null) const [diffLoading, setDiffLoading] = useState(false) // Ablehnen mit Grund: der Grund ist das Lern-Gedächtnis des Kreislaufs (Radar/Specifier // bekommen ihn als „nie wieder vorschlagen"-Material) — deshalb ein kleines Inline-Feld. const [rejecting, setRejecting] = useState(false) const [grund, setGrund] = useState("") const running = item.status?.state === "laeuft" || item.status?.state === "rollback" const decided = item.status?.state === "eingespielt" // Lucy-Karten (Desktop-App): Annehmen baut am PC (dist) statt auf der Box zu deployen. const repo = item.repo ?? "mc2" const isLucy = repo === "lucy" const k = `${repo}:${item.branch}` async function loadDiff() { const next = !open setOpen(next) if (next && diff === null) { setDiffLoading(true) try { const r = await api<{ diff: string; truncated: boolean }>(`/api/auftragsbuch/diff?branch=${encodeURIComponent(item.branch)}&repo=${repo}`) setDiff(r.diff + (r.truncated ? "\n… (gekürzt)" : "")) } catch (e: any) { setDiff(`(Diff nicht ladbar: ${e?.message || e})`) } finally { setDiffLoading(false) } } } return (
    {KIND_LABEL[item.kind] ?? item.kind} {isLucy && ( Lucy )} {item.branch} {fmtWhen(item.ts)}

    {item.subject || "(ohne Titel)"}

    {item.body &&

    {item.body}

    } {item.empfehlung && (

    Empfehlung: {item.empfehlung.empfehlung === "ANNEHMEN" ? "Annehmen" : item.empfehlung.empfehlung === "ABLEHNEN" ? "Ablehnen" : "Unklar"}. {item.empfehlung.satz}

    )}
    {!running && !decided && (
    {!item.verwaist && ( )}
    )}
    {rejecting && !running && !decided && (

    Der Branch '{item.branch}' wird auf Gitea gelöscht (die Arbeit bleibt in der Historie). Sag der Box kurz warum — dann schlägt sie dir so etwas nicht wieder vor.

    setGrund(e.target.value)} placeholder={"Grund (optional) — z. B. „gibt es schon“ oder „will ich nicht“"} maxLength={400} className="w-full rounded-lg border border-border/60 bg-background/60 px-2.5 py-1.5 text-xs text-foreground placeholder:text-muted-foreground/50 focus:border-red-400/50 focus:outline-none" />
    )} {item.status && (running || !decided) && item.status.detail && (

    {item.status.detail}

    )} {item.verwaist && (

    Kaputt aufgesetzt: Dieser Branch hat keinen gemeinsamen Ursprung mit main (der Werkstatt-Worker hat nicht richtig geklont) — Annehmen ist technisch unmöglich, deshalb fehlt der Knopf. Am besten ablehnen mit Grund; die Idee kann danach frisch in die Queue.

    )}
    {item.ahead} Commit{item.ahead === 1 ? "" : "s"} {item.shortstat && {item.shortstat.trim()}} {item.verwaist && ( kein gemeinsamer Ursprung )} {item.leer && ( Diff leer — bringt nichts )} {item.behind > 0 && !item.verwaist && ( {item.behind} hinter main )} {item.frontend_ohne_build && ( Frontend ohne Build )}
    {open && (
    {item.files.map((f) => ( {f.status} {f.path} ))} {item.files_truncated && }
    {diffLoading ? Diff wird geladen … :
    {
                        (diff ?? "").split("\n").map((ln, i) => (
                          {ln || " "}
                        ))
                      }
    }
    )}
    ) } function KandidatCard({ k, busy, onAct, showConfirm, }: { k: SkillKandidat busy: Record onAct: (key: string, path: string, body: object, done?: string) => void showConfirm: (title: string, msg: string, ok: () => void) => void }) { const [open, setOpen] = useState(false) return (
    Traum-Idee {k.file} {fmtWhen(k.mtime)}

    {k.title}

    {open && (
    {k.preview}
    )}
    ) }