Files
ThothII/frontend/src/shell/WidgetHost.tsx
T

81 lines
3.3 KiB
TypeScript

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 (
<ErrorBoundary resetKeys={[pending.id]} label="step">
<div className="space-y-2">
<fieldset disabled={responding} className="min-w-0 border-0 p-0">
<Renderer descriptor={pending} onRespond={onRespond} sessionId={sessionId ?? undefined} />
</fieldset>
{responding && (
<p role="status" aria-live="polite" className="text-sm text-muted-foreground">
Sending response…
</p>
)}
</div>
</ErrorBoundary>
);
}