| 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 |