diff --git a/components/settings/chatgpt-connection.tsx b/components/settings/chatgpt-connection.tsx index 2ad34a9..b6fdd9a 100644 --- a/components/settings/chatgpt-connection.tsx +++ b/components/settings/chatgpt-connection.tsx @@ -8,19 +8,21 @@ 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 }; +export const CHATGPT_BACKGROUND_HINT = "Background work counts toward your ChatGPT plan usage."; +const BACKGROUND_LABEL = "Allow background use for all ChatGPT accounts"; +const REVOCATION_WARNING = "Account disconnected locally. Remote revocation could not be confirmed."; +export const CHATGPT_DISCONNECT_COPY = { title: "Disconnect account?", body: "This removes the saved account and its credentials from this device." }; +const empty: ChatGPTStatus = { available: false, pending: false, allowBackgroundUse: true, activeId: null, accounts: [], error: null }; type Account = ChatGPTStatus["accounts"][number]; -type Dialog = { kind: "connect" } | { kind: "manage"; accountId: string } | { kind: "warning" }; -type LoginAttempt = { generation: number; dialog: Dialog; registration: string; background: boolean; pending: boolean; accountId?: string; phase: "starting" | "pending" | "consent" }; +type Dialog = { kind: "connect" } | { kind: "manage"; accountId: string; account: Account; confirm?: boolean } | { kind: "warning" }; +type LoginAttempt = { generation: number; dialog: Dialog; registration: string; background: boolean; backgroundDirty: boolean; pending: boolean; accountId?: string; phase: "starting" | "pending" | "consent" }; const CONTROL_ERROR = "The ChatGPT connection could not be changed. Try again."; function isStatus(value: unknown): value is ChatGPTStatus { if (!value || typeof value !== "object") return false; const s = value as ChatGPTStatus; - return typeof s.available === "boolean" && typeof s.pending === "boolean" && (s.activeId === null || typeof s.activeId === "string") && + return typeof s.available === "boolean" && typeof s.pending === "boolean" && typeof s.allowBackgroundUse === "boolean" && (s.activeId === null || typeof s.activeId === "string") && (s.error === null || typeof s.error === "string") && (s.catalogError == null || typeof s.catalogError === "string") && Array.isArray(s.accounts) && - s.accounts.every((a) => a && typeof a.id === "string" && typeof a.email === "string" && typeof a.registration === "string" && typeof a.connected === "boolean" && typeof a.background === "boolean" && (a.planEnabled === undefined || typeof a.planEnabled === "boolean")); + s.accounts.every((a) => a && typeof a.id === "string" && typeof a.email === "string" && typeof a.registration === "string" && typeof a.connected === "boolean" && (a.planEnabled === undefined || typeof a.planEnabled === "boolean")); } function replyError(value: unknown): string { return value && typeof value === "object" && "error" in value && typeof value.error === "string" ? value.error : CONTROL_ERROR; @@ -45,9 +47,10 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [dismissedError, setDismissedError] = useState(null); - const [background, setBackground] = useState(false); + const [background, setBackground] = useState(true); + const [backgroundDirty, setBackgroundDirty] = useState(false); const [registration, setRegistration] = useState(""); - const operation = useRef<"login" | "cancel" | "change" | "logout" | null>(null); + const operation = useRef<"login" | "cancel" | "change" | "disconnect" | null>(null); const generation = useRef(0); const statusSequence = useRef(0); const mounted = useRef(false); @@ -72,18 +75,28 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { finish(attempt); setError(next.error || CONTROL_ERROR); return; } attempt.phase = "consent"; attempt.accountId = account.id; + let replyFailure: string | null = null; try { - if (account.background !== attempt.background) { + if (attempt.backgroundDirty && next.allowBackgroundUse !== attempt.background) { operation.current = "change"; - const result: unknown = await window.lh!.chatgpt!.consent(account.id, attempt.background); + const request = ++statusSequence.current; + const result: unknown = await window.lh!.chatgpt!.consent(attempt.background); if (!owns(attempt)) return; - const saved = publishStatus(result); + if (!isStatus(result)) { setError(replyError(result)); finish(attempt); return; } + replyFailure = result.error; + let saved: ChatGPTStatus | null; + if (request !== statusSequence.current) { + const refresh = ++statusSequence.current; + const value: unknown = await window.lh!.chatgpt!.status(); + if (!owns(attempt)) return; + saved = refresh === statusSequence.current ? publishStatus(value) : statusRef.current; + } else saved = publishStatus(result); if (!saved) { finish(attempt); return; } next = saved; } if (!owns(attempt)) return; const current = next.accounts.find((a) => a.id === account.id && a.id === next.activeId && a.connected); - if (next.error || next.pending || !next.available || !current || current.background !== attempt.background) setError(next.error || CONTROL_ERROR); + if (replyFailure || next.error || next.pending || !next.available || !current || (attempt.backgroundDirty && next.allowBackgroundUse !== attempt.background)) setError(replyFailure || next.error || CONTROL_ERROR); else if (dialogRef.current === attempt.dialog) showDialog(null); } catch { if (owns(attempt)) setError(CONTROL_ERROR); } finally { finish(attempt); } @@ -121,19 +134,23 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { return () => { mounted.current = false; generation.current++; loginAttempt.current = null; off?.(); }; }, []); - const managed = dialog?.kind === "manage" ? status.accounts.find((a) => a.id === dialog.accountId && a.connected) : undefined; + const managed = dialog?.kind === "manage" ? status.accounts.find((a) => a.id === dialog.accountId) ?? dialog.account : undefined; + const managedPresent = !!managed && status.accounts.some((a) => a.id === managed.id); const visibleError = error || (status.error !== dismissedError ? status.error : null) || status.catalogError || null; const warning = visibleError === REVOCATION_WARNING ? visibleError : null; function openConnect() { if (operation.current) return; - setBackground(false); + setBackground(statusRef.current.allowBackgroundUse); + setBackgroundDirty(false); setRegistration(""); setError(null); setDismissedError(status.error); showDialog({ kind: "connect" }); } function close() { - if (operation.current === "logout" || operation.current === "change" || operation.current === "cancel") return; + if (operation.current === "disconnect" || operation.current === "change" || operation.current === "cancel") return; + const current = dialogRef.current; + if (current?.kind === "manage" && current.confirm) { showDialog({ ...current, confirm: false }); setError(null); return; } const cancel = loginAttempt.current !== null || statusRef.current.pending; const intent = ++generation.current; const owner = dialogRef.current; @@ -156,19 +173,36 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { }); } } - async function control(kind: "change" | "logout", action: () => Promise) { + async function control(kind: "change" | "disconnect", action: () => Promise, target?: string) { if (operation.current) return; const intent = ++generation.current; + const request = ++statusSequence.current; + const owner = dialogRef.current; operation.current = kind; - setBusy(true); setError(null); setDismissedError(null); + setBusy(true); setError(null); + if (kind === "disconnect") setDismissedError(null); try { const value: unknown = await action(); if (!mounted.current || intent !== generation.current) return; - const next = publishStatus(value); - if (!next) return; // Error-only IPC replies never replace safe status. - if (kind === "logout" && !next.error && !next.accounts.find((a) => a.id === status.activeId && a.connected)) showDialog(null); - else if (kind === "logout" && next.error === REVOCATION_WARNING) showDialog({ kind: "warning" }); - else setError(next.error); + if (!isStatus(value)) { setError(replyError(value)); return; } + let next: ChatGPTStatus | null; + if (request !== statusSequence.current) { + const refresh = ++statusSequence.current; + const current: unknown = await window.lh!.chatgpt!.status(); + if (!mounted.current || intent !== generation.current) return; + next = refresh === statusSequence.current ? publishStatus(current) : statusRef.current; + } else next = publishStatus(value); + if (!next) return; + // Dismissed status warnings belong to an earlier action. Only a new + // disconnect outcome (or an explicit IPC error envelope above) reopens one. + const failure = [value.error, next.error].find((message) => message && (kind === "disconnect" || message !== dismissedError)) ?? null; + if (kind === "disconnect" && !next.accounts.some((a) => a.id === target)) { + if (dialogRef.current === owner) { + if (failure === REVOCATION_WARNING) showDialog({ kind: "warning" }); + else if (!failure) showDialog(null); + } + setError(failure); + } else setError(failure || (kind === "disconnect" ? CONTROL_ERROR : null)); } catch { if (mounted.current && intent === generation.current) setError(CONTROL_ERROR); } finally { if (intent === generation.current) { operation.current = null; if (mounted.current) setBusy(false); } } } @@ -176,7 +210,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { const bridge = window.lh?.chatgpt; const owner = dialogRef.current; if (!bridge || operation.current || !status.available || status.pending || owner?.kind !== "connect") return; - const attempt: LoginAttempt = { generation: ++generation.current, dialog: owner, registration, background, pending: false, phase: "starting" }; + const attempt: LoginAttempt = { generation: ++generation.current, dialog: owner, registration, background, backgroundDirty, pending: false, phase: "starting" }; loginAttempt.current = attempt; operation.current = "login"; const request = ++statusSequence.current; setBusy(true); setError(null); setDismissedError(null); @@ -204,45 +238,67 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { const context = { status, busy, error: warning ? null : visibleError, inlineError: dialog || warning ? null : visibleError, warning: dialog?.kind === "warning" ? null : warning, connect: openConnect, - manage: (id: string) => { if (!operation.current) { setError(null); showDialog({ kind: "manage", accountId: id }); } }, + manage: (id: string) => { + const account = statusRef.current.accounts.find((a) => a.id === id); + if (!operation.current && account) { setError(null); showDialog({ kind: "manage", accountId: id, account }); } + }, 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}

+

{REVOCATION_WARNING}

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

{managed.email || "ChatGPT account"}

} -

{CHATGPT_NOTICE}

-
+ : dialog.kind === "manage" && dialog.confirm ?
+

{CHATGPT_DISCONNECT_COPY.title}

+

{managed?.email || "ChatGPT account"}

+

{CHATGPT_DISCONNECT_COPY.body}

+ {context.error &&

{context.error}

} +
+ + +
+
: <> + {dialog.kind === "connect" ? <> +

Continue with ChatGPT

+ {status.accounts.length > 0 &&
+ + {registration && } +
} +

{CHATGPT_NOTICE}

+ : managed && <> +
+ {status.accounts.length > 1 && managedPresent && !busy ? : {managed.email || "ChatGPT account"}} +
+
+ +
+ } +
+ { + if (dialog.kind === "connect") { setBackground(enabled); setBackgroundDirty(true); } + else if (bridge) void control("change", () => bridge.consent(enabled)); + }} /> + {BACKGROUND_LABEL}{CHATGPT_BACKGROUND_HINT} +
+ {dialog.kind === "manage" &&

{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 && }
} } @@ -293,7 +349,7 @@ export function ChatGPTConnection({ setup = false }: { setup?: boolean }) { const accounts = ui.status.accounts.filter((a) => a.connected); return
- {!setup && ChatGPT} + {!setup && active && ChatGPT} {active && !setup ? <>