import { useState } from "react" import { Inbox, Check, X, GitBranch, FileDiff, Loader2, AlertTriangle, Sparkles, ChevronDown, ChevronRight, Clock, Hammer, RefreshCw, Lightbulb, Send, } from "lucide-react" import { api, type AuftragItem, type IdeenItem, type SkillKandidat } from "@/lib/api" 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() 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 empty = items.length === 0 && kandidaten.length === 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 && (
Fertige Patches ({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: "wartet", cls: "text-muted-foreground border-border/60 bg-background/30" }, 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" }, } function IdeenSection() { const { data, isLoading } = useIdeen() const qc = useQueryClient() const { showAlert, dialogElement } = useDialog() const [text, setText] = useState("") const [sending, setSending] = useState(false) const [busy, setBusy] = useState>({}) const [openLog, setOpenLog] = useState(null) 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 : "" }) }) 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 })) } } // 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" />

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 „Fertige Patches“, 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 && (
    {items.map((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 const canOpen = it.status === "triage" || it.status === "running" return (
  • {ui.spin && } {ui.label} {it.titel} {fmtWhen(it.erstellt)} {(it.status === "done" || it.status === "blocked") && ( )} {canOpen && ( )}
    {isOpen && canOpen && (
    {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

    )}
    )}
  • ) })}
)} {data?.available && !isLoading && items.length === 0 && (

Queue ist leer — wirf der Box eine Idee zu.

)}
{dialogElement}
) } 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}
)}
) }