| 1 | import { CloudOff, Loader2, TriangleAlert } from "lucide-react"; |
| 2 | import { useEffect, useId, useRef, useState } from "react"; |
| 3 | import { useT } from "../lib/i18n"; |
| 4 | import type { SessionAvailability } from "../lib/sessionAvailability"; |
| 5 | |
| 6 | /** A persistent status region, outside the collapsible transcript. Key by session identity. */ |
| 7 | export function SessionRecoveryBanner({ availability, onRetry }: { |
| 8 | availability: SessionAvailability; |
| 9 | onRetry?: () => Promise<unknown>; |
| 10 | }) { |
| 11 | const t = useT(); |
| 12 | const detailId = useId(); |
| 13 | const mounted = useRef(false); |
| 14 | const inFlight = useRef(false); |
| 15 | const [busy, setBusy] = useState(false); |
| 16 | const [actionError, setActionError] = useState(""); |
| 17 | const [expanded, setExpanded] = useState(false); |
| 18 | useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); |
| 19 | useEffect(() => { setActionError(""); setExpanded(false); }, [availability.kind, availability.detail]); |
| 20 | const retry = async () => { |
| 21 | if (!onRetry || inFlight.current) return; |
| 22 | inFlight.current = true; |
| 23 | setBusy(true); |
| 24 | setActionError(""); |
| 25 | try { |
| 26 | await onRetry(); |
| 27 | } catch (error) { |
| 28 | if (mounted.current) setActionError(error instanceof Error ? error.message : String(error)); |
| 29 | } finally { |
| 30 | inFlight.current = false; |
| 31 | if (mounted.current) setBusy(false); |
| 32 | } |
| 33 | }; |
| 34 | // Runtime setup errors already have their own startup/lease recovery controls. |
| 35 | if (availability.kind === "ready" || availability.source === "runtime") return null; |
| 36 | const loading = busy || availability.kind === "loading"; |
| 37 | const connection = availability.source === "connection"; |
| 38 | const detail = actionError || availability.detail; |
| 39 | const Icon = loading ? Loader2 : connection ? CloudOff : TriangleAlert; |
| 40 | return ( |
| 41 | <section id={`reasonix-session-recovery-${detailId}`} className={`session-recovery${loading ? " session-recovery--loading" : ""}`} role={loading ? "status" : "alert"} aria-busy={loading}> |
| 42 | <Icon size={28} aria-hidden="true" className={loading ? "session-recovery__spinner" : undefined} /> |
| 43 | <div className="session-recovery__copy"> |
| 44 | <strong>{t(loading ? connection ? "remoteSurface.connecting" : "sessionRecovery.loadingHistory" |
| 45 | : connection ? "sessionRecovery.connectionLost" : "sessionRecovery.historyFailed")}</strong> |
| 46 | <span>{t(actionError ? "sessionRecovery.retryFailed" : connection ? "sessionRecovery.connectionHint" : "sessionRecovery.historyHint")}</span> |
| 47 | </div> |
| 48 | {onRetry && <button type="button" className="btn btn--primary btn--small" disabled={loading} onClick={() => void retry()}> |
| 49 | {t(loading ? "common.loading" : connection ? "remoteSurface.reconnect" : "sessionRecovery.retryHistory")} |
| 50 | </button>} |
| 51 | {detail && <button type="button" className="btn btn--ghost btn--small" aria-expanded={expanded} aria-controls={detailId} onClick={() => setExpanded(value => !value)}> |
| 52 | {t("sessionRecovery.details")} |
| 53 | </button>} |
| 54 | {detail && expanded && <pre className="session-recovery__detail" id={detailId}>{detail}</pre>} |
| 55 | </section> |
| 56 | ); |
| 57 | } |
| 58 | |
| 59 | export function SessionRecoveryPlaceholder({ availability }: { availability: SessionAvailability }) { |
| 60 | const t = useT(); |
| 61 | if (availability.kind === "pending") return null; |
| 62 | const loading = availability.kind === "loading"; |
| 63 | const Icon = loading ? Loader2 : availability.source === "connection" ? CloudOff : TriangleAlert; |
| 64 | return <div className="session-recovery-placeholder"> |
| 65 | <Icon size={30} aria-hidden="true" className={loading ? "session-recovery__spinner" : undefined} /> |
| 66 | <span>{t(loading ? "common.loading" : "sessionRecovery.contentAfterRecovery")}</span> |
| 67 | </div>; |
| 68 | } |
| 69 |