diff --git a/frontend/src/app/pages/Settings/sections/subscription/subscriptionConnect.ts b/frontend/src/app/pages/Settings/sections/subscription/subscriptionConnect.ts index 4d7a822e..c63447c8 100644 --- a/frontend/src/app/pages/Settings/sections/subscription/subscriptionConnect.ts +++ b/frontend/src/app/pages/Settings/sections/subscription/subscriptionConnect.ts @@ -143,16 +143,24 @@ function runAuthCodeFlow(ctx: ConnectCtx) { popup = window.open(data.auth_url, 'oauth_connect', 'width=600,height=700'); } + let stopped = false; + let resetTimer: ReturnType | null = null; + // Status polling: primary for external-browser flow, secondary for popup flow (postMessage is faster). const statusPoller = setInterval(async () => { + if (stopped) return; try { const sr = await fetch(`${API_BASE}/agents/subscriptions/status`); const sd = await sr.json(); const connections = sd.providers?.connections || []; if (connections.some((p: any) => p.provider === providerId && (p.isActive || p.testStatus === 'active'))) { + stopped = true; + if (resetTimer) clearTimeout(resetTimer); clearInterval(statusPoller); setPollTimer(null); if (!useExternal) window.removeEventListener('message', msgHandler); + window.removeEventListener('blur', onBlur); + window.removeEventListener('focus', onFocus); setConnecting(null); fetchStatus(); refreshPickerModels(); @@ -164,10 +172,14 @@ function runAuthCodeFlow(ctx: ConnectCtx) { // Shared exchange helper invoked by whichever relay path delivers the code first. let exchanged = false; const runExchange = async (code: string, state?: string) => { - if (exchanged) return; + if (exchanged || stopped) return; exchanged = true; + stopped = true; + if (resetTimer) clearTimeout(resetTimer); window.removeEventListener('message', msgHandler); if (ipcUnsub) ipcUnsub(); + window.removeEventListener('blur', onBlur); + window.removeEventListener('focus', onFocus); clearInterval(statusPoller); setPollTimer(null); if (popup && !popup.closed) popup.close(); @@ -208,13 +220,55 @@ function runAuthCodeFlow(ctx: ConnectCtx) { }); } + // If the user comes back to openswarm without finishing OAuth (closed the browser, cancelled), + // 3s of sustained focus + no active connection means abandoned; clear Connecting so they can retry. + // A blur during the wait cancels, so brief tab-backs to check progress don't false-positive. + const onBlur = () => { + if (resetTimer) { clearTimeout(resetTimer); resetTimer = null; } + }; + const onFocus = () => { + if (stopped) return; + if (resetTimer) clearTimeout(resetTimer); + resetTimer = setTimeout(async () => { + resetTimer = null; + if (stopped) return; + try { + const sr = await fetch(`${API_BASE}/agents/subscriptions/status`); + const sd = await sr.json(); + const conns = sd.providers?.connections || []; + if (conns.some((p: any) => p.provider === providerId && (p.isActive || p.testStatus === 'active'))) return; + } catch {} + if (stopped) return; + stopped = true; + clearInterval(statusPoller); + setPollTimer(null); + if (!useExternal) window.removeEventListener('message', msgHandler); + if (ipcUnsub) ipcUnsub(); + window.removeEventListener('blur', onBlur); + window.removeEventListener('focus', onFocus); + setConnecting(null); + }, 3000); + }; + // Delay attach; popup mode's window.open blurs/refocuses the parent and would false-trigger. + setTimeout(() => { + if (!stopped) { + window.addEventListener('blur', onBlur); + window.addEventListener('focus', onFocus); + } + }, 2000); + // 3min popup / 5min external-browser; bounds the Connecting indicator, safety-net poller is the real exit. const timeoutMs = useExternal ? 300_000 : 180_000; setTimeout(() => { + if (stopped) return; + stopped = true; + if (resetTimer) clearTimeout(resetTimer); clearInterval(statusPoller); setPollTimer(null); if (!useExternal) window.removeEventListener('message', msgHandler); if (ipcUnsub) ipcUnsub(); + window.removeEventListener('blur', onBlur); + window.removeEventListener('focus', onFocus); setConnecting(null); }, timeoutMs); }