import { useEffect, useState } from "react";
const BASE_URL = "https://api.praxa.io";
// Minimal SSE line reader for the v1 event stream: events are separated by
// a blank line, only "data:" lines carry payload, and a stray "[DONE]"
// closes the stream early. Mirrors the parsing rules used internally by
// @nexislabs/ai-platform.
async function* readEvents(response) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let dataLines = [];
const flush = () => {
if (dataLines.length === 0) return null;
const data = dataLines.join("\n");
dataLines = [];
return data === "[DONE]" ? null : JSON.parse(data);
};
while (true) {
const { value, done } = await reader.read();
buffer += decoder.decode(value, { stream: !done });
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() ?? "";
for (const line of lines) {
if (line === "") {
const event = flush();
if (event) yield event;
} else if (line.startsWith("data:")) {
dataLines.push(line.slice(5).replace(/^ /, ""));
}
}
if (done) break;
}
}
export function ApprovalGate({ runId, apiKey }) {
const [pending, setPending] = useState(null); // the approval.required event
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const [nowMs, setNowMs] = useState(Date.now());
useEffect(() => {
const id = setInterval(() => setNowMs(Date.now()), 1000);
return () => clearInterval(id);
}, []);
useEffect(() => {
const controller = new AbortController();
(async () => {
const response = await fetch(`${BASE_URL}/v1/runs/${runId}/events`, {
headers: { Authorization: `Bearer ${apiKey}`, Accept: "text/event-stream" },
signal: controller.signal,
});
if (!response.ok || !response.body) throw new Error(`stream failed (${response.status})`);
for await (const event of readEvents(response)) {
if (event.type === "approval.required") {
setPending(event);
} else if (event.type === "request.failed" && event.problem?.code === "approval_timed_out") {
setPending(null);
setError("The approval window expired before a decision was made.");
} else if (event.type === "response.final" || event.type === "request.cancelled") {
setPending(null);
}
}
})().catch((err) => {
if (err.name !== "AbortError") setError(err.message);
});
return () => controller.abort();
}, [runId, apiKey]);
async function decide(decision) {
if (!pending) return;
setBusy(true);
setError(null);
try {
const response = await fetch(`${BASE_URL}/v1/runs/${runId}/approve`, {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({ action_digest: pending.action_digest, decision }),
});
if (!response.ok) {
const problem = await response.json().catch(() => null);
throw new Error(problem?.detail ?? problem?.title ?? `approve failed (${response.status})`);
}
setPending(null); // the event stream reports what happens next
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
if (!pending) return error ? <p role="alert">{error}</p> : null;
const expiresInMs = pending.expiresAt ? new Date(pending.expiresAt).getTime() - nowMs : null;
const expired = expiresInMs !== null && expiresInMs <= 0;
return (
<div role="group" aria-label="Approval required">
<h3>Approval required</h3>
{/* The golden rule: this is the canonical payload — render it verbatim. */}
<pre style={{ whiteSpace: "pre-wrap", overflowY: "auto", maxHeight: "12rem" }}>
{pending.summary}
</pre>
{expiresInMs !== null && (
<p>
{expired
? "Expired — waiting for the run to update."
: `Expires in ${Math.max(0, Math.round(expiresInMs / 1000))}s`}
</p>
)}
<button onClick={() => decide("approved")} disabled={busy || expired}>
Approve
</button>
<button onClick={() => decide("denied")} disabled={busy || expired}>
Deny
</button>
{error && <p role="alert">{error}</p>}
</div>
);
}