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