56b374a0aa
Ampel / ampel (push) Successful in 22s
User: "dein Umbau ist immer noch nicht da. Oder denke ICH falsch?" — er dachte nicht falsch. Der ganze Auswahl-Block hing an `text.trim().length >= 3`, also sah man bei leerem Eingabefeld NICHTS davon. Kein Hinweis, dass es ueberhaupt eine Wahl gibt, keine Moeglichkeit sie vorher einzustellen, und nach jedem Neuladen war alles wieder weg. Meine Begruendung war, die Auswahl sei eine Entscheidung ZU dem Text und habe ohne Text nichts zu suchen. Das ist logisch sauber und praktisch falsch: ein Mechanismus, den man nicht sieht, existiert fuer den Nutzer nicht. Jetzt immer sichtbar. Ohne Einordnung steht davor "VOREINGESTELLT" — die Zeile ist dann eine Einstellung, keine Aussage ueber einen Text, den es noch nicht gibt. Sobald die Box gelesen hat, verschwindet das Wort und das Etikett "gelesen" erscheint. Aendern kann man beides jederzeit, auch vor dem Tippen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1024 lines
56 KiB
TypeScript
1024 lines
56 KiB
TypeScript
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<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: "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<string, { label: string; cls: string; icon: typeof Hammer; title: string }> = {
|
||
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<string, string> = {
|
||
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<Record<string, boolean>>({})
|
||
const [openLog, setOpenLog] = useState<string | null>(null)
|
||
const [openKonzept, setOpenKonzept] = useState<string | null>(null)
|
||
const [antworten, setAntworten] = useState<Record<string, string>>({})
|
||
|
||
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 (
|
||
<li key={it.id} className={cn("flex flex-col gap-2 rounded-xl border bg-background/30 px-3 py-2", blocked ? "border-red-500/30" : haengt ? "border-amber-500/40" : "border-border/40")}>
|
||
<div className="flex items-center gap-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>
|
||
{haengt && (
|
||
<span className="flex shrink-0 items-center gap-1 rounded-lg border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-bold text-amber-300"
|
||
title="Der Worker lebt, gibt aber kein Lebenszeichen mehr — vermutlich wartet er auf etwas, das nie kommt. „Neuer Versuch“ startet die Karte frisch.">
|
||
<AlertTriangle className="h-3 w-3" /> {Math.round((it.puls_alter ?? 0) / 60)} min ohne Regung
|
||
</span>
|
||
)}
|
||
{(it.prio ?? 0) !== 0 && (
|
||
<span className="shrink-0 rounded bg-background/50 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground"
|
||
title="Priorität — höher kommt zuerst dran (bei gleicher Prio: Erstellzeit)">P{it.prio}</span>
|
||
)}
|
||
{(() => {
|
||
const a = ART_UI[it.art || (it.welt === "ide" ? "projekt" : "box")]
|
||
if (!a) return null
|
||
const Icon = a.icon
|
||
return (
|
||
<span className={cn("flex shrink-0 items-center gap-1 rounded-lg border px-2 py-0.5 text-[10px] font-bold", a.cls)} title={a.title}>
|
||
<Icon className="h-3 w-3" /> {a.label}
|
||
</span>
|
||
)
|
||
})()}
|
||
{/* Tooltip = der volle TITEL (der ist abgeschnitten), nicht der Auftragstext —
|
||
der beginnt mit Vorspann-Textbausteinen und half beim Lesen nie. */}
|
||
<span className="min-w-0 flex-1 truncate text-xs text-foreground" title={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>
|
||
{wartend && (
|
||
<span className="flex shrink-0 items-center">
|
||
<button
|
||
disabled={!!busy["pr:" + it.id]}
|
||
onClick={() => steuer("pr:", it.id, "/api/ideen/prio", { id: it.id, richtung: "hoch" })}
|
||
title="Vorziehen (Prio +1)"
|
||
className="rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer disabled:opacity-50">
|
||
<ArrowUp className="h-3.5 w-3.5" />
|
||
</button>
|
||
<button
|
||
disabled={!!busy["pr:" + it.id]}
|
||
onClick={() => steuer("pr:", it.id, "/api/ideen/prio", { id: it.id, richtung: "runter" })}
|
||
title="Zurückstellen (Prio −1)"
|
||
className="rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer disabled:opacity-50">
|
||
<ArrowDown className="h-3.5 w-3.5" />
|
||
</button>
|
||
</span>
|
||
)}
|
||
{(it.status === "running" || blocked) && (
|
||
<button
|
||
disabled={!!busy["rt:" + it.id]}
|
||
onClick={() =>
|
||
showConfirm("Neuer Versuch?",
|
||
it.status === "running"
|
||
? `Der laufende Worker von '${it.titel.slice(0, 60)}' wird beendet (samt gestarteter Test-Prozesse) und die Karte frisch gestartet. Bisheriger Zwischenstand im Workspace bleibt liegen.`
|
||
: `Die Karte '${it.titel.slice(0, 60)}' wird entsperrt und läuft weiter, sobald ein Platz frei ist.`,
|
||
() => steuer("rt:", it.id, "/api/ideen/retry", { id: it.id }))}
|
||
title="Neuer Versuch — Karte frisch starten"
|
||
className={cn("shrink-0 rounded-md p-1 transition-colors cursor-pointer disabled:opacity-50",
|
||
haengt ? "text-amber-300 hover:text-amber-200" : "text-muted-foreground/60 hover:text-foreground")}>
|
||
{busy["rt:" + it.id] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RotateCcw className="h-3.5 w-3.5" />}
|
||
</button>
|
||
)}
|
||
{(it.status === "running" || wartend || it.status === "triage") && (
|
||
<button
|
||
disabled={!!busy["st:" + it.id]}
|
||
onClick={() =>
|
||
showConfirm("Karte stoppen?",
|
||
`'${it.titel.slice(0, 60)}' wird angehalten${it.status === "running" ? " und der laufende Worker beendet" : ""}. Sie bleibt als gestoppt im Board und kann jederzeit per „Neuer Versuch“ weiterlaufen.`,
|
||
() => steuer("st:", it.id, "/api/ideen/stopp", { id: it.id }))}
|
||
title="Stoppen — Karte anhalten"
|
||
className="shrink-0 rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-red-300 cursor-pointer disabled:opacity-50">
|
||
{busy["st:" + it.id] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Square className="h-3.5 w-3.5" />}
|
||
</button>
|
||
)}
|
||
{(it.status === "done" || blocked) && (
|
||
<button
|
||
disabled={!!busy[it.id]}
|
||
onClick={() => archive(it.id)}
|
||
title={blocked ? "Aufgeben und aus der Liste räumen (wandert ins Kanban-Archiv)" : "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>
|
||
)}
|
||
{it.welt === "ide" && (it.status === "running" || it.status === "done") && (
|
||
<button
|
||
onClick={() => setOpenKonzept(openKonzept === it.id ? null : it.id)}
|
||
title="Konzept ansehen (das ausgearbeitete KONZEPT.md)"
|
||
className={cn("flex shrink-0 items-center gap-1 rounded-md px-1.5 py-1 text-[10px] font-bold transition-colors cursor-pointer",
|
||
openKonzept === it.id ? "text-violet-300" : "text-violet-300/70 hover:text-violet-200")}>
|
||
<FileText className="h-3.5 w-3.5" /> Konzept
|
||
</button>
|
||
)}
|
||
{canOpen && (
|
||
<button
|
||
onClick={() => setOpenLog(isOpen ? null : it.id)}
|
||
title={it.status === "done" ? "Ergebnis anzeigen" : "Worker-Log anzeigen"}
|
||
className="shrink-0 rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer">
|
||
{isOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{openKonzept === it.id && (
|
||
<KonzeptPanel
|
||
id={it.id}
|
||
istIdee={it.art === "idee" && it.status === "done"}
|
||
onWeiter={reload}
|
||
/>
|
||
)}
|
||
|
||
{it.status === "running" && it.notiz && (
|
||
<p className="-mt-1 ml-1 text-[11px] italic leading-relaxed text-sky-200/80" title="Jüngste Zwischenmeldung des Workers (Heartbeat)">
|
||
„{it.notiz}“
|
||
</p>
|
||
)}
|
||
{(it.status === "todo" || it.status === "scheduled" || it.status === "ready") && (it.wartet_auf?.length ?? 0) > 0 && (
|
||
<p className="-mt-1 ml-1 text-[10px] text-muted-foreground/70">
|
||
→ wartet auf: {it.wartet_auf!.join(" · ")}
|
||
</p>
|
||
)}
|
||
|
||
{blocked && (
|
||
<div className="space-y-2 border-t border-red-500/20 pt-2">
|
||
<p className="flex items-start gap-1.5 text-[11px] leading-relaxed text-red-200/90">
|
||
<HelpCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-300" />
|
||
<span>
|
||
<b>{FRAGE_HINT[it.frage_kind || ""] ?? "Die Aufgabe hängt und wartet auf dich:"}</b>{" "}
|
||
{it.frage
|
||
? <span className="text-foreground/90">{it.frage}</span>
|
||
: <span className="text-muted-foreground italic">kein genauer Grund hinterlegt — antworte einfach mit einer Entscheidung, dann läuft sie weiter.</span>}
|
||
</span>
|
||
</p>
|
||
<div className="flex gap-2">
|
||
<input
|
||
value={antworten[it.id] || ""}
|
||
onChange={(e) => 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"
|
||
/>
|
||
<button
|
||
onClick={() => answer(it.id)}
|
||
disabled={answering || (antworten[it.id] || "").trim().length < 1}
|
||
className="flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 text-[10px] font-bold uppercase tracking-wide text-emerald-300 transition-all hover:bg-emerald-500/20 cursor-pointer disabled:opacity-50">
|
||
{answering ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />} Antworten & weiter
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{isOpen && canOpen && it.status !== "done" && (
|
||
<div className="ml-2 -mt-1">
|
||
<div className="rounded-lg border border-border/50 bg-background/50 p-2">
|
||
{logEnabled && logData.isLoading ? (
|
||
<p className="text-[10px] text-muted-foreground animate-pulse">Log wird geladen …</p>
|
||
) : logData.error ? (
|
||
<p className="text-[10px] text-red-400">Log nicht ladbar</p>
|
||
) : logData.data?.available && logData.data.lines.length > 0 ? (
|
||
<pre className="max-h-64 overflow-auto font-mono text-[10px] leading-relaxed text-foreground/80 whitespace-pre-wrap">
|
||
{logData.data.lines.join("\n")}
|
||
</pre>
|
||
) : (
|
||
<p className="text-[10px] text-muted-foreground">Kein Log verfügbar</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{isOpen && it.status === "done" && <ErgebnisPanel id={it.id} />}
|
||
</li>
|
||
)
|
||
}
|
||
|
||
// 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">
|
||
{/* 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. */}
|
||
<div className="flex items-center justify-between gap-3">
|
||
<SectionLabel icon={Lightbulb}>
|
||
{(() => {
|
||
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"
|
||
})()}
|
||
</SectionLabel>
|
||
{(() => {
|
||
const fertig = items.filter((i) => i.status === "done").length
|
||
if (fertig === 0) return null
|
||
return (
|
||
<button
|
||
disabled={!!busy["arch:*"]}
|
||
onClick={() => archiveAlle("", fertig, `Alle ${fertig} fertigen Karten`)}
|
||
title="Räumt ALLE fertigen Karten weg — auch die in abgeschlossenen Projekt-Ketten. Laufende, hängende und wartende bleiben unberührt."
|
||
className="flex shrink-0 items-center gap-1.5 rounded-lg border border-border/60 px-2.5 py-1 text-[11px] font-semibold text-muted-foreground transition-all hover:border-primary/40 hover:text-foreground disabled:opacity-50 cursor-pointer">
|
||
{busy["arch:*"] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
|
||
{fertig} fertige wegräumen
|
||
</button>
|
||
)
|
||
})()}
|
||
</div>
|
||
<div className="mc-card p-4 space-y-4">
|
||
{/* 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. */}
|
||
<div className="flex gap-2">
|
||
<input
|
||
value={text}
|
||
onChange={(e) => { 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"
|
||
/>
|
||
<button
|
||
onClick={submit}
|
||
disabled={sending || text.trim().length < 3}
|
||
className="flex h-10 shrink-0 items-center gap-1.5 rounded-lg border border-primary/40 bg-primary/10 px-4 text-xs font-semibold text-primary transition-all hover:bg-primary/20 disabled:opacity-40 cursor-pointer">
|
||
{sending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||
Los
|
||
</button>
|
||
</div>
|
||
|
||
{/* 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 ? (
|
||
<div className="space-y-3 rounded-xl border border-border/40 bg-background/20 p-3">
|
||
<div>
|
||
<p className="mb-1.5 text-[11px] text-muted-foreground/70">Wer baut das?</p>
|
||
<div className="grid gap-2 sm:grid-cols-2">
|
||
<Wahl aktiv={modus !== "box"}
|
||
onWahl={() => 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." }} />
|
||
<Wahl aktiv={modus === "box"}
|
||
onWahl={() => setModus("box")}
|
||
k={{ id: "box", icon: Hammer, titel: "Die Box, allein",
|
||
text: "Für dein Heimlabor. Das Ergebnis kommt als Patch-Vorschlag ins Auftragsbuch." }} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* 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" && (
|
||
<div className="border-l-2 border-primary/40 pl-3">
|
||
<p className="mb-1.5 text-[11px] text-muted-foreground/70">Und wie startest du?</p>
|
||
<div className="grid gap-2 sm:grid-cols-2">
|
||
<Wahl aktiv={modus === "denken"} onWahl={() => setModus("denken")}
|
||
k={{ id: "denken", icon: Lightbulb, titel: "Erst durchdenken",
|
||
text: "Konzept zuerst — auf dein Ja wird ein Repo daraus." }} />
|
||
<Wahl aktiv={modus === "repo"} onWahl={() => setModus("repo")}
|
||
k={{ id: "repo", icon: GitFork, titel: "Direkt ein Repo",
|
||
text: "Konzept und Gitea-Repo in einem Zug." }} />
|
||
</div>
|
||
{modus === "repo" ? (
|
||
<div className="mt-2.5 flex flex-wrap items-center gap-x-3 gap-y-2">
|
||
<span className="text-[11px] text-muted-foreground/70">Wie gründlich?</span>
|
||
<div className="flex rounded-lg border border-border/60 p-0.5">
|
||
{([["schlank", "schlank"], ["gruendlich", "gründlich"]] as const).map(([id, label]) => (
|
||
<button key={id} onClick={() => setTiefe(id)}
|
||
className={cn("rounded px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide transition-all cursor-pointer",
|
||
tiefe === id ? "bg-primary/15 text-primary" : "text-muted-foreground/60 hover:text-foreground")}>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<span className="ml-auto flex items-center gap-1.5">
|
||
<span className="text-[11px] text-muted-foreground/70">Läuft als</span>
|
||
<div className="flex rounded-lg border border-border/40 p-0.5">
|
||
{([["lxc", "LXC"], ["docker", "Docker"]] as const).map(([id, label]) => (
|
||
<button key={id} onClick={() => setZiel(id)}
|
||
title={id === "lxc"
|
||
? "Dienst in einem eigenen Proxmox-LXC (systemd, kein Docker). Wird vom Backup-Server mitgesichert."
|
||
: "Natives Docker: Dockerfile + compose, portabel und pipeline-tauglich."}
|
||
className={cn("rounded px-2 py-0.5 text-[10px] font-semibold transition-all cursor-pointer",
|
||
ziel === id ? "bg-foreground/10 text-foreground" : "text-muted-foreground/50 hover:text-foreground/80")}>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<p className="mt-2 flex items-start gap-1.5 text-[10px] leading-relaxed text-muted-foreground/60">
|
||
<HelpCircle className="mt-px h-3 w-3 shrink-0" />
|
||
Wie gründlich und wo es läuft, fragt die Box erst beim „gefällt mir" — dann weißt du mehr.
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Beweisbar statt behauptet: die Quote, an der dieser ganze Entwurf haengt. */}
|
||
{(() => {
|
||
const q = quoteLesen()
|
||
if (q.gesendet < 3) return null
|
||
return (
|
||
<p className="border-t border-border/30 pt-2 text-[10px] text-muted-foreground/50">
|
||
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.
|
||
</p>
|
||
)
|
||
})()}
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-border/40 bg-background/20 px-3 py-2.5 text-xs">
|
||
{modus === "box" ? <Hammer className="h-4 w-4 shrink-0 text-primary" />
|
||
: modus === "repo" ? <GitFork className="h-4 w-4 shrink-0 text-primary" />
|
||
: <Lightbulb className="h-4 w-4 shrink-0 text-primary" />}
|
||
{/* 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 && (
|
||
<span className="shrink-0 text-[10px] uppercase tracking-wider text-muted-foreground/50">
|
||
Voreingestellt
|
||
</span>
|
||
)}
|
||
<span className="text-muted-foreground">
|
||
{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."}
|
||
</span>
|
||
{vorgeschlagen && (
|
||
<span className="shrink-0 rounded border border-primary/40 bg-primary/10 px-1.5 py-0.5 text-[10px] font-bold text-primary">
|
||
gelesen
|
||
</span>
|
||
)}
|
||
<button
|
||
onClick={() => { setOffen(true); quoteZaehlen("geaendert") }}
|
||
className="ml-auto shrink-0 text-[11px] text-muted-foreground/60 underline underline-offset-2 transition-colors hover:text-foreground cursor-pointer">
|
||
anders
|
||
</button>
|
||
</div>
|
||
)
|
||
)}
|
||
{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 && (
|
||
<div className="space-y-3">
|
||
{(data?.projekte ?? []).map((p) => (
|
||
<ProjektGruppe key={p.key} projekt={p} karten={items.filter((i) => 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 (
|
||
<div className="space-y-2">
|
||
{aktiv.length > 0 && <ul className="space-y-1.5">{aktiv.map((it) => zeile(it))}</ul>}
|
||
{wartend.length > 0 && (
|
||
<details className="group">
|
||
<summary className="flex cursor-pointer list-none items-center gap-1.5 py-1 text-[11px] text-muted-foreground/70 hover:text-foreground">
|
||
<ChevronRight className="h-3 w-3 transition-transform group-open:rotate-90" />
|
||
<Clock className="h-3 w-3" /> Wartet ({wartend.length})
|
||
</summary>
|
||
<ul className="mt-1.5 space-y-1.5">{wartend.map((it) => zeile(it))}</ul>
|
||
</details>
|
||
)}
|
||
{fertig.length > 0 && (
|
||
<details className="group">
|
||
<summary className="flex cursor-pointer list-none items-center gap-1.5 py-1 text-[11px] text-muted-foreground/70 hover:text-foreground">
|
||
<ChevronRight className="h-3 w-3 transition-transform group-open:rotate-90" />
|
||
<Check className="h-3 w-3 text-emerald-400/70" /> Fertig ({fertig.length})
|
||
</summary>
|
||
<ul className="mt-1.5 space-y-1.5">{fertig.map((it) => zeile(it))}</ul>
|
||
</details>
|
||
)}
|
||
</div>
|
||
)
|
||
})()}
|
||
</div>
|
||
)}
|
||
{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.
|
||
<div className="space-y-2">
|
||
<p className="text-[11px] text-muted-foreground/70">Noch nichts hier. Zum Beispiel:</p>
|
||
{[
|
||
"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) => (
|
||
<button key={b} onClick={() => setText(b)}
|
||
className="flex w-full items-center gap-2 rounded-lg border border-border/40 bg-background/20 px-3 py-2 text-left text-xs text-muted-foreground transition-all hover:border-primary/40 hover:text-foreground cursor-pointer">
|
||
<Lightbulb className="h-3.5 w-3.5 shrink-0 text-muted-foreground/50" />
|
||
{b}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{dialogElement}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
// 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 (
|
||
<button
|
||
onClick={onWahl}
|
||
className={cn("rounded-xl border p-3 text-left transition-all cursor-pointer",
|
||
aktiv ? "border-primary/60 bg-primary/5" : "border-border/50 hover:border-border")}>
|
||
<div className="flex items-center gap-2">
|
||
<k.icon className={cn("h-4 w-4 shrink-0", aktiv ? "text-primary" : "text-muted-foreground")} />
|
||
<span className="text-xs font-semibold text-foreground">{k.titel}</span>
|
||
</div>
|
||
<p className="mt-1 text-[11px] leading-relaxed text-muted-foreground">{k.text}</p>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
// 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<string, number> = { 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 (
|
||
<div className="space-y-2 rounded-xl border border-border/50 bg-background/20 p-3">
|
||
<div className="flex items-center gap-2">
|
||
<Layers className="h-4 w-4 shrink-0 text-primary/70" />
|
||
<p className="min-w-0 flex-1 truncate text-xs font-semibold text-foreground" title={projekt.titel}>{projekt.titel}</p>
|
||
<span className="shrink-0 text-[10px] text-muted-foreground">{projekt.fertig} von {projekt.gesamt} fertig</span>
|
||
</div>
|
||
<div className="h-1 overflow-hidden rounded-full bg-background/60" title={`${anteil}%`}>
|
||
<div className="h-1 rounded-full bg-primary/70 transition-all" style={{ width: `${anteil}%` }} />
|
||
</div>
|
||
{aktiv.length > 0 && <ul className="space-y-1.5">{aktiv.map((it) => zeile(it))}</ul>}
|
||
{wartend.length > 0 && (
|
||
<div>
|
||
<button
|
||
onClick={() => setZeigWartende((v) => !v)}
|
||
className="flex items-center gap-1.5 text-[11px] font-semibold text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
|
||
{zeigWartende ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||
{wartend.length} Karte{wartend.length === 1 ? "" : "n"} in der Kette — laufen automatisch der Reihe nach
|
||
</button>
|
||
{zeigWartende && <ul className="mt-1.5 space-y-1.5">{wartend.map((it) => zeile(it))}</ul>}
|
||
</div>
|
||
)}
|
||
{fertig.length > 0 && (
|
||
<div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => setZeigFertige((v) => !v)}
|
||
className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-300/70 hover:text-emerald-300 transition-colors cursor-pointer">
|
||
{zeigFertige ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||
{fertig.length} fertig
|
||
</button>
|
||
{/* Ganze abgeschlossene Kette in einem Zug wegräumen — bei fünf fertigen
|
||
Projekten waren das sonst ~18 Einzelklicks auf das X. */}
|
||
{onArchivAlle && (
|
||
<button
|
||
disabled={raeumt}
|
||
onClick={() => onArchivAlle(projekt.key, fertig.length, `„${projekt.titel}"`)}
|
||
title="Räumt die fertigen Karten dieser Kette weg (Archiv, nichts wird gelöscht)."
|
||
className="flex items-center gap-1 text-[11px] text-muted-foreground/60 transition-colors hover:text-foreground disabled:opacity-50 cursor-pointer">
|
||
{raeumt ? <Loader2 className="h-3 w-3 animate-spin" /> : <X className="h-3 w-3" />}
|
||
wegräumen
|
||
</button>
|
||
)}
|
||
</div>
|
||
{zeigFertige && <ul className="mt-1.5 space-y-1.5">{fertig.map((it) => zeile(it))}</ul>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 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<IdeenKonzept | null>(null)
|
||
const [fehler, setFehler] = useState(false)
|
||
const [versuch, setVersuch] = useState(0)
|
||
useEffect(() => {
|
||
let weg = false
|
||
setData(null); setFehler(false)
|
||
api<IdeenKonzept>(`/api/ideen/${id}/konzept`)
|
||
.then((r) => { if (!weg) setData(r) })
|
||
.catch(() => { if (!weg) setFehler(true) })
|
||
return () => { weg = true }
|
||
}, [id, versuch])
|
||
return (
|
||
<div className="ml-2 -mt-1">
|
||
<div className="rounded-lg border border-violet-500/25 bg-violet-500/[0.04] p-3">
|
||
<div className="mb-2 flex items-center gap-2">
|
||
<p className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wide text-violet-300">
|
||
<FileText className="h-3 w-3" /> Konzept
|
||
</p>
|
||
{data?.repo && (
|
||
<a href={`https://git.tobisniceshomelab.ddnsfree.com/${data.repo}`}
|
||
className="flex items-center gap-1 text-[10px] text-violet-300/70 hover:text-violet-200">
|
||
<GitFork className="h-3 w-3" /> {data.repo}
|
||
</a>
|
||
)}
|
||
{!data?.repo && data?.quelle === "datei" && (
|
||
<span className="truncate font-mono text-[10px] text-muted-foreground/70"
|
||
title="Der Worker hat kein Repo angelegt — das Konzept liegt als Datei auf der Box.">
|
||
{data.pfad}
|
||
</span>
|
||
)}
|
||
<button onClick={() => setVersuch((v) => v + 1)} title="Neu laden"
|
||
className="ml-auto rounded p-0.5 text-muted-foreground/50 hover:text-foreground cursor-pointer">
|
||
<RefreshCw className="h-3 w-3" />
|
||
</button>
|
||
</div>
|
||
{fehler ? (
|
||
<p className="text-[11px] text-red-400">Konzept nicht ladbar — später nochmal versuchen.</p>
|
||
) : data === null ? (
|
||
<p className="text-[10px] text-muted-foreground animate-pulse">Konzept wird geladen …</p>
|
||
) : data.konzept ? (
|
||
<div className="max-h-[28rem] overflow-y-auto pr-1">
|
||
<Markdown text={data.konzept} />
|
||
</div>
|
||
) : (
|
||
<p className="text-[11px] text-muted-foreground italic">
|
||
{data.error || "Noch kein Konzept — das Projekt wird gerade vorbereitet."}
|
||
</p>
|
||
)}
|
||
{istIdee && data?.konzept && <WeiterLeiste id={id} onFertig={onWeiter} />}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 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 (
|
||
<p className="mt-3 border-t border-violet-500/20 pt-2 text-[11px] text-emerald-300">
|
||
{neu.art === "projekt" ? (
|
||
<>Projekt-Karte <span className="font-mono">{neu.id}</span> ist angelegt — die Box bereitet
|
||
das Repo vor (Konzept übernimmt sie, sie denkt es nicht neu). Danach öffnest du es in Zed.</>
|
||
) : (
|
||
<>Überarbeitung <span className="font-mono">{neu.id}</span> läuft — die Box arbeitet deine
|
||
Rückmeldung ein und schreibt die neue Fassung an dieselbe Stelle. Danach hier wieder
|
||
„Konzept" öffnen.</>
|
||
)}
|
||
</p>
|
||
)
|
||
}
|
||
return (
|
||
<div className="mt-3 space-y-3 border-t border-violet-500/20 pt-2.5">
|
||
<div className="space-y-1.5">
|
||
<p className="text-[11px] text-muted-foreground">
|
||
<b className="text-sky-300/90">Passt etwas nicht?</b> Schreib hin, was anders werden soll —
|
||
die Box arbeitet es ein und lässt den Rest stehen. Beliebig oft wiederholbar.
|
||
</p>
|
||
<div className="flex gap-2">
|
||
<input
|
||
value={hinweis}
|
||
onChange={(e) => 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"
|
||
/>
|
||
<button
|
||
onClick={() => ruf("/api/ideen/nachschaerfen", { id, hinweis }, "runde")}
|
||
disabled={busy || hinweis.trim().length < 3}
|
||
className="flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-sky-500/40 bg-sky-500/10 px-3 text-[10px] font-bold uppercase tracking-wide text-sky-300 transition-all hover:bg-sky-500/20 cursor-pointer disabled:opacity-50">
|
||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||
Nachschärfen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<p className="text-[11px] text-muted-foreground">
|
||
<b className="text-violet-300/90">Passt es so?</b> 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.
|
||
</p>
|
||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
|
||
<span className="text-[11px] text-muted-foreground/70">Ziel</span>
|
||
<div className="flex rounded-lg border border-border/40 p-0.5">
|
||
{(["lxc", "docker"] as const).map((z) => (
|
||
<button key={z} onClick={() => setZiel(z)}
|
||
title={z === "lxc" ? "Dienst in einem eigenen Proxmox-LXC (systemd, kein Docker) — wird automatisch mitgesichert."
|
||
: "Natives Docker: Dockerfile + compose, portabel und pipeline-tauglich."}
|
||
className={cn("rounded px-2 py-0.5 text-[10px] font-semibold transition-all cursor-pointer",
|
||
ziel === z ? "bg-foreground/10 text-foreground" : "text-muted-foreground/50 hover:text-foreground/80")}>
|
||
{z === "lxc" ? "LXC" : "Docker"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<span className="text-[11px] text-muted-foreground/70">Aufwand</span>
|
||
<div className="flex rounded-lg border border-border/40 p-0.5">
|
||
{(["simpel", "gruendlich"] as const).map((t) => (
|
||
<button key={t} onClick={() => setTiefe(t)}
|
||
title={t === "simpel" ? "Konzept ist schon gehärtet — nur kurz auf die Bau-Sicht nachschärfen (empfohlen)."
|
||
: "Nochmal volle Kaskade über die Bau-Sicht: mehrere Rollen, Härtetests. Dauert entsprechend."}
|
||
className={cn("rounded px-2 py-0.5 text-[10px] font-semibold transition-all cursor-pointer",
|
||
tiefe === t ? "bg-foreground/10 text-foreground" : "text-muted-foreground/50 hover:text-foreground/80")}>
|
||
{t === "simpel" ? "schlank" : "gründlich"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<button
|
||
onClick={() => ruf("/api/ideen/weiterfuehren", { id, ziel, tiefe }, "projekt")}
|
||
disabled={busy}
|
||
className="ml-auto flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-violet-500/40 bg-violet-500/10 px-3 text-[10px] font-bold uppercase tracking-wide text-violet-300 transition-all hover:bg-violet-500/20 cursor-pointer disabled:opacity-50">
|
||
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Code2 className="h-3.5 w-3.5" />}
|
||
Gefällt mir — Projekt daraus machen
|
||
</button>
|
||
</div>
|
||
{fehler && <p className="text-[11px] text-red-400">{fehler}</p>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 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<IdeenErgebnis | null>(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<IdeenErgebnis>(`/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 (
|
||
<div className="ml-2 -mt-1">
|
||
<div className="rounded-lg border border-emerald-500/25 bg-emerald-500/5 p-2.5">
|
||
<p className="mb-1 flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wide text-emerald-300">
|
||
<ClipboardCheck className="h-3 w-3" /> Ergebnis des Workers
|
||
</p>
|
||
{fehler ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => setVersuch((v) => v + 1)}
|
||
className="text-[10px] text-red-400 underline decoration-dotted underline-offset-2 hover:text-red-300"
|
||
>
|
||
Ergebnis nicht ladbar — nochmal versuchen
|
||
</button>
|
||
) : erg === null ? (
|
||
<p className="text-[10px] text-muted-foreground animate-pulse">Wird geladen …</p>
|
||
) : text ? (
|
||
<p className="max-h-64 overflow-y-auto whitespace-pre-wrap text-xs leading-relaxed text-foreground/85">{text}</p>
|
||
) : (
|
||
<p className="text-[10px] text-muted-foreground italic">
|
||
Der Worker hat keine Abschluss-Zusammenfassung hinterlassen (ältere Karte oder Protokollverstoß).
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// 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 (
|
||
<div className="space-y-6">
|
||
<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">
|
||
Ideen
|
||
</h1>
|
||
<p className="text-sm text-muted-foreground">
|
||
Schreib hin, was entstehen soll. Die Box liest mit und schlägt vor, wie es weitergeht —
|
||
ändern kannst du das immer.
|
||
</p>
|
||
</div>
|
||
<IdeenSection />
|
||
</div>
|
||
)
|
||
}
|