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