import { useCallback, useEffect, useRef, useState } from 'react' import { Button } from '@/components/ui/button' import { getMemoryProviderOAuthStatus, startMemoryProviderOAuth } from '@/hermes' import { useI18n } from '@/i18n' import { Check, ExternalLink, Loader2 } from '@/lib/icons' import { notifyError } from '@/store/notifications' import type { MemoryProviderOAuthStatus } from '@/types/hermes' const POLL_MS = 1500 const POLL_TIMEOUT_MS = 120_000 // Small connect affordance rendered under the provider dropdown. Capability is // backend-driven: the status route 404s for providers without an oauth_flow // module, so non-OAuth providers render nothing. export function MemoryConnect({ profile, provider }: { profile?: string; provider: string }) { const { t } = useI18n() const copy = t.settings.runtime.memory const [capable, setCapable] = useState<'no' | 'unknown' | 'yes'>('unknown') const [connected, setConnected] = useState(false) const [auth, setAuth] = useState(null) const [phase, setPhase] = useState<'error' | 'idle' | 'pending'>('idle') const [detail, setDetail] = useState('') const timer = useRef | null>(null) const deadline = useRef(0) const stop = useCallback(() => { if (timer.current !== null) { clearInterval(timer.current) timer.current = null } }, []) useEffect(() => { let active = true setCapable('unknown') getMemoryProviderOAuthStatus(provider, profile) .then(s => { if (!active) { return } setCapable('yes') setConnected(s.connected) setAuth(s.auth) }) .catch(() => { if (active) { setCapable('no') } }) return () => { active = false stop() } }, [profile, provider, stop]) // An error message isn't sticky — it clears back to the steady state // (Connect link, plus the connected badge if a credential is stored). useEffect(() => { if (phase !== 'error') { return } const t = setTimeout(() => { setPhase('idle') setDetail('') }, 6000) return () => clearTimeout(t) }, [phase]) const connect = useCallback(async () => { setPhase('pending') try { await startMemoryProviderOAuth(provider, profile) } catch (err) { setPhase('error') setDetail(copy.startFailed) notifyError(err, copy.startFailed) return } deadline.current = Date.now() + POLL_TIMEOUT_MS stop() timer.current = setInterval(() => { void (async () => { try { const next = await getMemoryProviderOAuthStatus(provider, profile) if (next.state === 'pending') { if (Date.now() > deadline.current) { stop() setPhase('error') setDetail(copy.timeout) } return } stop() setConnected(next.connected) setAuth(next.auth) if (next.state === 'error') { setPhase('error') setDetail(next.detail || copy.connectionFailed) } else { setPhase('idle') } } catch { // Transient poll failure — keep trying until the deadline. } })() }, POLL_MS) }, [profile, provider, stop, copy]) const cancel = useCallback(() => { stop() setPhase('idle') }, [stop]) if (capable !== 'yes') { return null } const connectLabel = connected ? (auth === 'apikey' ? copy.connectOauth : copy.reconnect) : copy.connect return ( {phase === 'idle' && connected && ( {auth === 'apikey' ? copy.apiKeySet : copy.oauthSet} )} {phase === 'pending' ? ( <> {copy.waiting} ) : ( )} {phase === 'error' && detail && {detail}} ) }