6d5b09a592
Das Auftragsbuch-Gegenstueck fuer Lucy (Abloesungs-Paket S3, PC-Annahme-Weg): - backend/services/auftragsbuch.py: Multi-Repo (mc2 + lucy via ~/lucy-Clone). Karten tragen repo-Feld; Status-Schluessel fuer Lucy = 'lucy:<branch>'; fehlt ~/lucy, werden Lucy-Karten still weggelassen. Runner je Repo. - deploy/lucy-annahme.sh: detached Annahme-Runner fuer Lucy-Karten. Vorpruefung am PC VOR dem Merge (Executor erreichbar, Arbeitskopie main+clean, laeuft Lucy?), dann Merge im isolierten Worktree von ~/lucy -> Push main -> PC zieht ff-only und baut detached (deploy/lucy-annahme.ps1 im Lucy-Repo), Box pollt .lucy-annahme.json und spiegelt Fortschritt auf die Karte. Rot = Merge automatisch revertiert, laufende Lucy bleibt die alte. Executor-Zugang (URL+Token) kommt aus ~/.hermes/config.yaml — kein zweiter Ablageort. Neustart nur, wenn Lucy vorher lief (User-Entscheid). - Router/UI: repo-Parameter (rueckwaertskompatibel, Default mc2), rosa Lucy-Badge, eigener Annahme-Confirm-Text, Diff/Ablehnen je Repo. - werkstatt-SOUL: Lucy-Auftraege ebenfalls propose-only, bauen macht der PC bei der Annahme. - Doku: docs/AUFTRAGSBUCH.md (Lucy-Annahme-Kapitel), docs/wissen/OFFENE-FAEDEN (S2 erledigt, S3-Stand). Geprueft: py_compile gruen, bash -n gruen, tsc+vite build gruen (dist dabei).
461 lines
24 KiB
TypeScript
461 lines
24 KiB
TypeScript
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, 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<string, string> = {
|
|
wartung: "Werkstatt",
|
|
orchestrator: "Orchestrator",
|
|
doku: "Doku",
|
|
feature: "Feature",
|
|
}
|
|
|
|
const STATE_UI: Record<string, { label: string; cls: string; spin?: boolean }> = {
|
|
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<Record<string, boolean>>({})
|
|
|
|
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 (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<h1 className="bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent">
|
|
Auftragsbuch
|
|
</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Was die Box gebaut oder sich ausgedacht hat — du entscheidest. Annehmen spielt es mit Fangnetz ein (Health-Check, automatischer Rollback).
|
|
</p>
|
|
</div>
|
|
<button onClick={() => refetch()}
|
|
className="flex h-9 items-center gap-1.5 rounded-lg border border-border/60 bg-card/45 px-3 text-xs font-semibold text-muted-foreground hover:text-foreground hover:border-primary/40 transition-all cursor-pointer">
|
|
<RefreshCw className={cn("h-3.5 w-3.5", isFetching && "animate-spin")} /> Aktualisieren
|
|
</button>
|
|
</div>
|
|
|
|
{data && !data.available && (
|
|
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/5 p-4 text-xs text-amber-300">
|
|
Das Auftragsbuch lebt auf der Box (liest die Gitea-Branches dort). Im lokalen Dev-Modus ist hier nichts zu sehen.
|
|
</div>
|
|
)}
|
|
|
|
{isLoading && (
|
|
<div className="flex items-center gap-2 rounded-2xl border border-border/60 bg-card/30 p-6 text-xs text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" /> Vorschläge werden geladen …
|
|
</div>
|
|
)}
|
|
|
|
{data?.available && empty && (
|
|
<div className="flex items-center gap-3 rounded-2xl border border-emerald-500/25 bg-emerald-500/5 p-5">
|
|
<Check className="h-5 w-5 shrink-0 text-emerald-400" />
|
|
<div>
|
|
<p className="text-sm font-semibold text-foreground">Nichts zu entscheiden</p>
|
|
<p className="text-xs text-muted-foreground">Keine offenen Vorschläge. Werkstatt, Orchestrator und Traum melden sich hier, sobald es etwas gibt.</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<IdeenSection />
|
|
|
|
{items.length > 0 && (
|
|
<section className="space-y-3">
|
|
<SectionLabel icon={GitBranch}>Fertige Patches ({items.length})</SectionLabel>
|
|
{items.map((it) => (
|
|
<ProposalCard key={`${it.repo ?? "mc2"}:${it.branch}`} item={it} busy={busy} onAct={act} showConfirm={showConfirm} />
|
|
))}
|
|
</section>
|
|
)}
|
|
|
|
{kandidaten.length > 0 && (
|
|
<section className="space-y-3">
|
|
<SectionLabel icon={Sparkles}>Skill-Kandidaten aus dem Traum ({kandidaten.length})</SectionLabel>
|
|
{kandidaten.map((k) => (
|
|
<KandidatCard key={k.file} k={k} busy={busy} onAct={act} showConfirm={showConfirm} />
|
|
))}
|
|
</section>
|
|
)}
|
|
|
|
{dialogElement}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SectionLabel({ icon: Icon, children }: { icon: any; children: React.ReactNode }) {
|
|
return (
|
|
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
|
|
<Icon className="h-3.5 w-3.5" /> {children}
|
|
</p>
|
|
)
|
|
}
|
|
|
|
// ── Ideen-Queue (natives Hermes-Kanban) ──────────────────────────────────────
|
|
// Eine Queue, drei Türen (Zentrale/Telegram/Lucy). Idee rein → die Box arbeitet sie
|
|
// selbst aus (Specifier) und baut (Werkstatt-Profil) → Code kommt als Karte zurück.
|
|
|
|
const IDEEN_STATE: Record<string, { label: string; cls: string; spin?: boolean }> = {
|
|
triage: { label: "wird ausgearbeitet …", cls: "text-violet-300 border-violet-500/40 bg-violet-500/10", spin: true },
|
|
todo: { label: "wartet", cls: "text-muted-foreground border-border/60 bg-background/30" },
|
|
scheduled: { label: "geplant", cls: "text-muted-foreground border-border/60 bg-background/30" },
|
|
ready: { label: "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<Record<string, boolean>>({})
|
|
|
|
const items = data?.items ?? []
|
|
const reload = () => qc.invalidateQueries({ queryKey: qk.ideen })
|
|
|
|
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 (
|
|
<section className="space-y-3">
|
|
<SectionLabel icon={Lightbulb}>
|
|
Ideen-Queue{data ? ` — ${data.offen} offen` : ""}
|
|
</SectionLabel>
|
|
<div className="rounded-2xl border border-border/60 bg-card/45 p-4 shadow-lg shadow-black/15 backdrop-blur-md space-y-3">
|
|
<div className="flex gap-2">
|
|
<input
|
|
value={text}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<button
|
|
onClick={submit}
|
|
disabled={sending || text.trim().length < 3}
|
|
className="flex h-9 shrink-0 items-center gap-1.5 rounded-lg border border-primary/40 bg-primary/10 px-3 text-[11px] font-bold uppercase tracking-wide text-primary transition-all hover:bg-primary/20 cursor-pointer disabled:opacity-50">
|
|
{sending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />} In die Queue
|
|
</button>
|
|
</div>
|
|
<p className="text-[11px] text-muted-foreground">
|
|
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).
|
|
</p>
|
|
|
|
{isLoading && (
|
|
<p className="flex items-center gap-2 text-xs text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin" /> Queue wird geladen …</p>
|
|
)}
|
|
{data?.fehler && (
|
|
<p className="text-xs text-amber-300">Queue gerade nicht lesbar — neuer Versuch läuft.</p>
|
|
)}
|
|
|
|
{items.length > 0 && (
|
|
<ul className="space-y-1.5">
|
|
{items.map((it: IdeenItem) => {
|
|
const ui = IDEEN_STATE[it.status] ?? { label: it.status, cls: "text-muted-foreground border-border/60 bg-background/30" }
|
|
return (
|
|
<li key={it.id} className="flex items-center gap-2 rounded-xl border border-border/40 bg-background/30 px-3 py-2">
|
|
<span className={cn("flex shrink-0 items-center gap-1.5 rounded-lg border px-2 py-0.5 text-[10px] font-bold", ui.cls)}>
|
|
{ui.spin && <Loader2 className="h-3 w-3 animate-spin" />}
|
|
{ui.label}
|
|
</span>
|
|
<span className="min-w-0 flex-1 truncate text-xs text-foreground" title={it.body || it.titel}>{it.titel}</span>
|
|
<span className="flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground/70"><Clock className="h-3 w-3" />{fmtWhen(it.erstellt)}</span>
|
|
{(it.status === "done" || it.status === "blocked") && (
|
|
<button
|
|
disabled={!!busy[it.id]}
|
|
onClick={() => archive(it.id)}
|
|
title="Aus der Liste räumen (wandert ins Kanban-Archiv)"
|
|
className="shrink-0 rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer disabled:opacity-50">
|
|
{busy[it.id] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
|
|
</button>
|
|
)}
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
)}
|
|
{data?.available && !isLoading && items.length === 0 && (
|
|
<p className="text-xs text-muted-foreground/70">Queue ist leer — wirf der Box eine Idee zu.</p>
|
|
)}
|
|
</div>
|
|
{dialogElement}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function StateBadge({ item }: { item: AuftragItem }) {
|
|
const st = item.status
|
|
if (!st) return null
|
|
const ui = STATE_UI[st.state]
|
|
if (!ui) return null
|
|
return (
|
|
<span className={cn("flex items-center gap-1.5 rounded-lg border px-2 py-0.5 text-[10px] font-bold", ui.cls)} title={st.detail}>
|
|
{ui.spin && <Loader2 className="h-3 w-3 animate-spin" />}
|
|
{ui.label}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function ProposalCard({
|
|
item, busy, onAct, showConfirm,
|
|
}: {
|
|
item: AuftragItem
|
|
busy: Record<string, boolean>
|
|
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<string | null>(null)
|
|
const [diffLoading, setDiffLoading] = useState(false)
|
|
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 (
|
|
<div className="rounded-2xl border border-border/60 bg-card/45 p-4 shadow-lg shadow-black/15 backdrop-blur-md space-y-3">
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="rounded-md border border-primary/30 bg-primary/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-primary">
|
|
{KIND_LABEL[item.kind] ?? item.kind}
|
|
</span>
|
|
{isLucy && (
|
|
<span className="rounded-md border border-pink-500/40 bg-pink-500/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-pink-300"
|
|
title="Vorschlag fürs Lucy-Repo — Annehmen baut die App am PC neu und startet sie neu, wenn sie läuft.">
|
|
Lucy
|
|
</span>
|
|
)}
|
|
<span className="font-mono text-[11px] text-muted-foreground truncate" title={item.branch}>{item.branch}</span>
|
|
<span className="flex items-center gap-1 text-[10px] text-muted-foreground/70"><Clock className="h-3 w-3" />{fmtWhen(item.ts)}</span>
|
|
<StateBadge item={item} />
|
|
</div>
|
|
<p className="mt-1.5 text-sm font-semibold text-foreground">{item.subject || "(ohne Titel)"}</p>
|
|
{item.body && <p className="mt-1 whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground line-clamp-6">{item.body}</p>}
|
|
</div>
|
|
|
|
{!running && !decided && (
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<button
|
|
disabled={!!busy[k]}
|
|
onClick={() =>
|
|
showConfirm("Vorschlag annehmen?",
|
|
isLucy
|
|
? `'${item.branch}' wird im Lucy-Repo auf main gemergt, dann baut dein PC die App neu (dist) und startet Lucy neu, falls sie gerade läuft. Der PC muss dafür an sein. Schlägt Build oder Start fehl, dreht die Box den Merge automatisch zurück — die laufende Lucy bleibt die alte.`
|
|
: `'${item.branch}' wird auf main gemergt und live eingespielt. Die Zentrale startet dabei kurz neu. Geht der Health-Check danach rot, rollt die Box automatisch zurück.${item.frontend_ohne_build ? "\n\nAchtung: Enthält Frontend-Quelltext ohne gebautes Bundle — die Oberfläche bleibt alt, bis am PC gebaut wird." : ""}`,
|
|
() => onAct(k, "/api/auftragsbuch/annehmen", { branch: item.branch, repo }))}
|
|
className="flex h-9 items-center gap-1.5 rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 text-[11px] font-bold uppercase tracking-wide text-emerald-300 transition-all hover:bg-emerald-500/20 cursor-pointer disabled:opacity-50">
|
|
{busy[k] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />} Annehmen
|
|
</button>
|
|
<button
|
|
disabled={!!busy["ab:" + k]}
|
|
onClick={() =>
|
|
showConfirm("Vorschlag ablehnen?",
|
|
`Der Branch '${item.branch}' wird auf Gitea gelöscht. Die Arbeit bleibt in der Historie, aber die Karte verschwindet.`,
|
|
() => onAct("ab:" + k, "/api/auftragsbuch/ablehnen", { branch: item.branch, repo }))}
|
|
className="flex h-9 items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-500/5 px-3 text-[11px] font-bold uppercase tracking-wide text-red-300/90 transition-all hover:bg-red-500/15 cursor-pointer disabled:opacity-50">
|
|
{busy["ab:" + k] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />} Ablehnen
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{item.status && (running || !decided) && item.status.detail && (
|
|
<p className="text-[11px] text-muted-foreground">{item.status.detail}</p>
|
|
)}
|
|
|
|
<div className="flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
|
|
<span className="rounded bg-background/40 px-1.5 py-0.5 font-mono">{item.ahead} Commit{item.ahead === 1 ? "" : "s"}</span>
|
|
{item.shortstat && <span className="rounded bg-background/40 px-1.5 py-0.5 font-mono">{item.shortstat.trim()}</span>}
|
|
{item.behind > 0 && (
|
|
<span className="flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-amber-300" title="main ist seit diesem Vorschlag weitergelaufen">
|
|
<AlertTriangle className="h-3 w-3" /> {item.behind} hinter main
|
|
</span>
|
|
)}
|
|
{item.frontend_ohne_build && (
|
|
<span className="flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-amber-300" title="Die Box kann kein Frontend bauen (kein Node) — nach dem Annehmen bleibt die Oberfläche alt, bis am PC gebaut wird.">
|
|
<AlertTriangle className="h-3 w-3" /> Frontend ohne Build
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<button onClick={loadDiff}
|
|
className="flex items-center gap-1.5 text-[11px] font-semibold text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
|
|
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
|
<FileDiff className="h-3.5 w-3.5" /> {item.files.length} Datei{item.files.length === 1 ? "" : "en"} — Änderungen ansehen
|
|
</button>
|
|
{open && (
|
|
<div className="mt-2 space-y-2">
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{item.files.map((f) => (
|
|
<span key={f.path} className="rounded bg-background/40 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
|
<b className={cn(f.status.startsWith("A") && "text-emerald-400", f.status.startsWith("D") && "text-red-400", f.status.startsWith("M") && "text-sky-400")}>{f.status}</b> {f.path}
|
|
</span>
|
|
))}
|
|
{item.files_truncated && <span className="text-[10px] text-muted-foreground/60">…</span>}
|
|
</div>
|
|
<div className="max-h-96 overflow-auto rounded-xl border border-border/50 bg-background/50 p-3">
|
|
{diffLoading
|
|
? <span className="text-[11px] text-muted-foreground">Diff wird geladen …</span>
|
|
: <pre className="text-[10px] leading-relaxed text-foreground/80 whitespace-pre">{
|
|
(diff ?? "").split("\n").map((ln, i) => (
|
|
<span key={i} className={cn("block", ln.startsWith("+") && !ln.startsWith("+++") && "text-emerald-300/90 bg-emerald-500/5", ln.startsWith("-") && !ln.startsWith("---") && "text-red-300/90 bg-red-500/5", (ln.startsWith("@@") || ln.startsWith("diff ")) && "text-sky-300/90")}>{ln || " "}</span>
|
|
))
|
|
}</pre>}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function KandidatCard({
|
|
k, busy, onAct, showConfirm,
|
|
}: {
|
|
k: SkillKandidat
|
|
busy: Record<string, boolean>
|
|
onAct: (key: string, path: string, body: object, done?: string) => void
|
|
showConfirm: (title: string, msg: string, ok: () => void) => void
|
|
}) {
|
|
const [open, setOpen] = useState(false)
|
|
return (
|
|
<div className="rounded-2xl border border-violet-500/25 bg-violet-500/[0.04] p-4 shadow-lg shadow-black/15 backdrop-blur-md space-y-3">
|
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="rounded-md border border-violet-500/30 bg-violet-500/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-violet-300">Traum-Idee</span>
|
|
<span className="font-mono text-[11px] text-muted-foreground truncate">{k.file}</span>
|
|
<span className="flex items-center gap-1 text-[10px] text-muted-foreground/70"><Clock className="h-3 w-3" />{fmtWhen(k.mtime)}</span>
|
|
</div>
|
|
<p className="mt-1.5 text-sm font-semibold text-foreground">{k.title}</p>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<button
|
|
disabled={!!busy["sk:" + k.file]}
|
|
onClick={() =>
|
|
showConfirm("Werkstatt beauftragen?",
|
|
`Der Kandidat '${k.title}' geht als Auftrag an die Werkstatt (Orchestrator, propose-only). Das Ergebnis erscheint später als neuer Patch-Vorschlag hier im Auftragsbuch — nichts geht ungefragt live.`,
|
|
() => onAct("sk:" + k.file, "/api/auftragsbuch/skill/annehmen", { file: k.file },
|
|
"Die Werkstatt arbeitet. Das Ergebnis taucht als neuer Vorschlag im Auftragsbuch auf (das kann einige Minuten dauern)."))}
|
|
className="flex h-9 items-center gap-1.5 rounded-lg border border-violet-500/40 bg-violet-500/10 px-3 text-[11px] font-bold uppercase tracking-wide text-violet-300 transition-all hover:bg-violet-500/20 cursor-pointer disabled:opacity-50">
|
|
{busy["sk:" + k.file] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Hammer className="h-3.5 w-3.5" />} Beauftragen
|
|
</button>
|
|
<button
|
|
disabled={!!busy["skab:" + k.file]}
|
|
onClick={() =>
|
|
showConfirm("Kandidat verwerfen?",
|
|
`'${k.title}' wandert ins Archiv des Wissens-Vaults (verworfen/). Der Traum schlägt ihn nicht erneut vor.`,
|
|
() => onAct("skab:" + k.file, "/api/auftragsbuch/skill/ablehnen", { file: k.file }))}
|
|
className="flex h-9 items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-500/5 px-3 text-[11px] font-bold uppercase tracking-wide text-red-300/90 transition-all hover:bg-red-500/15 cursor-pointer disabled:opacity-50">
|
|
{busy["skab:" + k.file] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />} Verwerfen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<button onClick={() => setOpen((o) => !o)}
|
|
className="flex items-center gap-1.5 text-[11px] font-semibold text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
|
|
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
|
<Inbox className="h-3.5 w-3.5" /> Vorschlag lesen
|
|
</button>
|
|
{open && (
|
|
<pre className="max-h-72 overflow-auto rounded-xl border border-border/50 bg-background/50 p-3 text-[11px] leading-relaxed text-foreground/85 whitespace-pre-wrap">{k.preview}</pre>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|