Files
projectbrain-Public/src/features/autonomy/pages/AutoMode.tsx
T

124 lines
22 KiB
TypeScript

"use client";
import React from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowRight, Bot, BrainCircuit, CheckCircle2, ListTodo, RefreshCw, RotateCcw, ShieldAlert, Upload } from "lucide-react";
import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label, Progress, Switch, Textarea } from "@/components/ui";
import { autonomyApi, type AutoModePhase, type AutoModeState } from "@/shared/api/autonomyApi";
import { ApiError } from "@/shared/api/httpClient";
import { autoModeItemLabel, autoModePhaseLabel, autoModeStatusLabel, getAutoModeOperationalState } from "@/shared/lib/autoModePresentation";
import { autonomyProfileDefinitions, autonomyProfileOrder } from "@/shared/lib/autonomyProfiles";
import { showSuccess } from "@/utils/toast";
const phaseVariant = (phase: AutoModePhase) => phase.status === "completed" ? "default" : phase.status === "blocked" ? "destructive" : phase.status === "pending" ? "secondary" : "outline";
const readableError = (error: unknown) => error instanceof ApiError ? error.message : error instanceof Error ? error.message : "Onbekende fout.";
const AutoMode = () => {
const { projectId = "" } = useParams();
const navigate = useNavigate();
const [state, setState] = React.useState<AutoModeState>();
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string>();
const [roadmapTitle, setRoadmapTitle] = React.useState("");
const [roadmapMarkdown, setRoadmapMarkdown] = React.useState("");
const [confirmRoadmapReplacement, setConfirmRoadmapReplacement] = React.useState(false);
const loadGeneration = React.useRef(0);
const [policy, setPolicy] = React.useState({ profile: "normal" as AutoModeState["profile"], autoBrainstorm: true, maxBrainstormIterations: 5, maxConsecutiveFailures: 3, leaseMinutes: 30, maxWorkItemsPerRun: 8, maxRunMinutes: 45, maxRecoveryMinutes: 120 });
const load = React.useCallback(async (preservePolicy = false) => {
if (!projectId) return;
const generation = ++loadGeneration.current;
setLoading(true); setError(undefined);
try {
const next = await autonomyApi.get(projectId);
if (generation !== loadGeneration.current) return;
setState(next);
if (!preservePolicy) setPolicy({ profile: next.profile ?? "normal", autoBrainstorm: next.autoBrainstorm, maxBrainstormIterations: next.maxBrainstormIterations, maxConsecutiveFailures: next.maxConsecutiveFailures, leaseMinutes: next.leaseMinutes, maxWorkItemsPerRun: next.maxWorkItemsPerRun, maxRunMinutes: next.maxRunMinutes, maxRecoveryMinutes: next.maxRecoveryMinutes });
if (next.roadmap?.sourceMarkdown) { setRoadmapTitle((current) => current || next.roadmap?.title || ""); setRoadmapMarkdown((current) => current || next.roadmap?.sourceMarkdown || ""); }
} catch (nextError) { if (generation === loadGeneration.current) setError(readableError(nextError)); }
finally { if (generation === loadGeneration.current) setLoading(false); }
}, [projectId]);
React.useEffect(() => { void load(); return () => { loadGeneration.current += 1; }; }, [load]);
const configure = async (patch: Parameters<typeof autonomyApi.configure>[1]) => {
if (!projectId) return false;
loadGeneration.current += 1; setLoading(false); setSaving(true); setError(undefined);
try {
const next = await autonomyApi.configure(projectId, patch); setState(next);
if (patch.enabled === undefined) setPolicy({ profile: next.profile ?? "normal", autoBrainstorm: next.autoBrainstorm, maxBrainstormIterations: next.maxBrainstormIterations, maxConsecutiveFailures: next.maxConsecutiveFailures, leaseMinutes: next.leaseMinutes, maxWorkItemsPerRun: next.maxWorkItemsPerRun, maxRunMinutes: next.maxRunMinutes, maxRecoveryMinutes: next.maxRecoveryMinutes });
return true;
} catch (nextError) { setError(readableError(nextError)); return false; }
finally { setSaving(false); }
};
const importRoadmap = async () => {
if (!projectId || roadmapMarkdown.trim().length < 3) return;
if (state?.roadmap && state.roadmap.sourceMarkdown.trim() === roadmapMarkdown.trim() && state.roadmap.title === roadmapTitle.trim()) { showSuccess("Deze roadmap is al opgeslagen. De voortgang blijft behouden."); return; }
const replacesExistingRoadmap = Boolean(state?.roadmap && state.roadmap.sourceMarkdown.trim() !== roadmapMarkdown.trim());
if (replacesExistingRoadmap && !confirmRoadmapReplacement) { setConfirmRoadmapReplacement(true); return; }
loadGeneration.current += 1; setLoading(false); setSaving(true); setError(undefined);
try { setState(await autonomyApi.importRoadmap(projectId, { ...(roadmapTitle.trim() ? { title: roadmapTitle.trim() } : {}), markdown: roadmapMarkdown })); setConfirmRoadmapReplacement(false); showSuccess("Roadmap opgeslagen"); }
catch (nextError) { setError(readableError(nextError)); }
finally { setSaving(false); }
};
const resume = async () => {
if (!projectId) return;
loadGeneration.current += 1; setLoading(false); setSaving(true); setError(undefined);
try { setState(await autonomyApi.resume(projectId)); showSuccess("Auto Mode kan opnieuw werk claimen"); }
catch (nextError) { setError(readableError(nextError)); }
finally { setSaving(false); }
};
const savePolicy = async () => { if (await configure(policy)) showSuccess("Autonomiebeleid opgeslagen"); };
const phases = state?.roadmap?.phases ?? [];
const items = phases.flatMap((phase) => phase.items);
const completedItems = items.filter((item) => item.status === "completed").length;
const openItems = items.filter((item) => item.status !== "completed");
const progress = items.length ? Math.round((completedItems / items.length) * 100) : state?.status === "completed" ? 100 : 0;
const activePhase = state?.roadmap ? phases[state.roadmap.currentPhaseIndex] : undefined;
const inProgressItem = activePhase?.items.find((item) => item.status === "in_progress");
const activeItem = inProgressItem ?? activePhase?.items.find((item) => item.status !== "completed");
const nextItem = phases.flatMap((phase) => phase.items).find((item) => item.status === "pending");
const operational = state ? getAutoModeOperationalState(state) : undefined;
const policyDirty = Boolean(state && (state.profile !== policy.profile || state.autoBrainstorm !== policy.autoBrainstorm || state.maxBrainstormIterations !== policy.maxBrainstormIterations || state.maxConsecutiveFailures !== policy.maxConsecutiveFailures || state.leaseMinutes !== policy.leaseMinutes || state.maxWorkItemsPerRun !== policy.maxWorkItemsPerRun || state.maxRunMinutes !== policy.maxRunMinutes || state.maxRecoveryMinutes !== policy.maxRecoveryMinutes));
const updatePolicyNumber = (key: "maxBrainstormIterations" | "maxConsecutiveFailures" | "leaseMinutes" | "maxWorkItemsPerRun" | "maxRunMinutes" | "maxRecoveryMinutes", value: string, minimum: number, maximum: number) => { const parsed = Number(value); if (Number.isFinite(parsed)) setPolicy((current) => ({ ...current, profile: "custom", [key]: Math.max(minimum, Math.min(maximum, Math.trunc(parsed))) })); };
const selectProfile = (profile: AutoModeState["profile"]) => { const settings = autonomyProfileDefinitions[profile].settings; setPolicy((current) => settings ? ({ ...current, profile, ...settings }) : ({ ...current, profile: "custom" })); };
React.useEffect(() => {
if (!projectId) return;
const interval = window.setInterval(() => { if (document.visibilityState === "visible" && !saving && !policyDirty) void load(true); }, 10_000);
return () => window.clearInterval(interval);
}, [load, policyDirty, projectId, saving]);
if (loading && !state) return <div className="rounded-xl border bg-card p-6 text-sm text-muted-foreground">Auto Mode laden…</div>;
const controllerClass = operational?.tone === "blocked" ? "border-destructive/40" : operational?.tone === "retry" ? "border-amber-300" : operational?.tone === "active" ? "border-primary/50" : operational?.tone === "complete" ? "border-emerald-300" : "border-dashed";
return <div className="w-full min-w-0 max-w-full space-y-6 [overflow-wrap:anywhere] [&_button]:max-w-full max-sm:[&_button]:whitespace-normal">
<div className="flex min-w-0 flex-col gap-4 lg:flex-row lg:items-start lg:justify-between"><div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><h1 className="text-3xl font-bold tracking-tight">Auto Mode</h1>{state ? <Badge variant={state.status === "blocked" && state.blocker?.kind === "human" ? "destructive" : state.enabled ? "default" : "secondary"} className="max-w-full whitespace-normal [overflow-wrap:anywhere]">{autoModeStatusLabel[state.status]}</Badge> : null}</div><p className="mt-2 max-w-3xl text-muted-foreground">ProjectBrain stuurt roadmap, afhankelijkheden en herstel; Gitea is de uitvoerings- en evidencebron voor repositorywerk.</p></div><div className="flex max-w-full flex-wrap gap-2"><Button onClick={() => navigate(`/projects/${projectId}/workspace`)}><ListTodo className="mr-2 h-4 w-4" />Opdracht geven</Button><Button variant="outline" onClick={() => void load(policyDirty)} disabled={loading || saving}><RefreshCw className="mr-2 h-4 w-4" />Vernieuwen</Button></div></div>
{error ? <div role="alert" className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{error}</div> : null}
{state && operational ? <Card className={controllerClass}>
<CardContent className="space-y-5 p-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="flex min-w-0 gap-3"><span className="shrink-0 rounded-full bg-muted p-2">{operational.tone === "blocked" ? <ShieldAlert className="h-5 w-5 text-destructive" /> : operational.tone === "retry" ? <RefreshCw className="h-5 w-5 text-amber-700" /> : <Bot className="h-5 w-5" />}</span><div className="min-w-0"><div className="flex flex-wrap gap-2"><Badge variant="outline" className="max-w-full whitespace-normal [overflow-wrap:anywhere]">{operational.label}</Badge><Badge variant={state.enabled ? "default" : "outline"} className="max-w-full whitespace-normal [overflow-wrap:anywhere]">Auto {state.enabled ? "aan" : "uit"}</Badge></div><h2 className="mt-2 text-xl font-semibold">{operational.headline}</h2><p className="mt-1 max-w-3xl text-sm leading-6 text-muted-foreground">{operational.detail}</p></div></div>
<div className="flex max-w-full flex-wrap gap-2"><div className="flex items-center gap-3 rounded-lg border px-4 py-3"><Label htmlFor="auto-mode-toggle" className="font-semibold">Auto</Label><Switch id="auto-mode-toggle" checked={state.enabled} disabled={saving} onCheckedChange={(enabled) => void configure({ enabled })} /></div>{state.enabled ? <Button variant="outline" onClick={() => void configure({ pauseAfterCurrent: true })} disabled={saving || state.pauseAfterCurrent}>{state.pauseAfterCurrent ? "Pauze gepland" : "Pauzeer na huidig werk"}</Button> : null}</div>
</div>
<div className="grid gap-3 md:grid-cols-4"><div className="rounded-lg border p-3"><p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Nu</p><p className="mt-1 font-semibold">{inProgressItem ? inProgressItem.title : state.activeLease ? "Compatibiliteitslease actief" : state.enabled ? "Wacht op Gitea-branch" : "Auto staat uit"}</p></div><div className="rounded-lg border p-3"><p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Hierna</p><p className="mt-1 text-sm">{nextItem?.title ?? (openItems.length ? activePhase?.title ?? "Volgende veilige stap wordt bepaald" : "Geen open committed werk")}</p></div><div className="rounded-lg border p-3"><p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Voortgang</p><p className="mt-1 font-semibold">{completedItems}/{items.length} · {progress}%</p><Progress className="mt-2 h-1.5" value={progress} aria-valuetext={items.length ? `${completedItems} van ${items.length} werkitems voltooid (${progress}%)` : `${progress}%`} /></div><div className="rounded-lg border p-3"><p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Open</p><p className="mt-1 font-semibold">{openItems.length} werkitem{openItems.length === 1 ? "" : "s"}</p><p className="mt-1 text-xs text-muted-foreground">{activePhase ? `Fase: ${activePhase.title}` : "Geen actieve fase"}</p></div></div>
<p className="text-xs leading-5 text-muted-foreground">Repositorywerk wordt atomair geclaimd via <code className="[overflow-wrap:anywhere]">projectbrain/auto/&lt;work-id&gt;</code>. Een item wordt pas voltooid na succesvolle exact-head Gitea Actions-validatie en merge van de canonieke PR. Leases en callbacks blijven alleen voor compatibiliteit en herstel.</p>
</CardContent>
</Card> : null}
{state?.blocker?.kind === "retryable" ? <Card className="border-amber-300 bg-amber-50/30"><CardContent className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between"><div><p className="font-semibold">Automatisch herstel</p><p className="mt-1 text-sm text-muted-foreground">{state.blocker.reason} ProjectBrain probeert dit automatisch opnieuw; jouw actie is niet nodig.</p>{state.blocker.retryAt ? <p className="mt-1 text-xs text-muted-foreground">Opnieuw uitvoerbaar vanaf {new Date(state.blocker.retryAt).toLocaleString("nl-BE")}</p> : null}</div><Button variant="outline" onClick={() => void resume()} disabled={saving}><RotateCcw className="mr-2 h-4 w-4" />Nu opnieuw proberen</Button></CardContent></Card> : null}
{state?.blocker?.kind === "human" ? <Card className="border-destructive/40"><CardContent className="flex min-w-0 flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between"><div className="min-w-0"><p className="font-semibold text-destructive">Jouw beslissing of toegang nodig</p><p className="mt-1 text-sm text-muted-foreground">{state.blocker.reason}</p></div><div className="flex max-w-full flex-wrap gap-2"><Button variant="destructive" onClick={() => navigate(`/projects/${projectId}/attention`)}>Jouw aandacht <ArrowRight size={14} /></Button><Button variant="outline" onClick={() => void resume()} disabled={saving}>Na oplossing hervatten</Button></div></CardContent></Card> : null}
<details className="rounded-xl border bg-card"><summary className="cursor-pointer p-4 font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">Roadmap en wachtrij</summary><div className="space-y-4 border-t p-4"><div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"><div className="min-w-0"><p className="font-semibold">Committed werk</p><p className="text-sm text-muted-foreground">{state?.roadmap ? `${openItems.length} open · ${completedItems}/${items.length} voltooid` : "Nog geen roadmap of opdrachten."}</p></div><span className="text-2xl font-bold tabular-nums">{progress}%</span></div>{phases.map((phase, index) => { const done = phase.items.filter((item) => item.status === "completed").length; return <div key={phase.id} className="min-w-0 rounded-lg border p-4"><div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between"><div className="min-w-0"><p className="font-semibold">{index + 1}. {phase.title}</p><p className="mt-1 text-sm text-muted-foreground">{phase.objective}</p></div><Badge variant={phaseVariant(phase)} className="max-w-full whitespace-normal [overflow-wrap:anywhere]">{autoModePhaseLabel[phase.status]}</Badge></div><p className="mt-2 text-xs text-muted-foreground">{done}/{phase.items.length} items</p>{phase.items.length ? <div className="mt-3 grid min-w-0 gap-2 md:grid-cols-2">{phase.items.map((item) => <div key={item.id} className="min-w-0 rounded-md bg-muted/35 px-3 py-2 text-sm"><div className="flex min-w-0 items-start gap-2">{item.status === "completed" ? <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-primary" /> : <span className={`mt-1 h-2 w-2 shrink-0 rounded-full ${item.status === "blocked" ? "bg-destructive" : item.status === "in_progress" ? "bg-primary" : "bg-muted-foreground/50"}`} />}<span className="min-w-0">{item.title}</span></div><p className="mt-1 pl-6 text-xs text-muted-foreground">{autoModeItemLabel[item.status]} · {item.attempts} poging(en)</p></div>)}</div> : null}</div>; })}{!items.length ? <Button variant="outline" onClick={() => navigate(`/projects/${projectId}/workspace`)}>Opdracht geven <ArrowRight className="ml-2 h-4 w-4" /></Button> : null}</div></details>
<details className="rounded-xl border bg-card"><summary className="cursor-pointer p-4 font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">Autonomiebeleid en geavanceerde instellingen</summary><div className="grid min-w-0 gap-6 border-t p-4 xl:grid-cols-2"><Card className="min-w-0"><CardHeader><CardTitle>Autonomieprofiel</CardTitle><CardDescription>Routinewerk automatiseren zonder echte risico- of productbeslissingen te verbergen.</CardDescription></CardHeader><CardContent className="space-y-4"><div className="grid min-w-0 gap-2 sm:grid-cols-2">{autonomyProfileOrder.map((profile) => { const definition = autonomyProfileDefinitions[profile]; return <Button key={profile} type="button" variant={policy.profile === profile ? "default" : "outline"} className="h-auto min-h-16 w-full min-w-0 max-w-full justify-start whitespace-normal text-left [overflow-wrap:anywhere]" onClick={() => selectProfile(profile)}><span className="min-w-0"><span className="block font-semibold">{definition.label}</span><span className="mt-1 block text-xs font-normal opacity-80">{definition.description}</span></span></Button>; })}</div><details className="rounded-lg border"><summary className="cursor-pointer p-3 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">Geavanceerde grenzen</summary><div className="grid min-w-0 gap-4 border-t p-3 sm:grid-cols-2"><div className="min-w-0 space-y-2"><Label htmlFor="brainstorm-iterations">Max. brainstormrondes</Label><Input id="brainstorm-iterations" type="number" min={1} max={10} value={policy.maxBrainstormIterations} onChange={(event) => updatePolicyNumber("maxBrainstormIterations", event.target.value, 1, 10)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="failure-limit">Retry-drempel</Label><Input id="failure-limit" type="number" min={1} max={10} value={policy.maxConsecutiveFailures} onChange={(event) => updatePolicyNumber("maxConsecutiveFailures", event.target.value, 1, 10)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="lease-minutes">Compatibiliteitslease (min.)</Label><Input id="lease-minutes" type="number" min={5} max={120} value={policy.leaseMinutes} onChange={(event) => updatePolicyNumber("leaseMinutes", event.target.value, 5, 120)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="run-items">Werkitems per run</Label><Input id="run-items" type="number" min={1} max={20} value={policy.maxWorkItemsPerRun} onChange={(event) => updatePolicyNumber("maxWorkItemsPerRun", event.target.value, 1, 20)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="run-minutes">Looptijd per run</Label><Input id="run-minutes" type="number" min={5} max={55} value={policy.maxRunMinutes} onChange={(event) => updatePolicyNumber("maxRunMinutes", event.target.value, 5, 55)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="recovery-minutes">Max. recoveryduur</Label><Input id="recovery-minutes" type="number" min={15} max={1440} value={policy.maxRecoveryMinutes} onChange={(event) => updatePolicyNumber("maxRecoveryMinutes", event.target.value, 15, 1440)} /></div><div className="flex min-w-0 items-center justify-between gap-4 sm:col-span-2"><div className="min-w-0"><Label htmlFor="auto-brainstorm">Convergence-brainstorm</Label><p className="text-xs text-muted-foreground">Zoek na concrete taken nog naar echte gaten.</p></div><Switch id="auto-brainstorm" checked={policy.autoBrainstorm} onCheckedChange={(checked) => setPolicy((current) => ({ ...current, profile: "custom", autoBrainstorm: checked }))} /></div></div></details><Button variant="outline" className="w-full" onClick={() => void savePolicy()} disabled={saving || !policyDirty}>Beleid opslaan</Button></CardContent></Card><Card className="min-w-0"><CardHeader><CardTitle>Roadmap handmatig importeren</CardTitle><CardDescription>Alleen voor geavanceerd beheer; gewone opdrachten gaan automatisch naar de duurzame wachtrij.</CardDescription></CardHeader><CardContent className="space-y-4"><div className="min-w-0 space-y-2"><Label htmlFor="roadmap-title">Titel</Label><Input id="roadmap-title" value={roadmapTitle} onChange={(event) => setRoadmapTitle(event.target.value)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="roadmap-markdown">Roadmap</Label><Textarea id="roadmap-markdown" value={roadmapMarkdown} onChange={(event) => { setRoadmapMarkdown(event.target.value); setConfirmRoadmapReplacement(false); }} rows={10} className="max-w-full min-w-0 font-mono text-sm" /></div>{confirmRoadmapReplacement ? <div role="alert" className="min-w-0 rounded-lg border border-amber-300 bg-amber-50/40 p-4"><p className="font-semibold">Bestaande roadmap en voortgang vervangen?</p><div className="mt-3 flex max-w-full flex-wrap gap-2"><Button variant="destructive" onClick={() => void importRoadmap()} disabled={saving}>Definitief vervangen</Button><Button variant="outline" onClick={() => setConfirmRoadmapReplacement(false)}>Annuleren</Button></div></div> : <Button onClick={() => void importRoadmap()} disabled={saving || roadmapMarkdown.trim().length < 3}><Upload className="mr-2 h-4 w-4" />{state?.roadmap ? "Roadmap bijwerken" : "Roadmap importeren"}</Button>}</CardContent></Card></div></details>
<details className="rounded-xl border bg-card"><summary className="cursor-pointer p-4 font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"><BrainCircuit className="mr-2 inline-block h-5 w-5 align-text-bottom" />Technische autonome activiteit</summary><div className="border-t p-4">{state?.events.length ? <div className="space-y-3">{[...state.events].reverse().slice(0, 20).map((entry) => <div key={entry.id} className="min-w-0 border-l-2 pl-3"><div className="flex min-w-0 flex-wrap items-center gap-2"><span className="min-w-0 text-sm font-medium">{entry.summary}</span><Badge variant="outline" className="max-w-full whitespace-normal text-[10px] [overflow-wrap:anywhere]">{entry.actor}</Badge></div><p className="mt-1 text-xs text-muted-foreground">{new Date(entry.at).toLocaleString()} · {entry.type}</p></div>)}</div> : <p className="text-sm text-muted-foreground">Nog geen autonome activiteit.</p>}</div></details>
</div>;
};
export default AutoMode;