Die Queue war 703 der 1147 Zeilen in AuftragsbuchView — die MEHRHEIT einer Seite,
die nach etwas anderem benannt ist. Und sie laufen gegenlaeufig: Auftragsbuch =
die Box schlaegt DIR vor (Eingang), Ideen = DU beauftragst die Box (Ausgang).
Seit dem Umbau ist Idee -> Konzept -> Repo -> Zed der Haupteingang des Systems
und gehoert in die Seitenleiste, nicht in einen Unterabschnitt.
Umzug (reines Verschieben, kein Logik-Umbau):
AuftragsbuchView 1147 -> 406 Zeilen Bundle 51,0 -> 17,8 KB
IdeenView neu, 785 Zeilen Bundle 32,7 KB, laedt nur bei Bedarf
fmtWhen -> lib/format.ts (dessen Kopf genau davor warnt: "vorher in
einzelnen Views dupliziert")
SectionLabel -> components/ui/ (beide Seiten brauchen sie jetzt)
Zwei inhaltliche Aenderungen:
1. Die flache Einzelkarten-Liste wird gruppiert — mit der Ordnung, die
ProjektGruppe fuer Ketten LAENGST benutzt (AKTIV_RANG): aktiv zuerst,
Wartende und Fertige eingeklappt. Bei 19 Karten, davon 16 fertig, gingen die
drei relevanten in der Erstellzeit-Liste unter.
2. Die Kopfzeile sagt jetzt "1 braucht dich · 2 in Arbeit · 16 fertig" statt
einer nackten Gesamtzahl.
Das Auftragsbuch zaehlte im gruenen "Nichts zu entscheiden" bisher haengende
Ideen mit, damit es nicht luegt, waehrend direkt darunter eine Karte haengt.
Nach der Trennung wird daraus ein anklickbarer Querverweis auf die Ideen-Seite —
dieselbe Ehrlichkeit, jetzt mit Weg dorthin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ const AgentView = lazy(() => import("@/views/AgentView").then((m) => ({ default:
|
||||
const KonsoleView = lazy(() => import("@/views/KonsoleView").then((m) => ({ default: m.KonsoleView })))
|
||||
const GuideView = lazy(() => import("@/views/GuideView").then((m) => ({ default: m.GuideView })))
|
||||
const AuftragsbuchView = lazy(() => import("@/views/AuftragsbuchView").then((m) => ({ default: m.AuftragsbuchView })))
|
||||
const IdeenView = lazy(() => import("@/views/IdeenView").then((m) => ({ default: m.IdeenView })))
|
||||
const ChronikView = lazy(() => import("@/views/ChronikView").then((m) => ({ default: m.ChronikView })))
|
||||
const EigenlebenView = lazy(() => import("@/views/EigenlebenView").then((m) => ({ default: m.EigenlebenView })))
|
||||
const WissenView = lazy(() => import("@/views/WissenView").then((m) => ({ default: m.WissenView })))
|
||||
@@ -201,6 +202,7 @@ export default function App() {
|
||||
<main className="flex-1 overflow-y-auto p-4 md:p-6 scrollbar-thin">
|
||||
<Suspense fallback={<ViewLoading />}>
|
||||
{view === "dashboard" && <CockpitView onNavigate={(v) => navigate(v as ViewId)} />}
|
||||
{view === "ideen" && <IdeenView />}
|
||||
{view === "auftraege" && <AuftragsbuchView />}
|
||||
{view === "models" && <ModelsView />}
|
||||
{view === "connect" && <ConnectView />}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
|
||||
// Kleine Abschnitts-Überschrift innerhalb einer Seite. Lag vorher in AuftragsbuchView;
|
||||
// seit der Ideen-Queue eine eigene Seite hat (25.07.2026) brauchen beide sie — also
|
||||
// einmal hier statt zweimal dupliziert.
|
||||
export function SectionLabel({ icon: Icon, children }: { icon: LucideIcon; 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>
|
||||
)
|
||||
}
|
||||
@@ -30,3 +30,9 @@ export function fmtEta(s?: number): string {
|
||||
export function fmtCtx(c: number | null): string {
|
||||
return c ? `${Math.round(c / 1024)}k` : "—"
|
||||
}
|
||||
|
||||
/** Unix-Sekunden → "25.07., 21:04"; "—" bei 0/null/undefined. */
|
||||
export 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" })
|
||||
}
|
||||
|
||||
+5
-1
@@ -10,10 +10,11 @@ import {
|
||||
Sparkles,
|
||||
SquareTerminal,
|
||||
HelpCircle,
|
||||
Lightbulb,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
export type ViewId = "dashboard" | "auftraege" | "eigenleben" | "models" | "memory" | "wissen" | "chronik" | "connect" | "agent" | "konsole" | "guide"
|
||||
export type ViewId = "dashboard" | "ideen" | "auftraege" | "eigenleben" | "models" | "memory" | "wissen" | "chronik" | "connect" | "agent" | "konsole" | "guide"
|
||||
|
||||
export type NavGroup = "Operativ" | "Wissen" | "Werkzeuge" | "Wartung"
|
||||
|
||||
@@ -28,6 +29,9 @@ export interface NavItem {
|
||||
// Eine Quelle der Wahrheit für Sidebar UND Command-Palette.
|
||||
export const NAV: NavItem[] = [
|
||||
{ id: "dashboard", label: "Cockpit", hint: "Deine Box auf einen Blick", icon: LayoutDashboard, group: "Operativ" },
|
||||
// Der Haupteingang: Idee → Konzept → Repo → Zed. Steht bewusst direkt unter dem
|
||||
// Cockpit — hier fängt jede Arbeit an (eigene Seite seit 25.07.2026).
|
||||
{ id: "ideen", label: "Ideen", hint: "Idee eingeben — die Box legt Konzept und Repo an", icon: Lightbulb, group: "Operativ" },
|
||||
{ id: "auftraege", label: "Auftragsbuch", hint: "Vorschläge der Box — annehmen oder ablehnen", icon: Inbox, group: "Operativ" },
|
||||
{ id: "eigenleben", label: "Skills", hint: "Fähigkeiten der Box — aktiv/inaktiv, Herkunft & Vorschlags-Bilanz", icon: Sparkles, group: "Operativ" },
|
||||
{ id: "chronik", label: "Chronik", hint: "Was die Box von allein getan hat + Zeitmaschine", icon: History, group: "Operativ" },
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { 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,
|
||||
ChevronDown, ChevronRight, Clock, Hammer, RefreshCw, Lightbulb, ArrowRight,
|
||||
} 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 { api, type AuftragItem, type SkillKandidat } from "@/lib/api"
|
||||
import { useAuftragsbuch, useIdeen, 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"
|
||||
|
||||
// Auftragsbuch = die Vorschlags-Inbox der Box. Werkstatt/Orchestrator liefern propose-only-
|
||||
// Branches, der Traum liefert Skill-Kandidaten — hier fällt die Entscheidung per Klick.
|
||||
@@ -31,10 +31,6 @@ const STATE_UI: Record<string, { label: string; cls: string; spin?: boolean }> =
|
||||
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()
|
||||
@@ -62,8 +58,11 @@ export function AuftragsbuchView() {
|
||||
|
||||
const items = data?.items ?? []
|
||||
const kandidaten = data?.skill_kandidaten ?? []
|
||||
// Hängende Ideen leben seit 25.07. auf der eigenen Ideen-Seite. Das grüne „Nichts zu
|
||||
// entscheiden" darf trotzdem nicht behaupten, alles sei erledigt — es zeigt jetzt einen
|
||||
// Querverweis statt die Karte selbst (vorher stand sie direkt darunter).
|
||||
const blockedIdeen = ideenData?.available ? ideenData.items.filter((i) => i.status === "blocked").length : 0
|
||||
const empty = items.length === 0 && kandidaten.length === 0 && blockedIdeen === 0
|
||||
const empty = items.length === 0 && kandidaten.length === 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -104,7 +103,20 @@ export function AuftragsbuchView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<IdeenSection />
|
||||
{blockedIdeen > 0 && (
|
||||
<button
|
||||
onClick={() => window.dispatchEvent(new CustomEvent("mc-navigate", { detail: { view: "ideen" } }))}
|
||||
className="flex w-full items-center gap-3 rounded-2xl border border-red-500/30 bg-red-500/5 p-4 text-left transition-all hover:border-red-500/50 cursor-pointer">
|
||||
<Lightbulb className="h-5 w-5 shrink-0 text-red-300" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
{blockedIdeen === 1 ? "Eine Idee hängt und braucht dich" : `${blockedIdeen} Ideen hängen und brauchen dich`}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Hier ist nichts zu tun — aber drüben in den Ideen wartet eine Frage auf deine Antwort.</p>
|
||||
</div>
|
||||
<ArrowRight className="h-4 w-4 shrink-0 text-red-300/70" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
@@ -129,759 +141,6 @@ export function AuftragsbuchView() {
|
||||
)
|
||||
}
|
||||
|
||||
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: "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:",
|
||||
}
|
||||
|
||||
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<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 })
|
||||
|
||||
// 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 (
|
||||
<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">
|
||||
<SectionLabel icon={Lightbulb}>
|
||||
Ideen-Queue{data ? ` — ${data.offen} offen` : ""}
|
||||
</SectionLabel>
|
||||
<div className="mc-card p-4 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={cn("flex h-9 shrink-0 items-center gap-1.5 rounded-lg border px-3 text-[11px] font-bold uppercase tracking-wide transition-all cursor-pointer disabled:opacity-50",
|
||||
modus === "gruendlich" ? "border-amber-500/40 bg-amber-500/10 text-amber-300 hover:bg-amber-500/20"
|
||||
: modus === "simpel" ? "border-violet-500/40 bg-violet-500/10 text-violet-300 hover:bg-violet-500/20"
|
||||
: isIdee ? "border-sky-500/40 bg-sky-500/10 text-sky-300 hover:bg-sky-500/20"
|
||||
: "border-primary/40 bg-primary/10 text-primary hover:bg-primary/20")}>
|
||||
{sending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : isIdee ? <Lightbulb className="h-3.5 w-3.5" /> : isProjekt ? <Code2 className="h-3.5 w-3.5" /> : <Send className="h-3.5 w-3.5" />}
|
||||
{isIdee ? "Durchdenken" : isProjekt ? "Vorbereiten" : "In die Queue"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] text-muted-foreground/70">Modus</span>
|
||||
<div className="flex rounded-lg border border-border/60 p-0.5">
|
||||
<button
|
||||
onClick={() => setModus("box")}
|
||||
className={cn("flex items-center gap-1 rounded px-2 py-1 text-[10px] font-bold uppercase tracking-wide transition-all cursor-pointer",
|
||||
modus === "box" ? "bg-primary/15 text-primary" : "text-muted-foreground/60 hover:text-foreground")}>
|
||||
<Hammer className="h-3 w-3" /> Box
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setModus("idee")}
|
||||
title="Produkt-Idee erst einmal durchdenken lassen: Was ist es, für wen, was gibt es schon, was spricht dagegen? Ergebnis ist ein Konzept — kein Projekt, kein Bau-Druck."
|
||||
className={cn("flex items-center gap-1 rounded px-2 py-1 text-[10px] font-bold uppercase tracking-wide transition-all cursor-pointer",
|
||||
modus === "idee" ? "bg-sky-500/15 text-sky-300" : "text-muted-foreground/60 hover:text-foreground")}>
|
||||
<Lightbulb className="h-3 w-3" /> Idee
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setModus("simpel")}
|
||||
title="IDE-Projekt, schnell vorbereitet: knappes Konzept, wenige Denk-Runden. Für kleine, klar umrissene Sachen."
|
||||
className={cn("flex items-center gap-1 rounded px-2 py-1 text-[10px] font-bold uppercase tracking-wide transition-all cursor-pointer",
|
||||
modus === "simpel" ? "bg-violet-500/15 text-violet-300" : "text-muted-foreground/60 hover:text-foreground")}>
|
||||
<Code2 className="h-3 w-3" /> IDE: simpel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setModus("gruendlich")}
|
||||
title="IDE-Projekt, gründlich durchdacht: volle Kaskade mit mehreren Rollen und Härtetests. Für große/ambitionierte Projekte."
|
||||
className={cn("flex items-center gap-1 rounded px-2 py-1 text-[10px] font-bold uppercase tracking-wide transition-all cursor-pointer",
|
||||
modus === "gruendlich" ? "bg-amber-500/15 text-amber-300" : "text-muted-foreground/60 hover:text-foreground")}>
|
||||
<Code2 className="h-3 w-3" /> IDE: gründlich
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 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.). */}
|
||||
<p className="text-[10px] leading-relaxed text-muted-foreground/60">
|
||||
<b className="text-primary/80">Box</b>, wenn die Box es selbst bauen soll (dein Heimlabor,
|
||||
MC2, Lucy — Ergebnis kommt als Patch-Vorschlag zurück). ·{" "}
|
||||
<b className="text-sky-300/80">Idee</b>, wenn du noch nicht weißt, ob es das überhaupt wert
|
||||
ist — erst durchdenken, entscheiden kannst du danach. ·{" "}
|
||||
<b className="text-violet-300/80">IDE</b>, wenn du es selbst in Zed bauen willst und dafür
|
||||
ein startklares Repo brauchst.
|
||||
</p>
|
||||
{isProjekt && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] text-muted-foreground/70">Ziel</span>
|
||||
<div className="flex rounded-lg border border-border/40 p-0.5">
|
||||
<button
|
||||
onClick={() => setZiel("lxc")}
|
||||
title="Läuft als Dienst in einem eigenen Proxmox-LXC (systemd, kein Docker). Wird vom Backup-Server automatisch mitgesichert."
|
||||
className={cn("rounded px-2 py-0.5 text-[10px] font-semibold transition-all cursor-pointer",
|
||||
ziel === "lxc" ? "bg-foreground/10 text-foreground" : "text-muted-foreground/50 hover:text-foreground/80")}>
|
||||
LXC
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setZiel("docker")}
|
||||
title="Natives Docker nach Standard-Praxis: Dockerfile + compose, portabel, über Gitea-/GitLab-Pipelines baubar. Für Projekte, die bewusst außerhalb der LXC-Welt leben."
|
||||
className={cn("rounded px-2 py-0.5 text-[10px] font-semibold transition-all cursor-pointer",
|
||||
ziel === "docker" ? "bg-foreground/10 text-foreground" : "text-muted-foreground/50 hover:text-foreground/80")}>
|
||||
Docker
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground/50">
|
||||
{ziel === "lxc" ? "Dienst im Proxmox-Container (deine Heimat)" : "natives Docker, portabel & pipeline-tauglich"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{isIdee ? (
|
||||
<>
|
||||
<b className="text-sky-300/90">Idee durchdenken:</b> 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 <b>nur ein Konzept</b> (kein Gerüst, keine Roadmap, kein Bau-Druck) — du liest es hier
|
||||
und entscheidest danach, ob ein Projekt draus wird.
|
||||
</>
|
||||
) : isProjekt ? (
|
||||
<>
|
||||
<b className={modus === "gruendlich" ? "text-amber-300/90" : "text-violet-300/90"}>IDE-Welt{modus === "gruendlich" ? " · gründlich" : " · simpel"}:</b>{" "}
|
||||
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) — <b>kein Code</b>. 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).
|
||||
</>
|
||||
)}
|
||||
</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 && (
|
||||
<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} />
|
||||
))}
|
||||
{(() => {
|
||||
// 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 ? <ul className="space-y-1.5">{einzeln.map((it) => zeile(it))}</ul> : null
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 }: {
|
||||
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 (
|
||||
<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>
|
||||
<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>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
function StateBadge({ item }: { item: AuftragItem }) {
|
||||
const st = item.status
|
||||
|
||||
@@ -0,0 +1,828 @@
|
||||
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:",
|
||||
}
|
||||
|
||||
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<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 })
|
||||
|
||||
// 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 (
|
||||
<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. */}
|
||||
<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)
|
||||
return `Ideen-Queue${teile.length ? " — " + teile.join(" · ") : ""}`
|
||||
})()}
|
||||
</SectionLabel>
|
||||
<div className="mc-card p-4 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={cn("flex h-9 shrink-0 items-center gap-1.5 rounded-lg border px-3 text-[11px] font-bold uppercase tracking-wide transition-all cursor-pointer disabled:opacity-50",
|
||||
modus === "gruendlich" ? "border-amber-500/40 bg-amber-500/10 text-amber-300 hover:bg-amber-500/20"
|
||||
: modus === "simpel" ? "border-violet-500/40 bg-violet-500/10 text-violet-300 hover:bg-violet-500/20"
|
||||
: isIdee ? "border-sky-500/40 bg-sky-500/10 text-sky-300 hover:bg-sky-500/20"
|
||||
: "border-primary/40 bg-primary/10 text-primary hover:bg-primary/20")}>
|
||||
{sending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : isIdee ? <Lightbulb className="h-3.5 w-3.5" /> : isProjekt ? <Code2 className="h-3.5 w-3.5" /> : <Send className="h-3.5 w-3.5" />}
|
||||
{isIdee ? "Durchdenken" : isProjekt ? "Vorbereiten" : "In die Queue"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] text-muted-foreground/70">Modus</span>
|
||||
<div className="flex rounded-lg border border-border/60 p-0.5">
|
||||
<button
|
||||
onClick={() => setModus("box")}
|
||||
className={cn("flex items-center gap-1 rounded px-2 py-1 text-[10px] font-bold uppercase tracking-wide transition-all cursor-pointer",
|
||||
modus === "box" ? "bg-primary/15 text-primary" : "text-muted-foreground/60 hover:text-foreground")}>
|
||||
<Hammer className="h-3 w-3" /> Box
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setModus("idee")}
|
||||
title="Produkt-Idee erst einmal durchdenken lassen: Was ist es, für wen, was gibt es schon, was spricht dagegen? Ergebnis ist ein Konzept — kein Projekt, kein Bau-Druck."
|
||||
className={cn("flex items-center gap-1 rounded px-2 py-1 text-[10px] font-bold uppercase tracking-wide transition-all cursor-pointer",
|
||||
modus === "idee" ? "bg-sky-500/15 text-sky-300" : "text-muted-foreground/60 hover:text-foreground")}>
|
||||
<Lightbulb className="h-3 w-3" /> Idee
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setModus("simpel")}
|
||||
title="IDE-Projekt, schnell vorbereitet: knappes Konzept, wenige Denk-Runden. Für kleine, klar umrissene Sachen."
|
||||
className={cn("flex items-center gap-1 rounded px-2 py-1 text-[10px] font-bold uppercase tracking-wide transition-all cursor-pointer",
|
||||
modus === "simpel" ? "bg-violet-500/15 text-violet-300" : "text-muted-foreground/60 hover:text-foreground")}>
|
||||
<Code2 className="h-3 w-3" /> IDE: simpel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setModus("gruendlich")}
|
||||
title="IDE-Projekt, gründlich durchdacht: volle Kaskade mit mehreren Rollen und Härtetests. Für große/ambitionierte Projekte."
|
||||
className={cn("flex items-center gap-1 rounded px-2 py-1 text-[10px] font-bold uppercase tracking-wide transition-all cursor-pointer",
|
||||
modus === "gruendlich" ? "bg-amber-500/15 text-amber-300" : "text-muted-foreground/60 hover:text-foreground")}>
|
||||
<Code2 className="h-3 w-3" /> IDE: gründlich
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 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.). */}
|
||||
<p className="text-[10px] leading-relaxed text-muted-foreground/60">
|
||||
<b className="text-primary/80">Box</b>, wenn die Box es selbst bauen soll (dein Heimlabor,
|
||||
MC2, Lucy — Ergebnis kommt als Patch-Vorschlag zurück). ·{" "}
|
||||
<b className="text-sky-300/80">Idee</b>, wenn du noch nicht weißt, ob es das überhaupt wert
|
||||
ist — erst durchdenken, entscheiden kannst du danach. ·{" "}
|
||||
<b className="text-violet-300/80">IDE</b>, wenn du es selbst in Zed bauen willst und dafür
|
||||
ein startklares Repo brauchst.
|
||||
</p>
|
||||
{isProjekt && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] text-muted-foreground/70">Ziel</span>
|
||||
<div className="flex rounded-lg border border-border/40 p-0.5">
|
||||
<button
|
||||
onClick={() => setZiel("lxc")}
|
||||
title="Läuft als Dienst in einem eigenen Proxmox-LXC (systemd, kein Docker). Wird vom Backup-Server automatisch mitgesichert."
|
||||
className={cn("rounded px-2 py-0.5 text-[10px] font-semibold transition-all cursor-pointer",
|
||||
ziel === "lxc" ? "bg-foreground/10 text-foreground" : "text-muted-foreground/50 hover:text-foreground/80")}>
|
||||
LXC
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setZiel("docker")}
|
||||
title="Natives Docker nach Standard-Praxis: Dockerfile + compose, portabel, über Gitea-/GitLab-Pipelines baubar. Für Projekte, die bewusst außerhalb der LXC-Welt leben."
|
||||
className={cn("rounded px-2 py-0.5 text-[10px] font-semibold transition-all cursor-pointer",
|
||||
ziel === "docker" ? "bg-foreground/10 text-foreground" : "text-muted-foreground/50 hover:text-foreground/80")}>
|
||||
Docker
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground/50">
|
||||
{ziel === "lxc" ? "Dienst im Proxmox-Container (deine Heimat)" : "natives Docker, portabel & pipeline-tauglich"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{isIdee ? (
|
||||
<>
|
||||
<b className="text-sky-300/90">Idee durchdenken:</b> 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 <b>nur ein Konzept</b> (kein Gerüst, keine Roadmap, kein Bau-Druck) — du liest es hier
|
||||
und entscheidest danach, ob ein Projekt draus wird.
|
||||
</>
|
||||
) : isProjekt ? (
|
||||
<>
|
||||
<b className={modus === "gruendlich" ? "text-amber-300/90" : "text-violet-300/90"}>IDE-Welt{modus === "gruendlich" ? " · gründlich" : " · simpel"}:</b>{" "}
|
||||
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) — <b>kein Code</b>. 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).
|
||||
</>
|
||||
)}
|
||||
</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 && (
|
||||
<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} />
|
||||
))}
|
||||
{(() => {
|
||||
// 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 && (
|
||||
<p className="text-xs text-muted-foreground/70">Queue ist leer — wirf der Box eine Idee zu.</p>
|
||||
)}
|
||||
</div>
|
||||
{dialogElement}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 }: {
|
||||
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 (
|
||||
<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>
|
||||
<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>
|
||||
{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 arbeitet es aus. Je nach Modus baut sie es selbst,
|
||||
denkt es nur durch, oder legt dir ein startklares Repo fuer Zed an.
|
||||
</p>
|
||||
</div>
|
||||
<IdeenSection />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user