Refine ChatGPT setup and model provider settings

This commit is contained in:
amosblomqvist
2026-10-24 14:02:54 +03:00
parent 0fc6d4ab94
commit b8abad2b4f
11 changed files with 402 additions and 80 deletions
-1
View File
@@ -311,7 +311,6 @@ export function ModelControl({ chatId }: { chatId?: string }) {
return (
<div ref={containerRef} className="relative flex items-center gap-1.5" data-testid="model-control">
{effectiveProvider === "chatgpt" && <a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="text-xs text-muted-foreground underline-offset-2 hover:underline">Using ChatGPT plan</a>}
<Hint
// Trimmed (owner, subtlety round 2026-09-25): "Model selector"
// — the long sentence was way too much. ⌘K opens this same
+2
View File
@@ -72,6 +72,7 @@ import { prettyModel } from "@/components/usage-format";
import { cn } from "@/lib/utils";
import { SettingsGroup, SettingsRow, Toggle, CommitInput } from "./controls.tsx";
import { ModelMenuList, ThinkingMenuList } from "./model-menu";
import { ChatGPTBackgroundAccess } from "./chatgpt-connection";
/** One slot's display identity — the tier's roster as the row hint rides
* with the resolved default (owner: the hint ALWAYS names the default). */
@@ -647,6 +648,7 @@ export function AgentSection({
ref={thisMenuOpen ? menuAnchorRef : undefined}
className="relative flex items-center gap-2"
>
{slot !== "strong" && <ChatGPTBackgroundAccess provider={coord?.provider} />}
<MenuTrigger
value={modelId ? prettyModel(modelId) : ""}
emptyLabel="—"
+206 -60
View File
@@ -1,86 +1,232 @@
"use client";
import { useEffect, useState } from "react";
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: () => {},
});
/** Status/control surface only; OAuth URLs, codes, and tokens never reach React. */
export function ChatGPTConnection() {
/** 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<ChatGPTStatus>(empty);
const [selected, setSelected] = useState("");
const [dialog, setDialog] = useState<Dialog | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dismissedError, setDismissedError] = useState<string | null>(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;
if (!bridge) return;
let mounted = true;
const refresh = async () => {
const request = ++statusSequence.current;
try {
const next = await bridge.status();
if (!mounted) return;
setStatus(next);
setSelected((old) => old || next.activeId || "");
} catch { if (mounted) setError("The desktop connection is unavailable. Retry when the app is ready."); }
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 = false; off(); };
const off = bridge?.onChange(() => void refresh());
return () => { mounted.current = false; generation.current++; off?.(); };
}, []);
const account = status.accounts.find((a) => a.id === selected);
async function control(action: () => Promise<ChatGPTStatus>) {
if (busy) return;
setBusy(true);
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<ChatGPTStatus>) {
if (operation.current) return;
operation.current = kind;
setBusy(true); setError(null); setDismissedError(null);
try {
const next = await action();
if (typeof next.available === "boolean") setStatus(next);
setError(next.error ?? null);
} catch { setError("The ChatGPT connection could not be changed. Try again."); }
finally { setBusy(false); }
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;
return (
<div className="flex flex-col gap-3" data-testid="chatgpt-connection">
<div className="flex items-center justify-between gap-3">
<span className="font-ui text-xs text-foreground">ChatGPT</span>
<span className="text-xs text-muted-foreground" role="status">{status.pending ? "Waiting for browser sign-in…" : status.accounts.some((a) => a.id === status.activeId && a.connected) ? "ChatGPT plan connection" : "Not connected"}</span>
</div>
<p className="text-xs leading-relaxed text-muted-foreground" data-testid="chatgpt-privacy-notice">{CHATGPT_NOTICE}</p>
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
<a href="https://openai.com/policies/privacy-policy/" target="_blank" rel="noreferrer" className="underline underline-offset-2">OpenAI privacy</a>
<a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="underline underline-offset-2">Manage usage</a>
</div>
{status.accounts.length > 0 && (
<label className="flex flex-col gap-1 text-xs text-muted-foreground">
Saved ChatGPT account / registration
<select aria-label="Saved ChatGPT account" value={selected} onChange={(event) => setSelected(event.target.value)} disabled={busy || status.pending} className="rounded-sm border border-border bg-background px-2 py-1.5 text-foreground">
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 <Context.Provider value={context}>
{children}
{dialog && <AccountDialog onClose={close} busy={busy && operation.current !== "login"}>
<h2 className="text-sm font-semibold text-foreground">{dialog.kind === "connect" ? "Continue with ChatGPT" : "ChatGPT account"}</h2>
{dialog.kind === "warning" ? <>
<p role="alert" className="mt-3 rounded-sm border border-border px-3 py-2 text-xs text-foreground" data-testid="chatgpt-revocation-warning">{REVOCATION_WARNING}</p>
<div className="mt-4 flex justify-end"><Button variant="outline" size="sm" onClick={() => { context.dismissWarning(); setDialog(null); }}>Dismiss</Button></div>
</> : <>
{dialog.kind === "connect" && status.accounts.length > 0 && <label className="mt-3 flex flex-col gap-1 text-xs text-muted-foreground">
Saved ChatGPT account
<select aria-label="Saved ChatGPT account" value={registration} onChange={(event) => setRegistration(event.target.value)} disabled={busy || status.pending} className="rounded-sm border border-border bg-background px-2 py-1.5 text-foreground">
<option value="">Add another account or workspace</option>
{status.accounts.map((item) => <option key={item.id} value={item.id}>{item.email} · registration {item.registration}{item.id === status.activeId ? " · active" : ""}</option>)}
{status.accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
</select>
<span>Registrations can belong to different workspaces. An email does not identify a workspace.</span>
</label>
)}
<div className="flex flex-wrap items-center gap-3">
{/* Official black/white format and unmodified OpenAI-provided mark. */}
<button type="button" data-testid="chatgpt-login" disabled={!status.available || busy || status.pending} onClick={() => bridge && void control(() => bridge.login(selected || undefined))} className="inline-flex min-h-10 items-center justify-center gap-2 rounded-md border border-white/20 bg-black px-4 py-2 text-sm font-medium text-white disabled:opacity-40">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/siwc/chatgpt-logo-white.svg" width="21" height="21" alt="" aria-hidden="true" />
Continue with ChatGPT
</button>
{status.pending && <button type="button" onClick={() => bridge && void control(() => bridge.cancel())} className="text-xs underline underline-offset-2">Cancel sign-in</button>}
{account?.connected && selected !== status.activeId && <button type="button" disabled={busy || status.pending} onClick={() => bridge && void control(() => bridge.select(selected))} className="text-xs underline underline-offset-2">Use this account</button>}
{account && <button type="button" disabled={busy} onClick={() => bridge && void control(() => bridge.logout(selected))} className="text-xs underline underline-offset-2">Disconnect</button>}
</div>
{!status.available && <p className="text-xs text-muted-foreground">ChatGPT sign-in is available only in the desktop app with secure OS credential storage. API-key connections remain available here.</p>}
{account && (
<label className="flex items-start gap-2 text-xs text-foreground">
<input type="checkbox" checked={account.background} disabled={busy || !status.available || status.pending} onChange={(event) => bridge && void control(() => bridge.consent(account.id, event.target.checked))} className="mt-0.5" />
<span>Allow background use<span className="mt-1 block leading-relaxed text-muted-foreground">Allow extraction, compaction, delegated jobs, and automatic follow-up work to use this ChatGPT connection.</span></span>
</label>
)}
{(error || status.error || status.catalogError) && <p role="alert" className="text-xs text-red-400">{error || status.error || status.catalogError}</p>}
</label>}
{managed && <p className="mt-3 text-xs text-foreground">{managed.email || "ChatGPT account"}</p>}
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="chatgpt-privacy-notice">{CHATGPT_NOTICE}</p>
<div className="mt-3 flex flex-wrap gap-3 text-xs text-muted-foreground">
<a href="https://openai.com/policies/privacy-policy/" target="_blank" rel="noreferrer" className="underline underline-offset-2">OpenAI privacy</a>
<a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="underline underline-offset-2">Manage usage</a>
</div>
{(dialog.kind === "connect" || managed) && <div className="mt-4 flex items-start gap-3 text-xs text-foreground">
<Toggle label="Allow background use" checked={dialog.kind === "connect" ? background : managed!.background} disabled={busy || status.pending || !status.available} onChange={(enabled) => {
if (dialog.kind === "connect") setBackground(enabled);
else if (managed && bridge) void control("change", () => bridge.consent(managed.id, enabled));
}} />
<span>Allow background use<span className="mt-1 block leading-relaxed text-muted-foreground">{CHATGPT_BACKGROUND_HINT}</span></span>
</div>}
{!status.available && <p className="mt-3 text-xs text-muted-foreground">ChatGPT sign-in is available only in the desktop app with secure OS credential storage. API-key connections remain available here.</p>}
{status.pending && <p role="status" className="mt-3 text-xs text-muted-foreground">Waiting for browser sign-in…</p>}
{context.error && <p role="alert" className="mt-3 text-xs text-red-400">{context.error}</p>}
<div className="mt-4 flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={close} disabled={busy && operation.current !== "login"}>{dialog.kind === "connect" ? "Cancel" : "Close"}</Button>
{dialog.kind === "connect" && <ContinueButton onClick={() => void login()} disabled={!status.available || busy || status.pending} testid="chatgpt-proceed" />}
{managed && <Button variant="outline" size="sm" data-testid="chatgpt-disconnect" disabled={busy || status.pending} onClick={() => bridge && void control("logout", () => bridge.logout(managed.id))}>Disconnect</Button>}
</div>
</>}
</AccountDialog>}
</Context.Provider>;
}
function ContinueButton({ onClick, disabled, testid = "chatgpt-login" }: { onClick: () => void; disabled: boolean; testid?: string }) {
return <Button type="button" variant="outline" size="sm" data-testid={testid} onClick={onClick} disabled={disabled} className="cursor-pointer disabled:cursor-not-allowed">
{/* Official unmodified mark; existing secondary button hover/focus treatment. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/siwc/chatgpt-logo-white.svg" width="16" height="16" alt="" aria-hidden="true" />
Continue with ChatGPT
</Button>;
}
function AccountDialog({ children, onClose, busy }: { children: React.ReactNode; onClose: () => void; busy: boolean }) {
const ref = useRef<HTMLDivElement>(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<HTMLElement>('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(<div className="fixed inset-0 z-[60] flex items-center justify-center bg-background/80" data-testid="chatgpt-dialog-backdrop" {...backdropDismiss}>
<div ref={ref} role="dialog" aria-modal="true" aria-label="ChatGPT account" aria-busy={busy} data-testid="chatgpt-account-dialog" onClick={(event) => 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}</div>
</div>, 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 <div className="flex flex-col gap-2" data-testid="chatgpt-connection">
<div className="flex flex-wrap items-center gap-3">
{!setup && <span className="font-ui text-xs text-foreground">ChatGPT</span>}
{active && !setup ? <>
<select aria-label="ChatGPT account" value={active.id} disabled={ui.busy || ui.status.pending} onChange={(event) => ui.select(event.target.value)} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1 text-xs text-foreground">
{accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
</select>
<Button type="button" size="xs" variant="ghost" disabled={ui.busy} onClick={() => ui.manage(active.id)}>Manage…</Button>
</> : <ContinueButton onClick={ui.connect} disabled={!ui.status.available || ui.busy || ui.status.pending} />}
</div>
);
{ui.warning && <div role="alert" data-testid="chatgpt-revocation-warning" className="rounded-sm border border-border px-3 py-2 text-xs text-muted-foreground">{ui.warning}<button type="button" className="ml-2 cursor-pointer underline underline-offset-2" onClick={ui.dismissWarning}>Dismiss</button></div>}
</div>;
}
/** 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 <Button type="button" size="xs" variant="ghost" className="cursor-pointer" disabled={ui.busy} onClick={() => ui.manage(account.id)}>Enable background use</Button>;
}
+1 -1
View File
@@ -413,7 +413,6 @@ export function KeysSection({
return (
<>
<SettingsGroup label="ChatGPT" testid="group-chatgpt"><ChatGPTConnection /></SettingsGroup>
<SettingsGroup label="Model providers" testid="group-provider-keys">
{providerRows.map((status) => (
<KeyTableRow
@@ -448,6 +447,7 @@ export function KeysSection({
<AddProviderEditor provider={adding} onSave={(v) => void saveNewProvider(adding, v)} onCancel={() => setAdding(null)} error={addError} />
</div>
)}
<div className="mt-3 border-t border-border/60 pt-3" data-testid="group-chatgpt"><ChatGPTConnection /></div>
</SettingsGroup>
<SettingsGroup label="Tools & services" testid="group-tool-keys">
+7 -6
View File
@@ -49,13 +49,14 @@ import { KeysSection } from "./keys-section.tsx";
import { Hint } from "@/components/ui/hint";
import { AppearanceSection, GraphSection } from "./appearance-section.tsx";
import { KeyboardSection } from "./keyboard-section.tsx";
import { ChatGPTUIProvider } from "./chatgpt-connection.tsx";
/** Owner (2026-09-20): hints earn their place — mechanics-explaining
* prose is out; only the keyboard note stays (storage location is a
* real question users ask). */
const SECTIONS = [
{ id: "keys", label: "Model providers" },
{ id: "general", label: "General" },
{ id: "keys", label: "Connections" },
{ id: "agent", label: "Agent" },
{ id: "appearance", label: "Appearance" },
{ id: "graph", label: "Graph" },
@@ -83,7 +84,7 @@ export function SettingsDialogProvider({
children: React.ReactNode;
}) {
const [show, setShow] = useState(false);
const [landOn, setLandOn] = useState<SectionId>("general");
const [landOn, setLandOn] = useState<SectionId>("keys");
const [landOnRow, setLandOnRow] = useState<string | null>(null);
const open = useCallback((section?: string, row?: string) => {
if (section) setLandOn(section as SectionId);
@@ -91,12 +92,12 @@ export function SettingsDialogProvider({
setShow(true);
}, []);
return (
<DialogContext.Provider value={{ open }}>
<ChatGPTUIProvider><DialogContext.Provider value={{ open }}>
{children}
{show && (
<SettingsDialog onClose={() => setShow(false)} initialSection={landOn} initialRow={landOnRow} />
)}
</DialogContext.Provider>
</DialogContext.Provider></ChatGPTUIProvider>
);
}
@@ -133,7 +134,7 @@ function SettingsDialog({
* (portals render nothing under renderToStaticMarkup). */
export function SettingsDialogBody({
onClose,
initialSection = "general",
initialSection = "keys",
initialRow = null,
}: {
onClose: () => void;
@@ -376,7 +377,7 @@ export function SettingsDialogBody({
</SettingsSection>
)}
{active === "keys" && (
<SettingsSection title="Connections" testid="section-keys">
<SettingsSection title="Model providers" testid="section-keys">
<KeysSection initialRow={initialRow ?? undefined} />
</SettingsSection>
)}
+3 -3
View File
@@ -44,7 +44,7 @@ import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-sig
import { Hint } from "@/components/ui/hint";
import { type ProviderKeyInfo } from "@/components/settings/keys-section";
import { ProviderMenuList } from "@/components/settings/model-menu";
import { ChatGPTConnection } from "@/components/settings/chatgpt-connection";
import { ChatGPTBackgroundAccess, ChatGPTConnection } from "@/components/settings/chatgpt-connection";
import { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
import { ThinkingMenuList } from "@/components/settings/model-menu";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
@@ -148,8 +148,6 @@ export function SetupKeysBody({
}) {
return (
<div className="flex flex-col gap-3 px-6 py-5" data-testid="setup-stage-keys">
<ChatGPTConnection />
<p className="border-t border-border pt-3 text-sm text-foreground">Or add an API key.</p>
<p className="text-xs text-muted-foreground">
Choose your AI provider and paste its API key. You’ll pick your models next.
</p>
@@ -213,6 +211,7 @@ export function SetupKeysBody({
</p>
)}
</div>
<div className="mt-2 border-t border-border/60 pt-4"><ChatGPTConnection setup /></div>
</div>
);
}
@@ -478,6 +477,7 @@ export function SetupDialog({
)}
</span>
<span className="text-xs text-muted-foreground/70">{SLOT_META[slot].roster}</span>
{slot !== "strong" && <ChatGPTBackgroundAccess provider={entry?.provider} />}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<button