"use client"; import { createContext, useContext, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import type { ChatGPTStatus } from "@/src/core"; import { useBackdropDismiss } from "@/components/backdrop-dismiss"; import { Button } from "@/components/ui/button"; import { Toggle } from "./controls"; export const CHATGPT_NOTICE = "LearnHarness stores your ChatGPT account details and encrypted credentials locally on this device. When this connection is used, relevant conversation context, learning material, and tool results are sent directly to OpenAI. ChatGPT plan limits apply; any additional credits are authorized and managed in ChatGPT, not LearnHarness."; export const CHATGPT_BACKGROUND_HINT = "Background work uses your ChatGPT allowance."; const REVOCATION_WARNING = "Signed out locally. Remote revocation was not confirmed; disconnect LearnHarness in ChatGPT Settings."; const empty: ChatGPTStatus = { available: false, pending: false, activeId: null, accounts: [], error: null }; type Account = ChatGPTStatus["accounts"][number]; type Dialog = { kind: "connect" } | { kind: "manage"; accountId: string } | { kind: "warning" }; const Context = createContext({ status: empty, busy: false, error: null as string | null, warning: null as string | null, connect: () => {}, manage: (_id: string) => {}, select: (_id: string) => {}, dismissWarning: () => {}, }); /** UI lifetime spans setup's keys → models transition. Only existing safe controls * are used; account IDs remain control values, never displayed account labels. */ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { const [status, setStatus] = useState(empty); const [dialog, setDialog] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [dismissedError, setDismissedError] = useState(null); const [background, setBackground] = useState(false); const [registration, setRegistration] = useState(""); const operation = useRef<"login" | "change" | "logout" | null>(null); const generation = useRef(0); const statusSequence = useRef(0); const mounted = useRef(false); function publishStatus(next: ChatGPTStatus) { statusSequence.current++; setStatus(next); } useEffect(() => { mounted.current = true; const bridge = window.lh?.chatgpt; const refresh = async () => { const request = ++statusSequence.current; try { const next = await bridge?.status(); if (next && mounted.current && request === statusSequence.current) setStatus(next); } catch { if (mounted.current) setError("The desktop connection is unavailable. Retry when the app is ready."); } }; void refresh(); const off = bridge?.onChange(() => void refresh()); return () => { mounted.current = false; generation.current++; off?.(); }; }, []); const managed = dialog?.kind === "manage" ? status.accounts.find((a) => a.id === dialog.accountId && a.connected) : undefined; const visibleError = error || (status.error !== dismissedError ? status.error : null) || status.catalogError || null; const warning = visibleError === REVOCATION_WARNING ? visibleError : null; function openConnect() { setBackground(false); setRegistration(""); setError(null); setDismissedError(status.error); setDialog({ kind: "connect" }); } function close() { if (operation.current === "logout" || operation.current === "change") return; generation.current++; setDialog(null); setError(null); setDismissedError(status.error); if (operation.current === "login") { // Closing an in-flight sign-in cancels it and never applies the draft consent. void window.lh?.chatgpt?.cancel().then((next) => { if (mounted.current) { publishStatus(next); setDismissedError(next.error); } }).catch(() => {}); } } async function control(kind: "change" | "logout", action: () => Promise) { if (operation.current) return; operation.current = kind; setBusy(true); setError(null); setDismissedError(null); try { const next = await action(); if (!mounted.current) return; publishStatus(next); if (kind === "logout" && !next.accounts.find((a) => a.id === status.activeId && a.connected)) { setDialog(next.error === REVOCATION_WARNING ? { kind: "warning" } : null); } else setError(next.error); } catch { if (mounted.current) setError("The ChatGPT connection could not be changed. Try again."); } finally { operation.current = null; if (mounted.current) setBusy(false); } } async function login() { const bridge = window.lh?.chatgpt; if (!bridge || operation.current || !status.available) return; const attempt = ++generation.current; const allowBackground = background; operation.current = "login"; setBusy(true); setError(null); setDismissedError(null); try { let next = await bridge.login(registration || undefined); if (!mounted.current || attempt !== generation.current) return; publishStatus(next); const account = next.accounts.find((a) => a.id === next.activeId && a.connected); if (account && !next.error) { // Explicit initial choice is applied only after verified successful login. // Choosing OFF also revokes any prior consent on a returning registration. if (account.background !== allowBackground) { operation.current = "change"; next = await bridge.consent(account.id, allowBackground); } if (!mounted.current || attempt !== generation.current) return; publishStatus(next); if (!next.error) setDialog(null); } setError(next.error); } catch { if (mounted.current && attempt === generation.current) setError("The ChatGPT connection could not be changed. Try again."); } finally { operation.current = null; if (mounted.current) setBusy(false); } } const bridge = typeof window !== "undefined" ? window.lh?.chatgpt : undefined; const context = { status, busy, error: warning ? null : visibleError, warning: dialog?.kind === "warning" ? null : warning, connect: openConnect, manage: (id: string) => { setError(null); setDialog({ kind: "manage", accountId: id }); }, select: (id: string) => { if (bridge) void control("change", () => bridge.select(id)); }, dismissWarning: () => { setDismissedError(REVOCATION_WARNING); setError(null); }, }; return {children} {dialog &&

{dialog.kind === "connect" ? "Continue with ChatGPT" : "ChatGPT account"}

{dialog.kind === "warning" ? <>

{REVOCATION_WARNING}

: <> {dialog.kind === "connect" && status.accounts.length > 0 && } {managed &&

{managed.email || "ChatGPT account"}

}

{CHATGPT_NOTICE}

{(dialog.kind === "connect" || managed) &&
{ if (dialog.kind === "connect") setBackground(enabled); else if (managed && bridge) void control("change", () => bridge.consent(managed.id, enabled)); }} /> Allow background use{CHATGPT_BACKGROUND_HINT}
} {!status.available &&

ChatGPT sign-in is available only in the desktop app with secure OS credential storage. API-key connections remain available here.

} {status.pending &&

Waiting for browser sign-in…

} {context.error &&

{context.error}

}
{dialog.kind === "connect" && void login()} disabled={!status.available || busy || status.pending} testid="chatgpt-proceed" />} {managed && }
}
}
; } function ContinueButton({ onClick, disabled, testid = "chatgpt-login" }: { onClick: () => void; disabled: boolean; testid?: string }) { return ; } function AccountDialog({ children, onClose, busy }: { children: React.ReactNode; onClose: () => void; busy: boolean }) { const ref = useRef(null); const closeRef = useRef(onClose); closeRef.current = onClose; const backdropDismiss = useBackdropDismiss(onClose); useEffect(() => { const previous = document.activeElement as HTMLElement | null; const focusable = () => [...(ref.current?.querySelectorAll('button:not([disabled]), a[href], input:not([disabled]), select:not([disabled])') ?? [])]; focusable()[0]?.focus(); const key = (event: KeyboardEvent) => { if (event.key === "Escape") { event.preventDefault(); event.stopImmediatePropagation(); closeRef.current(); } else if (event.key === "Tab") { const items = focusable(), first = items[0], last = items.at(-1); if (!first) { event.preventDefault(); return; } if (event.shiftKey && (document.activeElement === first || !ref.current?.contains(document.activeElement))) { event.preventDefault(); last?.focus(); } else if (!event.shiftKey && (document.activeElement === last || !ref.current?.contains(document.activeElement))) { event.preventDefault(); first.focus(); } } }; // Capture claims the nested dialog's Escape before outer Settings/setup. document.addEventListener("keydown", key, true); return () => { document.removeEventListener("keydown", key, true); if (previous?.isConnected) previous.focus(); }; }, []); return createPortal(
event.stopPropagation()} className="settings-dialog-pop mx-4 max-h-[85vh] w-full max-w-lg overflow-y-auto rounded-md border border-border bg-card p-5 shadow-xl">{children}
, document.body); } /** Compact settings row, or setup's secondary action only. */ export function ChatGPTConnection({ setup = false }: { setup?: boolean }) { const ui = useContext(Context); const active = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected); const accounts = ui.status.accounts.filter((a) => a.connected); return
{!setup && ChatGPT} {active && !setup ? <> : }
{ui.warning &&
{ui.warning}
}
; } /** Contextual consent discovery belongs beside background model choices only. */ export function ChatGPTBackgroundAccess({ provider }: { provider: string | undefined }) { const ui = useContext(Context); const account: Account | undefined = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected); if (provider !== "chatgpt" || !account || account.background) return null; return ; }