返回 DeepSeek-Reasonix
SessionTakeoverDialog.tsx
根目录 / desktop / frontend / src / components / SessionTakeoverDialog.tsx
1 import { ErrorMessage } from "./ErrorMessage";
2 import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
3 import { createPortal } from "react-dom";
4 import { useT } from "../lib/i18n";
5 import { app } from "../lib/bridge";
6 import type { SessionTakeoverView, TabMeta } from "../lib/types";
7 import type { HistoricalSourceUpdateView, SessionPreparationView } from "../generated/desktopContract.generated";
8 import { historicalPreparationSnapshot, reconcileHistoricalPreparation, subscribeHistoricalPreparation, type DesktopNavigationIntent } from "../app-runtime/desktopNavigationOwner";
9 import { historicalFailureKey, useManagementT } from "../lib/managementLocale";
10
11 /**
12 * SessionTakeoverDialog confirms taking a lease-blocked session over from the
13 * resident serve on this machine. The remote tab keeps watching through the
14 * frame mirror and drops to read-only; when it reclaims, this window demotes
15 * itself the same way.
16 */
17 export function SessionTakeoverDialog({ tabId, onClose }: { tabId: string; onClose: () => void }) {
18 const t = useT();
19 const titleId = useId();
20 const messageId = useId();
21 const cancelRef = useRef<HTMLButtonElement>(null);
22 const restoreFocusRef = useRef<HTMLElement | null>(null);
23 const [view, setView] = useState<SessionTakeoverView | null>(null);
24 const [queryError, setQueryError] = useState("");
25 const [actionError, setActionError] = useState("");
26 const [busyMode, setBusyMode] = useState<"wait" | "interrupt" | null>(null);
27
28 useLayoutEffect(() => {
29 restoreFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
30 cancelRef.current?.focus();
31 return () => {
32 if (restoreFocusRef.current?.isConnected) restoreFocusRef.current.focus();
33 };
34 }, []);
35
36 useEffect(() => {
37 let cancelled = false;
38 app.QuerySessionTakeover(tabId)
39 .then((result) => {
40 if (!cancelled) setView(result);
41 })
42 .catch((error) => {
43 if (!cancelled) setQueryError(error instanceof Error ? error.message : String(error));
44 });
45 return () => {
46 cancelled = true;
47 };
48 }, [tabId]);
49
50 useEffect(() => {
51 const onKeyDown = (event: KeyboardEvent) => {
52 if (event.key === "Escape") {
53 event.preventDefault();
54 event.stopPropagation();
55 if (!busyMode) onClose();
56 }
57 };
58 document.addEventListener("keydown", onKeyDown, { capture: true });
59 return () => document.removeEventListener("keydown", onKeyDown, { capture: true });
60 }, [busyMode, onClose]);
61
62 const take = (mode: "wait" | "interrupt") => {
63 if (busyMode) return;
64 setBusyMode(mode);
65 setActionError("");
66 app.TakeoverSession(tabId, mode)
67 .then(onClose)
68 .catch((error) => {
69 setActionError(error instanceof Error ? error.message : String(error));
70 setBusyMode(null);
71 });
72 };
73
74 const busy = busyMode !== null;
75 let body: React.ReactNode;
76 if (queryError) {
77 body = <span className="reasonix-confirm-dialog__message-error"><ErrorMessage error={t("takeover.unavailable", { reason: queryError })} /></span>;
78 } else if (!view) {
79 body = <span>{t("takeover.querying")}</span>;
80 } else if (!view.available) {
81 body = <span>{t("takeover.unavailable", { reason: view.reason || t("takeover.noHolder") })}</span>;
82 } else {
83 body = (
84 <>
85 <span>{t("takeover.descRemote")}</span>
86 <span className="session-takeover-dialog__state">
87 {view.running ? t("takeover.running") : t("takeover.idle")}
88 </span>
89 </>
90 );
91 }
92 const canTake = !queryError && view?.available === true;
93
94 return createPortal(
95 <div
96 data-app-overlay=""
97 className="modal-backdrop reasonix-confirm-backdrop"
98 role="presentation"
99 onMouseDown={(event) => {
100 if (event.target === event.currentTarget && !busy) onClose();
101 }}
102 >
103 <div className="modal reasonix-confirm-dialog session-takeover-dialog" role="dialog" aria-modal="true" aria-labelledby={titleId} aria-describedby={messageId}>
104 <div className="modal__title reasonix-confirm-dialog__title" id={titleId}>{t("takeover.title")}</div>
105 <div className="reasonix-confirm-dialog__message" id={messageId}>
106 {body}
107 {actionError ? <span className="reasonix-confirm-dialog__message-error"><ErrorMessage error={actionError} /></span> : null}
108 </div>
109 <div className="modal__actions reasonix-confirm-dialog__actions">
110 <button ref={cancelRef} className="btn btn--small" type="button" disabled={busy} onClick={onClose}>
111 {t("takeover.cancel")}
112 </button>
113 {canTake ? (
114 <button className="btn btn--small btn--primary" type="button" disabled={busy} onClick={() => take("wait")}>
115 {busyMode === "wait" ? t("takeover.busy") : view?.running ? t("takeover.takeWait") : t("takeover.takeIdle")}
116 </button>
117 ) : null}
118 {canTake && view?.running ? (
119 <button className="btn btn--small btn--danger" type="button" disabled={busy} onClick={() => take("interrupt")}>
120 {busyMode === "interrupt" ? t("takeover.busy") : t("takeover.takeInterrupt")}
121 </button>
122 ) : null}
123 </div>
124 </div>
125 </div>,
126 document.body,
127 );
128 }
129
130 const terminalPreparation = new Set(["ready", "blocked", "failed", "cancelled"]);
131 export type HistoricalSessionBannerProps = {
132 tab?: TabMeta;
133 navigate(intent: DesktopNavigationIntent): Promise<void>;
134 captureNavigation?(): () => boolean;
135 openRecoveryDetails?(): void;
136 };
137 type HistoricalFailure = { summary: string; detail?: unknown };
138 export function HistoricalSessionBanners({ tab, navigate, captureNavigation, openRecoveryDetails }: HistoricalSessionBannerProps) {
139 const t = useT();
140 const m = useManagementT();
141 const activeRef = tab?.session ?? (tab?.sessionId ? { hostId: "local", sessionId: tab.sessionId } : undefined);
142 const preparation = useSyncExternalStore(subscribeHistoricalPreparation, historicalPreparationSnapshot);
143 const [update, setUpdate] = useState<HistoricalSourceUpdateView | null>(null);
144 const [busy, setBusy] = useState(false);
145 const [updateError, setUpdateError] = useState<HistoricalFailure | null>(null);
146 const updateOperation = useRef(0);
147 const [cancellingOperationId, setCancellingOperationId] = useState("");
148 const cancellingOperationRef = useRef("");
149 const activeHostId = activeRef?.hostId ?? "";
150 const activeSessionId = activeRef?.sessionId ?? "";
151 const historicalSource = tab?.historicalSource;
152 const activeKey = activeSessionId ? `${activeHostId}:${activeSessionId}`
153 : historicalSource ? `source:${tab?.id}:${historicalSource.sourceKey || historicalSource.path}` : "";
154 const activeKeyRef = useRef(activeKey);
155 activeKeyRef.current = activeKey;
156 const mounted = useRef(true);
157 useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
158
159 useEffect(() => {
160 let current = true;
161 updateOperation.current++;
162 setUpdate(null);
163 setUpdateError(null);
164 setBusy(false);
165 if (!activeSessionId || activeHostId !== "local" || !app.CheckHistoricalSourceUpdate) return () => { current = false; };
166 const ref = { hostId: activeHostId, sessionId: activeSessionId };
167 const run = async () => {
168 let next = await app.CheckHistoricalSourceUpdate!({ ref });
169 for (let attempt = 0; current && next.status === "checking" && attempt < 60; attempt++) {
170 await new Promise(resolve => setTimeout(resolve, 500));
171 if (current) next = await app.CheckHistoricalSourceUpdate!({ ref });
172 }
173 if (!current || next.status !== "available" || !next.version || !next.source) return;
174 try {
175 if (localStorage.getItem(`historical-source-update:${next.sourceKey}`) === next.version) return;
176 } catch { /* private storage can be unavailable */ }
177 setUpdate(next);
178 };
179 void run().catch(() => {});
180 return () => { current = false; };
181 }, [activeHostId, activeSessionId, activeKey]);
182
183 const dismissUpdate = () => {
184 if (update?.version) {
185 try { localStorage.setItem(`historical-source-update:${update.sourceKey}`, update.version); } catch { /* best effort */ }
186 }
187 setUpdate(null);
188 };
189 const importUpdate = () => {
190 if (!update?.source || !update.version || !app.PrepareHistoricalSourceVersion) return;
191 const { source, version } = update;
192 return runPreparation(() => app.PrepareHistoricalSourceVersion!(source, version));
193 };
194 const importSource = () => {
195 if (!historicalSource || !app.PrepareSession) return;
196 return runPreparation(() => app.PrepareSession!({ source: historicalSource }));
197 };
198 const runPreparation = async (start: () => Promise<SessionPreparationView>) => {
199 if (busy || !app.GetSessionPreparation) return;
200 const expectedActive = activeKey;
201 const operation = ++updateOperation.current;
202 const navigationCurrent = captureNavigation?.() ?? (() => activeKeyRef.current === expectedActive);
203 const current = () => mounted.current && operation === updateOperation.current && activeKeyRef.current === expectedActive && navigationCurrent();
204 setBusy(true);
205 setUpdateError(null);
206 try {
207 let view = await start();
208 while (current() && !terminalPreparation.has(view.status)) {
209 await new Promise(resolve => setTimeout(resolve, 300));
210 if (!current()) return;
211 view = await app.GetSessionPreparation(view.operationId);
212 }
213 if (!current()) return;
214 if (view.status !== "ready" || !view.target) {
215 setUpdateError({ summary: m(historicalFailureKey(view.errorCode)), detail: view.errorDetail });
216 return;
217 }
218 try {
219 await navigate({ kind: "canonical-session", ref: view.target });
220 } catch (error) {
221 if (current()) setUpdateError({ summary: m("historicalOpenFailed"), detail: error });
222 }
223 } catch (error) {
224 if (current()) setUpdateError({ summary: m("historicalImportFailed"), detail: error });
225 }
226 finally { if (mounted.current && operation === updateOperation.current) setBusy(false); }
227 };
228 const cancelPreparation = async () => {
229 if (!preparation || !app.CancelSessionPreparation || cancellingOperationRef.current === preparation.operationId) return;
230 const operationId = preparation.operationId;
231 cancellingOperationRef.current = operationId;
232 setCancellingOperationId(operationId);
233 try {
234 const view = await app.CancelSessionPreparation(operationId);
235 if (mounted.current) reconcileHistoricalPreparation(preparation, view);
236 } catch { /* The preparation poll remains the authority after a failed cancellation request. */ }
237 finally {
238 if (cancellingOperationRef.current === operationId) cancellingOperationRef.current = "";
239 if (mounted.current) setCancellingOperationId(current => current === operationId ? "" : current);
240 }
241 };
242
243 const failureNotice = (failure: HistoricalFailure) => <ErrorMessage error={failure.detail ?? ""} summary={failure.summary} />;
244 const recoveryDetails = openRecoveryDetails
245 ? <button type="button" className="btn btn--small btn--ghost" onClick={openRecoveryDetails}>{m("historicalRecoveryDetails")}</button> : null;
246 if (preparation) {
247 const waiting = preparation.status === "queued" || preparation.status === "preparing";
248 return <div className={`banner ${waiting ? "banner--warning" : "banner--error"} banner--actionable`} role="status">
249 <span className="banner__msg">{m("historicalImporting")}: {preparation.session.title || preparation.session.topicId || m("historicalTitle")}</span>
250 <span className="banner__hint">{waiting ? m(preparation.status === "queued" ? "historicalQueued" : "historicalImporting")
251 : failureNotice({ summary: m(historicalFailureKey(preparation.errorCode)), detail: preparation.errorDetail })}</span>
252 <span className="banner__spacer" />
253 {waiting && <button type="button" className="btn btn--small" disabled={cancellingOperationId === preparation.operationId} onClick={() => void cancelPreparation()}>{t("common.cancel")}</button>}
254 {!waiting && recoveryDetails}
255 {!waiting && preparation.retryable && <button type="button" className="btn btn--small" onClick={() => void navigate({ kind: "resume-session", session: preparation.session })}>{t("common.retry")}</button>}
256 </div>;
257 }
258 if (historicalSource) return <div className={`banner ${updateError ? "banner--error" : "banner--warning"} banner--actionable`} role={updateError ? "alert" : "status"}>
259 <span className="banner__msg">{tab?.topicTitle || m("historicalTitle")} · {m("historicalAvailable")}</span>
260 <span className="banner__hint">{updateError ? failureNotice(updateError) : m(busy ? "historicalImporting" : "historicalImportToSend")}</span>
261 <span className="banner__spacer" />
262 {updateError && recoveryDetails}
263 <button id="reasonix-prepare-restored-session" type="button" className="btn btn--small" disabled={busy} onClick={() => void importSource()}>{m("historicalImportOpen")}</button>
264 </div>;
265 if (!update) return null;
266 return <div className={`banner ${updateError ? "banner--error" : "banner--warning"} banner--actionable`} role={updateError ? "alert" : "status"}>
267 <span className="banner__msg">{m("historicalSourceUpdated")}</span>
268 {(updateError || busy) && <span className="banner__hint">{updateError ? failureNotice(updateError) : m("historicalImporting")}</span>}
269 <span className="banner__spacer" />
270 {updateError && recoveryDetails}
271 <button type="button" className="btn btn--small" disabled={busy} onClick={() => void importUpdate()}>{m("historicalImportOpen")} · {m("branch")}</button>
272 <button type="button" className="btn btn--small" disabled={busy} onClick={dismissUpdate}>{t("updater.dismiss")}</button>
273 </div>;
274 }
275
276 export function SessionRuntimeOverlays({ takeoverTabId, onCloseTakeover, historical }: {
277 takeoverTabId: string | null;
278 onCloseTakeover(): void;
279 historical?: HistoricalSessionBannerProps;
280 }) {
281 return <>
282 {takeoverTabId ? <SessionTakeoverDialog tabId={takeoverTabId} onClose={onCloseTakeover} /> : null}
283 {historical ? <HistoricalSessionBanners {...historical} /> : null}
284 </>;
285 }
286
286 lines Plain Text