import { useEffect, useRef, useState } from "react"; import { useSessionStore } from "../store/sessionStore"; import { resolve } from "../widgets"; import { postResponse } from "../api/sessions"; import type { UiResponse } from "../api/types"; import { ErrorBoundary } from "../components/ErrorBoundary"; import { captureAuthOperation, isAuthOperationCurrent } from "../auth/authOperation"; export function WidgetHost({ sessionId }: { sessionId: string | null }) { const pending = useSessionStore((s) => s.pendingWidget); const clearPending = useSessionStore((s) => s.clearPending); const pushToast = useSessionStore((s) => s.pushToast); const setLastUserEntry = useSessionStore((s) => s.setLastUserEntry); const [responding, setResponding] = useState(false); const responseInFlight = useRef(false); const operationEpochRef = useRef(0); useEffect(() => () => { operationEpochRef.current += 1; }, []); useEffect(() => { // A gate belongs to its active session target, not merely the authenticated user. operationEpochRef.current += 1; responseInFlight.current = false; setResponding(false); }, [sessionId]); if (!pending) return null; const Renderer = resolve(pending.widget); const onRespond = async (r: UiResponse) => { if (responseInFlight.current) return; const operation = captureAuthOperation({ sessionId, disposalEpoch: operationEpochRef.current }); if (!operation) return; responseInFlight.current = true; setResponding(true); if (sessionId) { try { await postResponse(sessionId, r); if (!isAuthOperationCurrent(operation, { sessionId, disposalEpoch: operationEpochRef.current })) return; } catch (err) { if (!isAuthOperationCurrent(operation, { sessionId, disposalEpoch: operationEpochRef.current })) return; // Surface feedback and keep the widget pending so the user can retry. pushToast({ level: "error", text: err instanceof Error ? `Failed to send response: ${err.message}` : "Failed to send response.", }); responseInFlight.current = false; setResponding(false); return; } } if (!isAuthOperationCurrent(operation, { sessionId, disposalEpoch: operationEpochRef.current })) return; responseInFlight.current = false; setResponding(false); setLastUserEntry({ kind: "choice", text: r.kind === "schema-linking" ? `${r.tables?.filter((t) => t.enacted).length ?? 0} tables curated` : r.text ?? r.choices?.join(", ") ?? r.decision?.type ?? r.control ?? "(choice)", }); clearPending(); }; // A malformed gate payload must not white-screen the whole app: isolate the // widget render. resetKeys on the descriptor id so the next gate starts clean. return (
{responding && (

Sending response…

)}
); }