Menu
popagent
publicLatest change 07dc1a1d732223a40f0e64281e312ce454309080 - fix: stop autonomy detail refresh loop by AkurAI Build
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
AlertTriangle,
Ban,
CheckCircle2,
CircleDashed,
Clock3,
Flag,
LoaderCircle,
RotateCcw,
ShieldCheck,
SkipForward,
XCircle,
} from "lucide-react";
import { AUTONOMY_WORKFLOW_PHASES } from "../api-types";
import type {
AgentTask,
AgentWorkspace,
AgentWorkflowDetail,
AgentWorkflowPhaseDetail,
AgentWorkflowPhaseId,
AgentWorkflowPhaseState,
AgentWorkflowPhaseSummary,
AgentWorkflowSummary,
AutonomySettings,
AutomationStatus,
EvolutionRevision,
EvolutionRevisionListResponse,
EvolutionSignal,
EvolutionSignalListResponse,
TaskListResponse,
} from "../api-types";
import { apiFetch } from "./api";
import { toast } from "./toast";
import { useFocusTrap } from "./use-focus-trap";
const HISTORY_LIMIT = 100;
const HISTORY_REFRESH_MS = 2_000;
const WORKFLOW_PHASES: readonly { id: AgentWorkflowPhaseId; label: string; purpose: string }[] = AUTONOMY_WORKFLOW_PHASES.map((id) => ({
id,
label: id === "inspect-change" ? "Inspect change" : id[0]!.toUpperCase() + id.slice(1),
purpose: id === "prepare"
? "Scope the request and establish the operator-approved boundaries."
: id === "research"
? "Collect bounded repository and external evidence."
: id === "decide"
? "Choose one useful change or a clean no-op."
: id === "implement"
? "Make the smallest complete change in the contained checkout."
: id === "inspect-change"
? "Inspect the resulting diff and changed-file evidence."
: id === "verify"
? "Run focused checks and capture their observable result."
: id === "review"
? "Independently assess correctness, safety, and residual risk."
: "Commit the reviewed result on the prepared branch.",
}));
const WORKFLOW_STATE_META: Record<AgentWorkflowPhaseState, {
label: string;
Icon: typeof CircleDashed;
tone: string;
}> = {
waiting: { label: "Waiting", Icon: CircleDashed, tone: "text-ink-dim" },
active: { label: "Active", Icon: LoaderCircle, tone: "text-accent" },
complete: { label: "Complete", Icon: CheckCircle2, tone: "text-emerald-500" },
failed: { label: "Failed", Icon: XCircle, tone: "text-red-400" },
cancelled: { label: "Cancelled", Icon: Ban, tone: "text-amber-500" },
skipped: { label: "Skipped", Icon: SkipForward, tone: "text-ink-dim" },
};
const WORKFLOW_STATE_LABEL: Record<AgentWorkflowSummary["state"], string> = {
running: "Running",
completed: "Completed",
failed: "Failed",
cancelled: "Cancelled",
};
const WORKFLOW_PHASE_BY_ID = new Map(WORKFLOW_PHASES.map((phase) => [phase.id, phase]));
function phaseSummary(workflow: AgentWorkflowSummary | AgentWorkflowDetail | undefined, id: AgentWorkflowPhaseId): AgentWorkflowPhaseSummary | AgentWorkflowPhaseDetail | undefined {
return workflow?.phases.find((phase) => phase.id === id);
}
export function workflowPhaseLabel(id: AgentWorkflowPhaseId): string {
return WORKFLOW_PHASE_BY_ID.get(id)?.label ?? id;
}
export function workflowPhaseStateLabel(state: AgentWorkflowPhaseState): string {
return WORKFLOW_STATE_META[state].label;
}
function workflowPhaseCopy(id: AgentWorkflowPhaseId, state: AgentWorkflowPhaseState): string {
if (state === "failed") return "This phase failed. Review the reported evidence and failure reason before taking another action.";
if (state === "cancelled") return "This phase was cancelled by the operator. No later phase was started.";
if (state === "skipped") return id === "decide"
? "No-op: the evidence did not justify a change, so the workflow stopped without implementation."
: "No-op: this phase was not needed for the selected outcome.";
if (id === "commit") return "Commit evidence is local to the prepared branch. Publication and deployment remain governed separately.";
return WORKFLOW_PHASE_BY_ID.get(id)?.purpose ?? "Bounded workflow evidence for this phase.";
}
function WorkflowState({ state }: { state: AgentWorkflowPhaseState }) {
const { Icon, label, tone } = WORKFLOW_STATE_META[state];
return <span className={`inline-flex items-center gap-1.5 text-xs font-medium ${tone}`}><Icon aria-hidden="true" className={`size-4 ${state === "active" ? "animate-spin motion-reduce:animate-none" : ""}`} />{label}</span>;
}
export function AutonomyWorkflowRail({
workflow,
selectedPhase,
onSelect,
}: {
workflow?: AgentWorkflowSummary | AgentWorkflowDetail;
selectedPhase: AgentWorkflowPhaseId;
onSelect: (phase: AgentWorkflowPhaseId) => void;
}) {
return <nav aria-label="Autonomous workflow phases">
<ol className="relative space-y-1 before:absolute before:bottom-5 before:left-5 before:top-5 before:w-px before:bg-line">
{WORKFLOW_PHASES.map((phase) => {
const summary = phaseSummary(workflow, phase.id);
const state = summary?.state ?? "waiting";
const selected = selectedPhase === phase.id;
const label = phase.label;
const { Icon, tone } = WORKFLOW_STATE_META[state];
return <li key={phase.id} className="relative">
<button type="button" aria-current={selected ? "step" : undefined} aria-label={`${label}: ${workflowPhaseStateLabel(state)}`} onClick={() => onSelect(phase.id)} className={`flex min-h-11 w-full items-center gap-3 rounded px-2.5 text-left text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent/60 ${selected ? "bg-accent/10 text-ink" : "text-ink-muted hover:bg-panel-2"}`}>
<span className={`z-10 flex size-6 shrink-0 items-center justify-center rounded-full border bg-panel ${selected ? "border-accent/60" : "border-line"} ${tone}`}><Icon aria-hidden="true" className={`size-4 ${state === "active" ? "animate-spin motion-reduce:animate-none" : ""}`} /></span>
<span className="min-w-0 flex-1"><span className="block truncate font-medium">{label}</span><span className="block text-xs text-ink-dim">{workflowPhaseStateLabel(state)}</span></span>
</button>
</li>;
})}
</ol>
</nav>;
}
export function AutonomyWorkflowPhaseDetail({
workflow,
selectedPhase,
}: {
workflow?: AgentWorkflowSummary | AgentWorkflowDetail;
selectedPhase: AgentWorkflowPhaseId;
}) {
const summary = phaseSummary(workflow, selectedPhase);
const state = summary?.state ?? "waiting";
const detail = summary && "startedAt" in summary ? summary : undefined;
return <article aria-labelledby="autonomy-phase-detail-title" className="min-w-0 rounded border border-line bg-panel p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0"><p className="text-xs font-semibold uppercase tracking-[0.12em] text-ink-dim">Selected phase</p><h3 id="autonomy-phase-detail-title" className="mt-1 text-lg font-semibold">{summary?.label ?? workflowPhaseLabel(selectedPhase)}</h3></div>
<WorkflowState state={state} />
</div>
<p className="mt-3 text-sm leading-relaxed text-ink-muted">{workflowPhaseCopy(selectedPhase, state)}</p>
{detail?.error ? <p role="alert" className="mt-4 break-words rounded border border-red-500/20 bg-red-500/5 px-3 py-2 text-sm text-red-400">{detail.error}</p> : null}
{detail?.startedAt || detail?.completedAt ? <p className="mt-4 text-xs text-ink-dim">{detail.startedAt ? `Started ${dateTime(detail.startedAt)}` : ""}{detail.startedAt && detail.completedAt ? " · " : ""}{detail.completedAt ? `Finished ${dateTime(detail.completedAt)}` : ""}</p> : null}
<div className="mt-5">
<h4 className="text-xs font-semibold uppercase tracking-[0.12em] text-ink-dim">Evidence</h4>
{summary?.evidence.length ? <ul className="mt-2 space-y-2">{summary.evidence.map((item, index) => <li key={`${selectedPhase}-${index}`} className="break-words rounded border border-line bg-bg px-3 py-2 text-sm leading-relaxed">{item}</li>)}</ul> : <p className="mt-2 text-sm text-ink-dim">No evidence has been recorded for this phase yet.</p>}
</div>
</article>;
}
function workflowTaskState(task: AgentTask): AgentWorkflowSummary["state"] | undefined {
return task.workflow?.state;
}
function workflowOverallPhaseState(state: AgentWorkflowSummary["state"]): AgentWorkflowPhaseState {
if (state === "running") return "active";
if (state === "failed") return "failed";
if (state === "cancelled") return "cancelled";
return "complete";
}
export function AutonomyWorkflowHistory({
tasks,
workspaces,
selectedTaskId,
onSelect,
stateFilter,
workspaceFilter,
onStateFilter,
onWorkspaceFilter,
}: {
tasks: AgentTask[];
workspaces: AgentWorkspace[];
selectedTaskId?: string;
onSelect: (task: AgentTask) => void;
stateFilter: "all" | AgentWorkflowSummary["state"];
workspaceFilter: string;
onStateFilter: (value: "all" | AgentWorkflowSummary["state"]) => void;
onWorkspaceFilter: (value: string) => void;
}) {
const workspaceNames = new Map(workspaces.map((workspace) => [workspace.id, workspace.name]));
const filtered = tasks.filter((task) =>
task.workflow &&
(stateFilter === "all" || workflowTaskState(task) === stateFilter) &&
(workspaceFilter === "all" || task.workspaceId === workspaceFilter)
);
return <section aria-labelledby="autonomy-workflow-history-title" className="mt-5 rounded border border-line bg-panel p-5">
<div className="flex items-center gap-2"><Clock3 aria-hidden="true" className="size-4 text-accent" /><h2 id="autonomy-workflow-history-title" className="font-medium">Runs</h2></div>
<div className="mt-4 flex flex-wrap gap-2" aria-label="Workflow history filters">
{(["all", "running", "completed", "failed", "cancelled"] as const).map((value) => <button key={value} type="button" aria-pressed={stateFilter === value} onClick={() => onStateFilter(value)} className={`min-h-11 rounded border px-3 text-xs font-medium capitalize ${stateFilter === value ? "border-accent/50 bg-accent/10 text-ink" : "border-line text-ink-dim hover:text-ink"}`}>{value}</button>)}
{workspaces.length ? <select aria-label="Filter workflow history by workspace" value={workspaceFilter} onChange={(event) => onWorkspaceFilter(event.currentTarget.value)} className="min-h-11 max-w-full rounded border border-line bg-bg px-3 text-xs text-ink outline-none focus:border-accent/50"><option value="all">All workspaces</option>{workspaces.map((workspace) => <option key={workspace.id} value={workspace.id}>{workspace.name}</option>)}</select> : null}
</div>
{filtered.length ? <ol className="mt-4 divide-y divide-line border-y border-line">{filtered.map((task) => {
const current = task.workflow?.phases.find((phase) => phase.state === "active") ?? task.workflow?.phases.find((phase) => phase.state === "failed") ?? task.workflow?.phases.at(-1);
const selected = task.id === selectedTaskId;
return <li key={task.id}><button type="button" aria-current={selected ? "true" : undefined} onClick={() => onSelect(task)} className={`flex min-h-16 w-full min-w-0 items-center gap-3 py-3 text-left outline-none focus-visible:ring-2 focus-visible:ring-accent/60 ${selected ? "bg-accent/5" : ""}`}><span className="min-w-0 flex-1"><span className="block truncate text-sm font-medium">{task.prompt}</span><span className="mt-1 block truncate text-xs text-ink-dim">{workspaceNames.get(task.workspaceId) ?? task.workspaceId} · {current?.label ?? (current ? workflowPhaseLabel(current.id) : "Prepare")} · {task.createdAt ? dateTime(task.createdAt) : "Unknown time"}</span></span>{task.workflow ? <WorkflowState state={current?.state ?? workflowOverallPhaseState(task.workflow.state)} /> : null}</button></li>;
})}</ol> : <p className="mt-5 rounded border border-dashed border-line px-3 py-8 text-center text-sm text-ink-dim">No workflow runs match these filters.</p>}
</section>;
}
export type AutonomyHistoryPoll = {
signal: AbortSignal;
stop: () => void;
};
export function startAutonomyHistoryPolling(
refresh: (signal: AbortSignal, initial: boolean) => Promise<void>,
intervalMs = HISTORY_REFRESH_MS,
): AutonomyHistoryPoll {
const controller = new AbortController();
let timer: Timer | undefined;
let initial = true;
const run = async () => {
if (controller.signal.aborted) return;
try {
await refresh(controller.signal, initial);
} catch {
// Preserve the last rendered history and retry after transient refresh failures.
}
initial = false;
if (!controller.signal.aborted) timer = setTimeout(() => void run(), intervalMs);
};
void run();
return {
signal: controller.signal,
stop: () => {
controller.abort();
clearTimeout(timer);
},
};
}
function dateTime(value: string): string {
return new Date(value).toLocaleString();
}
const STATUS_CLASSES: Record<string, string> = {
applied: "text-emerald-500",
"dead-letter": "text-red-400",
reverted: "text-amber-500",
ignored: "text-amber-500",
};
const REVISION_LABEL_BY_TARGET: Record<EvolutionRevision["targetType"], string> = {
overlay: "Learned instruction overlay",
skill: "Agent-created skill",
fact: "Learned durable fact",
};
const REVISION_NOUN_BY_TARGET: Record<EvolutionRevision["targetType"], string> = {
overlay: "instruction overlay",
skill: "skill content",
fact: "durable fact",
};
export function countAutonomySignalHealth(signals: EvolutionSignal[]) {
const counts = { pending: 0, processing: 0, applied: 0, ignored: 0, retried: 0, deadLetters: 0 };
for (const signal of signals) {
if (signal.status === "pending") counts.pending++;
else if (signal.status === "processing") counts.processing++;
else if (signal.status === "applied") counts.applied++;
else if (signal.status === "ignored") counts.ignored++;
else counts.deadLetters++;
if (signal.attempts > 1) counts.retried++;
}
return counts;
}
export function AutonomySignalList({ signals }: { signals: EvolutionSignal[] }) {
if (!signals.length) return <p className="text-sm text-ink-dim">No evolution signals have been recorded.</p>;
return <ol className="space-y-2">
{signals.map((signal) => <li key={signal.id} className="rounded border border-line bg-bg p-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm leading-relaxed">{signal.summary}</p>
<p className="mt-1 text-xs text-ink-dim">{signal.kind} · {signal.agentId}{signal.workspaceId ? ` · ${signal.workspaceId}` : ""} · {dateTime(signal.createdAt)}</p>
</div>
<span className={`text-xs font-semibold uppercase tracking-wide ${STATUS_CLASSES[signal.status] ?? "text-ink-dim"}`}>{signal.status}</span>
</div>
<p className="mt-2 text-xs text-ink-dim">{signal.attempts === 1 ? "1 attempt" : `${signal.attempts} attempts`}{signal.nextAttemptAt ? ` · retry ${dateTime(signal.nextAttemptAt)}` : ""}{signal.turnId ? ` · turn ${signal.turnId}` : ""}{signal.traceId ? ` · trace ${signal.traceId}` : ""}</p>
{signal.error ? <p className="mt-2 rounded border border-red-500/20 bg-red-500/5 px-3 py-2 text-xs text-red-400">{signal.error}</p> : null}
</li>)}
</ol>;
}
export function AutonomyRevisionList({
revisions,
busyId,
onRevert,
}: {
revisions: EvolutionRevision[];
busyId?: string;
onRevert: (revision: EvolutionRevision) => void;
}) {
if (!revisions.length) return <p className="text-sm text-ink-dim">No learned changes have been applied.</p>;
const latestTargets = new Set<string>();
const revertedRevisionIds = new Set(revisions.flatMap((revision) =>
revision.revertsRevisionId ? [revision.revertsRevisionId] : []
));
const revertibleIds = new Set<string>();
for (const revision of revisions) {
const target = `${revision.agentId}\0${revision.targetType}\0${revision.targetKey}`;
if (latestTargets.has(target)) continue;
latestTargets.add(target);
if (revision.status === "applied" && !revertedRevisionIds.has(revision.id)) {
revertibleIds.add(revision.id);
}
}
return <div className="space-y-3">
{revisions.map((revision) => <article key={revision.id} className="rounded border border-line bg-bg p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-medium">{REVISION_LABEL_BY_TARGET[revision.targetType]}</p>
<p className="mt-1 break-all font-mono text-xs text-ink-dim">{revision.agentId} / {revision.targetKey}</p>
</div>
<span className={`text-xs font-semibold uppercase tracking-wide ${STATUS_CLASSES[revision.status] ?? "text-ink-dim"}`}>{revision.status}</span>
</div>
<p className="mt-3 text-sm leading-relaxed text-ink-muted">{revision.rationale}</p>
<p className="mt-2 break-all text-xs text-ink-dim">{dateTime(revision.appliedAt)} · {revision.evidenceIds.length} evidence {revision.evidenceIds.length === 1 ? "item" : "items"}{revision.revertsRevisionId ? ` · reverts ${revision.revertsRevisionId}` : ""}</p>
<details className="mt-3 border-t border-line pt-3 text-sm">
<summary className="cursor-pointer text-ink-muted outline-none focus:text-ink">Inspect learned change</summary>
<div className="mt-3 grid gap-3 lg:grid-cols-2">
<div><h3 className="text-xs font-semibold uppercase tracking-wide text-ink-dim">Before</h3><pre className="mt-1 max-h-64 overflow-auto whitespace-pre-wrap rounded bg-panel-2 p-3 text-xs">{revision.beforeContent || "Empty"}</pre></div>
<div><h3 className="text-xs font-semibold uppercase tracking-wide text-ink-dim">After</h3><pre className="mt-1 max-h-64 overflow-auto whitespace-pre-wrap rounded bg-panel-2 p-3 text-xs">{revision.afterContent || "Empty"}</pre></div>
</div>
</details>
{revertibleIds.has(revision.id) ? <div className="mt-3 flex justify-end"><button type="button" disabled={Boolean(busyId)} onClick={() => onRevert(revision)} className="flex min-h-11 items-center gap-2 rounded border border-amber-500/30 px-3 text-sm text-amber-500 disabled:opacity-50"><RotateCcw className="size-4" />{busyId === revision.id ? "Reverting…" : "Revert change"}</button></div> : null}
</article>)}
</div>;
}
function NumberField({ label, value, min, max, step = 1, onChange }: { label: string; value: number; min: number; max: number; step?: number; onChange: (value: number) => void }) {
return <label className="text-sm font-medium">{label}<input type="number" aria-label={label} value={value} min={min} max={max} step={step} onChange={(event) => { const next = event.currentTarget.valueAsNumber; if (Number.isFinite(next)) onChange(next); }} className="mt-2 min-h-11 w-full rounded border border-line bg-bg px-3 text-ink outline-none focus:border-accent/50" /></label>;
}
export function AutonomySettingsPanel() {
const [settings, setSettings] = useState<AutonomySettings>();
const [signals, setSignals] = useState<EvolutionSignal[]>();
const [revisions, setRevisions] = useState<EvolutionRevision[]>();
const [workspaces, setWorkspaces] = useState<AgentWorkspace[]>([]);
const [status, setStatus] = useState<AutomationStatus>();
const [workflowTasks, setWorkflowTasks] = useState<AgentTask[]>([]);
const [workflowLoading, setWorkflowLoading] = useState(true);
const [workflowError, setWorkflowError] = useState("");
const [selectedTaskId, setSelectedTaskId] = useState<string>();
const [selectedPhase, setSelectedPhase] = useState<AgentWorkflowPhaseId>("prepare");
const [workflowDetail, setWorkflowDetail] = useState<AgentWorkflowSummary | AgentWorkflowDetail>();
const [workflowDetailLoading, setWorkflowDetailLoading] = useState(false);
const [workflowDetailError, setWorkflowDetailError] = useState("");
const [historyStateFilter, setHistoryStateFilter] = useState<"all" | AgentWorkflowSummary["state"]>("all");
const [historyWorkspaceFilter, setHistoryWorkspaceFilter] = useState("all");
const [loadError, setLoadError] = useState("");
const [saving, setSaving] = useState(false);
const [reverting, setReverting] = useState<string>();
const [cancelling, setCancelling] = useState(false);
const [confirmRevision, setConfirmRevision] = useState<EvolutionRevision>();
const confirmRef = useRef<HTMLDivElement>(null);
const cancelRef = useRef<HTMLButtonElement>(null);
const lifecycleSignalRef = useRef<AbortSignal | null>(null);
const historyRequestRef = useRef(0);
const historyAppliedRef = useRef(0);
useFocusTrap(confirmRef, Boolean(confirmRevision));
const fetchHistory = useCallback(async (signal?: AbortSignal) => {
const [signalsResponse, revisionsResponse] = await Promise.all([
apiFetch(`/api/autonomy/signals?limit=${HISTORY_LIMIT}`, { signal }),
apiFetch(`/api/autonomy/revisions?limit=${HISTORY_LIMIT}`, { signal }),
]);
if (!signalsResponse.ok || !revisionsResponse.ok) {
throw new Error("Unable to load autonomy state.");
}
const [signalHistory, revisionHistory] = await Promise.all([
signalsResponse.json() as Promise<EvolutionSignalListResponse>,
revisionsResponse.json() as Promise<EvolutionRevisionListResponse>,
]);
return { signals: signalHistory.signals, revisions: revisionHistory.revisions };
}, []);
const fetchWorkflowTasks = useCallback(async (signal?: AbortSignal) => {
const response = await apiFetch("/api/tasks?workflow=true", { signal });
if (!response.ok) throw new Error(`Unable to load workflow history (HTTP ${response.status}).`);
const payload = await response.json() as TaskListResponse;
if (signal?.aborted) return;
setWorkflowTasks(payload.tasks.filter((task) => Boolean(task.workflow)));
setWorkflowError("");
}, []);
const refreshWorkflowTasks = useCallback(async (signal?: AbortSignal) => {
setWorkflowLoading(true);
try {
await fetchWorkflowTasks(signal);
} catch (error) {
if (!signal?.aborted) setWorkflowError(error instanceof Error ? error.message : "Unable to load workflow history.");
} finally {
if (!signal?.aborted) setWorkflowLoading(false);
}
}, [fetchWorkflowTasks]);
const selectedTask = useMemo(
() => workflowTasks.find((task) => task.id === selectedTaskId),
[selectedTaskId, workflowTasks],
);
useEffect(() => {
const active = status?.activeTask?.workflow ? status.activeTask : undefined;
const next = active ?? (selectedTaskId ? workflowTasks.find((task) => task.id === selectedTaskId) : undefined) ?? workflowTasks[0];
if (!next) return;
if (next.id !== selectedTaskId) setSelectedTaskId(next.id);
if (next.workflow?.currentPhase) setSelectedPhase(next.workflow.currentPhase);
}, [selectedTaskId, status, workflowTasks]);
useEffect(() => {
if (!selectedTaskId) {
setWorkflowDetail(undefined);
return;
}
const summary = selectedTask?.workflow;
if (!summary) {
setWorkflowDetail(undefined);
setWorkflowDetailError("");
return;
}
const controller = new AbortController();
setWorkflowDetail(summary);
setWorkflowDetailLoading(true);
setWorkflowDetailError("");
void apiFetch(`/api/tasks/${encodeURIComponent(selectedTaskId)}/workflow`, { signal: controller.signal })
.then(async (response) => {
if (!response.ok) throw new Error(response.status === 404 ? "Workflow details are no longer available." : `Unable to load phase details (HTTP ${response.status}).`);
const detail = await response.json() as AgentWorkflowDetail;
if (!controller.signal.aborted) {
setWorkflowDetail(detail);
if (detail.currentPhase) setSelectedPhase(detail.currentPhase);
}
})
.catch((error) => {
if (!controller.signal.aborted) setWorkflowDetailError(error instanceof Error ? error.message : "Unable to load phase details.");
})
.finally(() => {
if (!controller.signal.aborted) setWorkflowDetailLoading(false);
});
return () => controller.abort();
}, [selectedTask?.updatedAt, selectedTaskId]);
const load = useCallback(async (signal?: AbortSignal) => {
const historyRequest = ++historyRequestRef.current;
setLoadError("");
try {
const [settingsResponse, workspacesResponse, statusResponse, history] = await Promise.all([
apiFetch("/api/settings/autonomy", { signal }),
apiFetch("/api/workspaces", { signal }),
apiFetch("/api/autonomy/status", { signal }),
fetchHistory(signal),
]);
if (!settingsResponse.ok || !workspacesResponse.ok || !statusResponse.ok) throw new Error("Unable to load autonomy state.");
const [nextSettings, workspaceResult, nextStatus] = await Promise.all([
settingsResponse.json() as Promise<AutonomySettings>,
workspacesResponse.json() as Promise<{ workspaces: AgentWorkspace[] }>,
statusResponse.json() as Promise<AutomationStatus>,
]);
if (signal?.aborted) return;
setSettings(nextSettings);
setWorkspaces(workspaceResult.workspaces);
setStatus(nextStatus);
if (historyRequest < historyAppliedRef.current) return;
historyAppliedRef.current = historyRequest;
setSignals(history.signals);
setRevisions(history.revisions);
} catch (error) {
if (signal?.aborted || historyRequest < historyAppliedRef.current) return;
setLoadError(error instanceof Error ? error.message : "Unable to load autonomy state.");
}
}, [fetchHistory]);
useEffect(() => {
const poll = startAutonomyHistoryPolling(async (signal, initial) => {
if (initial) {
await Promise.all([load(signal), refreshWorkflowTasks(signal)]);
return;
}
const historyRequest = ++historyRequestRef.current;
const history = await fetchHistory(signal);
void refreshWorkflowTasks(signal);
if (signal.aborted || historyRequest < historyAppliedRef.current) return;
historyAppliedRef.current = historyRequest;
setSignals(history.signals);
setRevisions(history.revisions);
});
lifecycleSignalRef.current = poll.signal;
return () => {
poll.stop();
if (lifecycleSignalRef.current === poll.signal) lifecycleSignalRef.current = null;
};
}, [fetchHistory, load, refreshWorkflowTasks]);
useEffect(() => {
if (!confirmRevision) return;
cancelRef.current?.focus();
const close = (event: KeyboardEvent) => {
if (event.key === "Escape" && !reverting) setConfirmRevision(undefined);
};
document.addEventListener("keydown", close);
return () => document.removeEventListener("keydown", close);
}, [confirmRevision, reverting]);
const update = <Key extends keyof AutonomySettings>(key: Key, value: AutonomySettings[Key]) => {
setSettings((current) => current ? { ...current, [key]: value } : current);
};
const save = async () => {
if (!settings) return;
setSaving(true);
try {
const { updatedAt, ...input } = settings;
void updatedAt;
const response = await apiFetch("/api/settings/autonomy", { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(input) });
if (!response.ok) throw new Error("Unable to save autonomy settings.");
setSettings(await response.json() as AutonomySettings);
toast("Autonomy settings saved.");
} catch (error) {
toast(error instanceof Error ? error.message : "Unable to save autonomy settings.");
} finally {
setSaving(false);
}
};
const revert = async () => {
if (!confirmRevision) return;
const id = confirmRevision.id;
setReverting(id);
try {
const response = await apiFetch(`/api/autonomy/revisions/${encodeURIComponent(id)}/revert`, { method: "POST" });
if (!response.ok) throw new Error(response.status === 404 ? "This revision can no longer be reverted." : "Unable to revert revision.");
setConfirmRevision(undefined);
await load(lifecycleSignalRef.current ?? undefined);
toast("Revision reverted.");
} catch (error) {
toast(error instanceof Error ? error.message : "Unable to revert revision.");
} finally {
setReverting(undefined);
}
};
const refreshWorkflow = async () => {
await refreshWorkflowTasks(lifecycleSignalRef.current ?? undefined);
};
const cancelWorkflow = async () => {
const task = selectedTask;
if (!task || task.status !== "running") return;
setCancelling(true);
try {
const response = await apiFetch(`/api/tasks/${encodeURIComponent(task.id)}/cancel`, { method: "POST" });
if (!response.ok) throw new Error(response.status === 409 ? "This workflow can no longer be cancelled." : "Unable to cancel workflow.");
toast("Workflow cancellation requested.");
await refreshWorkflow();
} catch (error) {
toast(error instanceof Error ? error.message : "Unable to cancel workflow.");
} finally {
setCancelling(false);
}
};
const activeWorkflowTask = status?.activeTask?.workflow
? status.activeTask
: workflowTasks.find((task) => task.workflow?.state === "running");
const selectedWorkflow = workflowDetail ?? selectedTask?.workflow;
if (loadError) return <div className="w-full max-w-5xl"><h1 className="text-xl font-semibold">Autonomy</h1><div role="alert" className="mt-6 rounded border border-red-500/20 bg-panel p-5"><div className="flex items-start gap-3"><AlertTriangle className="mt-0.5 size-5 text-red-400" /><div><p className="font-medium">Autonomy state unavailable</p><p className="mt-1 text-sm text-ink-dim">{loadError}</p><button type="button" onClick={() => void load(lifecycleSignalRef.current ?? undefined)} className="mt-4 min-h-11 rounded border border-line px-4 text-sm">Try again</button></div></div></div></div>;
if (!settings || !signals || !revisions) return <div className="w-full max-w-5xl"><h1 className="text-xl font-semibold">Autonomy</h1><p role="status" className="mt-6 text-sm text-ink-dim">Loading autonomy state and history…</p></div>;
const health = countAutonomySignalHealth(signals);
return <div className="w-full max-w-5xl">
<h1 className="text-xl font-semibold">Autonomy</h1>
<p className="mt-1 text-sm text-ink-dim">Control bounded self-improvement, inspect its evidence, and undo learned changes without altering base instructions or capabilities.</p>
<section className="mt-6 rounded border border-line bg-panel p-5" aria-labelledby="autonomy-workflow-title">
<header className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><Flag aria-hidden="true" className="size-5 text-accent" /><h2 id="autonomy-workflow-title" className="font-medium">Autonomous improvement workflow</h2>{activeWorkflowTask?.workflow ? <span className="rounded-full border border-accent/30 bg-accent/10 px-2 py-0.5 text-xs text-accent">{WORKFLOW_STATE_LABEL[activeWorkflowTask.workflow.state]}</span> : null}</div><p className="mt-1 text-xs leading-relaxed text-ink-dim">Follow one bounded run from preparation through review and its local commit.</p></div>
<div className="flex flex-wrap gap-2">
<button type="button" disabled={workflowLoading} onClick={() => void refreshWorkflow()} className="min-h-11 rounded border border-line px-3 text-xs font-medium disabled:opacity-50">{workflowLoading ? "Refreshing…" : "Refresh workflow"}</button>
{selectedTask?.status === "running" ? <button type="button" disabled={cancelling} onClick={() => void cancelWorkflow()} className="min-h-11 rounded border border-red-500/30 px-3 text-xs font-medium text-red-400 disabled:opacity-50">{cancelling ? "Cancelling…" : "Cancel run"}</button> : null}
</div>
</header>
<p role="status" aria-live="polite" className="sr-only">{activeWorkflowTask?.workflow ? `Workflow ${WORKFLOW_STATE_LABEL[activeWorkflowTask.workflow.state].toLowerCase()}. Current phase ${activeWorkflowTask.workflow.currentPhase ? workflowPhaseLabel(activeWorkflowTask.workflow.currentPhase) : "Prepare"}.` : "No autonomous workflow is running."}</p>
{workflowError ? <div role="alert" className="mt-5 flex items-start gap-3 rounded border border-red-500/20 bg-red-500/5 p-4"><AlertTriangle aria-hidden="true" className="mt-0.5 size-5 shrink-0 text-red-400" /><div className="min-w-0"><p className="text-sm font-medium">Workflow history unavailable</p><p className="mt-1 text-sm text-ink-dim">{workflowError}</p><button type="button" onClick={() => void refreshWorkflow()} className="mt-3 min-h-11 rounded border border-line px-3 text-xs">Try again</button></div></div> : null}
{workflowLoading ? <p role="status" className="sr-only">Loading runs</p> : workflowTasks.length === 0 ? <p className="mt-5 rounded border border-dashed border-line px-3 py-8 text-center text-sm text-ink-dim">No autonomous workflow runs yet. Enable a repository below to let the next contained cycle appear here.</p> : selectedWorkflow ? <div className="mt-5 grid gap-5 xl:grid-cols-[minmax(0,1fr)_minmax(0,2fr)]">
<div className="min-w-0 xl:sticky xl:top-4 xl:self-start"><AutonomyWorkflowRail workflow={selectedWorkflow} selectedPhase={selectedPhase} onSelect={setSelectedPhase} /></div>
<div className="min-w-0">{workflowDetailError ? <p role="alert" className="mb-3 rounded border border-red-500/20 bg-red-500/5 px-3 py-2 text-sm text-red-400">{workflowDetailError}</p> : null}{workflowDetailLoading ? <p role="status" className="mb-3 text-sm text-ink-dim">Loading selected phase details…</p> : null}<AutonomyWorkflowPhaseDetail workflow={selectedWorkflow} selectedPhase={selectedPhase} /></div>
</div> : <p className="mt-5 text-sm text-ink-dim">Select a workflow run from history to inspect its phases.</p>}
</section>
<AutonomyWorkflowHistory tasks={workflowTasks} workspaces={workspaces} selectedTaskId={selectedTaskId} onSelect={(task) => { setSelectedTaskId(task.id); if (task.workflow?.currentPhase) setSelectedPhase(task.workflow.currentPhase); }} stateFilter={historyStateFilter} workspaceFilter={historyWorkspaceFilter} onStateFilter={setHistoryStateFilter} onWorkspaceFilter={setHistoryWorkspaceFilter} />
<section className="mt-6 rounded border border-line bg-panel p-5" aria-labelledby="autonomy-controls-title">
<div className="flex items-start gap-3"><ShieldCheck className="mt-0.5 size-5 text-accent" /><div><h2 id="autonomy-controls-title" className="font-medium">Safety-controlled learning</h2><p className="mt-1 text-xs leading-relaxed text-ink-dim">Learning is limited to a separate instruction overlay, agent-created skills, and bounded durable facts. Workspace access, tools, secrets, hooks, authentication, and execution bounds remain fixed.</p></div></div>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
{([
["enabled", "Enable self-improvement", "Reflect on bounded signals and propose learned changes."],
["autoApplyStrategies", "Auto-apply strategies", "Apply eligible learned instruction overlays automatically."],
["autoCreateSkills", "Auto-create skills", "Create agent-owned skills when repeated evidence supports one."],
["autoRetainFacts", "Auto-retain facts", "Store stable operational facts established by cited execution evidence."],
["selfUpdateEnabled", "Enable source self-update", "Queue contained reviewed repository improvements."],
["idleImprovementEnabled", "Enable idle improvements", "Run one selected repository only after the idle grace period."],
["idleDeploymentEnabled", "Publish and deploy", "After review, use the repository's maintained publish and deploy path."],
] as const).map(([key, label, description]) => <label key={key} className="flex min-h-20 items-start justify-between gap-4 rounded border border-line bg-bg p-3"><span><span className="block text-sm font-medium">{label}</span><span className="mt-1 block text-xs leading-relaxed text-ink-dim">{description}</span></span><input type="checkbox" aria-label={label} checked={settings[key]} onChange={(event) => update(key, event.currentTarget.checked)} className="mt-1 size-5 shrink-0 accent-accent" /></label>)}
</div>
<div className="mt-5 grid gap-4 sm:grid-cols-3">
<NumberField label="Reflection interval (ms)" value={settings.reflectionIntervalMs} min={60_000} max={86_400_000} step={60_000} onChange={(value) => update("reflectionIntervalMs", value)} />
<NumberField label="Signals per reflection" value={settings.batchSize} min={1} max={100} onChange={(value) => update("batchSize", value)} />
<NumberField label="Maximum attempts" value={settings.maxAttempts} min={1} max={10} onChange={(value) => update("maxAttempts", value)} />
</div>
<fieldset className="mt-5"><legend className="text-sm font-medium">Autonomous repositories</legend><div className="mt-2 grid gap-2 sm:grid-cols-2">{workspaces.map((workspace) => <label key={workspace.id} className="flex min-h-11 items-center gap-3 rounded border border-line bg-bg px-3 text-sm"><input type="checkbox" checked={settings.idleWorkspaceIds.includes(workspace.id)} onChange={(event) => update("idleWorkspaceIds", event.currentTarget.checked ? [...settings.idleWorkspaceIds, workspace.id] : settings.idleWorkspaceIds.filter((id) => id !== workspace.id))} className="size-5 accent-accent" /><span>{workspace.name}</span></label>)}</div></fieldset>
{status ? <p role="status" className="mt-4 text-sm text-ink-dim">Automation: {status.state} · continuous scheduled work</p> : null}
<div className="mt-5 flex justify-end"><button type="button" disabled={saving} onClick={() => void save()} className="min-h-11 rounded bg-accent px-4 text-sm font-medium text-white disabled:opacity-60">{saving ? "Saving…" : "Save autonomy settings"}</button></div>
</section>
<section className="mt-5" aria-labelledby="autonomy-health-title">
<div className="flex items-center gap-2"><CheckCircle2 className="size-4 text-accent" /><h2 id="autonomy-health-title" className="font-medium">Recent signal health</h2></div>
<div className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6">
{[["Pending", health.pending], ["Processing", health.processing], ["Applied", health.applied], ["Ignored", health.ignored], ["Retried", health.retried], ["Dead letters", health.deadLetters]].map(([label, value]) => <div key={label} className="rounded border border-line bg-panel p-4"><p className="text-xs uppercase tracking-wide text-ink-dim">{label}</p><p className="mt-1 text-2xl font-semibold tabular-nums">{value}</p></div>)}
</div>
</section>
<div className="mt-5 grid gap-5 xl:grid-cols-2">
<section className="rounded border border-line bg-panel p-5" aria-labelledby="signal-history-title"><div className="flex items-center gap-2"><Clock3 className="size-4 text-accent" /><h2 id="signal-history-title" className="font-medium">Recent signals</h2></div><div className="mt-4"><AutonomySignalList signals={signals} /></div></section>
<section className="rounded border border-line bg-panel p-5" aria-labelledby="revision-history-title"><div className="flex items-center gap-2"><RotateCcw className="size-4 text-accent" /><h2 id="revision-history-title" className="font-medium">Learned revisions</h2></div><div className="mt-4"><AutonomyRevisionList revisions={revisions} busyId={reverting} onRevert={setConfirmRevision} /></div></section>
</div>
{confirmRevision ? <div className="fixed inset-0 z-[60] grid place-items-center bg-black/60 p-4" role="presentation"><div ref={confirmRef} role="alertdialog" aria-modal="true" aria-labelledby="revert-revision-title" aria-describedby="revert-revision-description" className="w-full max-w-md rounded border border-line bg-panel p-5 shadow-2xl"><h2 id="revert-revision-title" className="font-semibold">Revert learned change?</h2><p id="revert-revision-description" className="mt-2 text-sm leading-relaxed text-ink-dim">Restore the previous {REVISION_NOUN_BY_TARGET[confirmRevision.targetType]} for <span className="font-mono text-ink">{confirmRevision.targetKey}</span>. Base agent instructions and capabilities are not changed.</p><div className="mt-5 flex justify-end gap-3"><button ref={cancelRef} type="button" disabled={Boolean(reverting)} onClick={() => setConfirmRevision(undefined)} className="min-h-11 rounded border border-line px-4 text-sm disabled:opacity-50">Cancel</button><button type="button" disabled={Boolean(reverting)} onClick={() => void revert()} className="min-h-11 rounded bg-amber-600 px-4 text-sm font-medium text-white disabled:opacity-50">{reverting ? "Reverting…" : "Revert change"}</button></div></div></div> : null}
</div>;
}