返回 DeepSeek-Reasonix
SessionRecoveryBanner.tsx
根目录 / desktop / frontend / src / components / SessionRecoveryBanner.tsx
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
69 lines Plain Text