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 ( return (
<div ref={containerRef} className="relative flex items-center gap-1.5" data-testid="model-control"> <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 <Hint
// Trimmed (owner, subtlety round 2026-09-25): "Model selector" // Trimmed (owner, subtlety round 2026-09-25): "Model selector"
// — the long sentence was way too much. ⌘K opens this same // — 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 { cn } from "@/lib/utils";
import { SettingsGroup, SettingsRow, Toggle, CommitInput } from "./controls.tsx"; import { SettingsGroup, SettingsRow, Toggle, CommitInput } from "./controls.tsx";
import { ModelMenuList, ThinkingMenuList } from "./model-menu"; 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 /** 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). */ * with the resolved default (owner: the hint ALWAYS names the default). */
@@ -647,6 +648,7 @@ export function AgentSection({
ref={thisMenuOpen ? menuAnchorRef : undefined} ref={thisMenuOpen ? menuAnchorRef : undefined}
className="relative flex items-center gap-2" className="relative flex items-center gap-2"
> >
{slot !== "strong" && <ChatGPTBackgroundAccess provider={coord?.provider} />}
<MenuTrigger <MenuTrigger
value={modelId ? prettyModel(modelId) : ""} value={modelId ? prettyModel(modelId) : ""}
emptyLabel="—" emptyLabel="—"
+206 -60
View File
@@ -1,86 +1,232 @@
"use client"; "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 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_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 }; 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. */ /** UI lifetime spans setup's keys → models transition. Only existing safe controls
export function ChatGPTConnection() { * 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 [status, setStatus] = useState<ChatGPTStatus>(empty);
const [selected, setSelected] = useState(""); const [dialog, setDialog] = useState<Dialog | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); 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(() => { useEffect(() => {
mounted.current = true;
const bridge = window.lh?.chatgpt; const bridge = window.lh?.chatgpt;
if (!bridge) return;
let mounted = true;
const refresh = async () => { const refresh = async () => {
const request = ++statusSequence.current;
try { try {
const next = await bridge.status(); const next = await bridge?.status();
if (!mounted) return; if (next && mounted.current && request === statusSequence.current) setStatus(next);
setStatus(next); } catch { if (mounted.current) setError("The desktop connection is unavailable. Retry when the app is ready."); }
setSelected((old) => old || next.activeId || "");
} catch { if (mounted) setError("The desktop connection is unavailable. Retry when the app is ready."); }
}; };
void refresh(); void refresh();
const off = bridge.onChange(() => void refresh()); const off = bridge?.onChange(() => void refresh());
return () => { mounted = false; off(); }; return () => { mounted.current = false; generation.current++; off?.(); };
}, []); }, []);
const account = status.accounts.find((a) => a.id === selected);
async function control(action: () => Promise<ChatGPTStatus>) { const managed = dialog?.kind === "manage" ? status.accounts.find((a) => a.id === dialog.accountId && a.connected) : undefined;
if (busy) return; const visibleError = error || (status.error !== dismissedError ? status.error : null) || status.catalogError || null;
setBusy(true); const warning = visibleError === REVOCATION_WARNING ? visibleError : null;
function openConnect() {
setBackground(false);
setRegistration("");
setError(null); 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 { try {
const next = await action(); const next = await action();
if (typeof next.available === "boolean") setStatus(next); if (!mounted.current) return;
setError(next.error ?? null); publishStatus(next);
} catch { setError("The ChatGPT connection could not be changed. Try again."); } if (kind === "logout" && !next.accounts.find((a) => a.id === status.activeId && a.connected)) {
finally { setBusy(false); } 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 bridge = typeof window !== "undefined" ? window.lh?.chatgpt : undefined;
return ( const context = {
<div className="flex flex-col gap-3" data-testid="chatgpt-connection"> status, busy, error: warning ? null : visibleError, warning: dialog?.kind === "warning" ? null : warning,
<div className="flex items-center justify-between gap-3"> connect: openConnect,
<span className="font-ui text-xs text-foreground">ChatGPT</span> manage: (id: string) => { setError(null); setDialog({ kind: "manage", accountId: id }); },
<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> select: (id: string) => { if (bridge) void control("change", () => bridge.select(id)); },
</div> dismissWarning: () => { setDismissedError(REVOCATION_WARNING); setError(null); },
<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"> return <Context.Provider value={context}>
<a href="https://openai.com/policies/privacy-policy/" target="_blank" rel="noreferrer" className="underline underline-offset-2">OpenAI privacy</a> {children}
<a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="underline underline-offset-2">Manage usage</a> {dialog && <AccountDialog onClose={close} busy={busy && operation.current !== "login"}>
</div> <h2 className="text-sm font-semibold text-foreground">{dialog.kind === "connect" ? "Continue with ChatGPT" : "ChatGPT account"}</h2>
{status.accounts.length > 0 && ( {dialog.kind === "warning" ? <>
<label className="flex flex-col gap-1 text-xs text-muted-foreground"> <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>
Saved ChatGPT account / registration <div className="mt-4 flex justify-end"><Button variant="outline" size="sm" onClick={() => { context.dismissWarning(); setDialog(null); }}>Dismiss</Button></div>
<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"> </> : <>
{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> <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> </select>
<span>Registrations can belong to different workspaces. An email does not identify a workspace.</span> </label>}
</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="flex flex-wrap items-center gap-3"> <div className="mt-3 flex flex-wrap gap-3 text-xs text-muted-foreground">
{/* Official black/white format and unmodified OpenAI-provided mark. */} <a href="https://openai.com/policies/privacy-policy/" target="_blank" rel="noreferrer" className="underline underline-offset-2">OpenAI privacy</a>
<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"> <a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="underline underline-offset-2">Manage usage</a>
{/* eslint-disable-next-line @next/next/no-img-element */} </div>
<img src="/siwc/chatgpt-logo-white.svg" width="21" height="21" alt="" aria-hidden="true" /> {(dialog.kind === "connect" || managed) && <div className="mt-4 flex items-start gap-3 text-xs text-foreground">
Continue with ChatGPT <Toggle label="Allow background use" checked={dialog.kind === "connect" ? background : managed!.background} disabled={busy || status.pending || !status.available} onChange={(enabled) => {
</button> if (dialog.kind === "connect") setBackground(enabled);
{status.pending && <button type="button" onClick={() => bridge && void control(() => bridge.cancel())} className="text-xs underline underline-offset-2">Cancel sign-in</button>} else if (managed && bridge) void control("change", () => bridge.consent(managed.id, enabled));
{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>} <span>Allow background use<span className="mt-1 block leading-relaxed text-muted-foreground">{CHATGPT_BACKGROUND_HINT}</span></span>
</div> </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>} {!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>}
{account && ( {status.pending && <p role="status" className="mt-3 text-xs text-muted-foreground">Waiting for browser sign-in…</p>}
<label className="flex items-start gap-2 text-xs text-foreground"> {context.error && <p role="alert" className="mt-3 text-xs text-red-400">{context.error}</p>}
<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" /> <div className="mt-4 flex items-center justify-end gap-2">
<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> <Button variant="ghost" size="sm" onClick={close} disabled={busy && operation.current !== "login"}>{dialog.kind === "connect" ? "Cancel" : "Close"}</Button>
</label> {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>}
{(error || status.error || status.catalogError) && <p role="alert" className="text-xs text-red-400">{error || status.error || status.catalogError}</p>} </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> </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 ( return (
<> <>
<SettingsGroup label="ChatGPT" testid="group-chatgpt"><ChatGPTConnection /></SettingsGroup>
<SettingsGroup label="Model providers" testid="group-provider-keys"> <SettingsGroup label="Model providers" testid="group-provider-keys">
{providerRows.map((status) => ( {providerRows.map((status) => (
<KeyTableRow <KeyTableRow
@@ -448,6 +447,7 @@ export function KeysSection({
<AddProviderEditor provider={adding} onSave={(v) => void saveNewProvider(adding, v)} onCancel={() => setAdding(null)} error={addError} /> <AddProviderEditor provider={adding} onSave={(v) => void saveNewProvider(adding, v)} onCancel={() => setAdding(null)} error={addError} />
</div> </div>
)} )}
<div className="mt-3 border-t border-border/60 pt-3" data-testid="group-chatgpt"><ChatGPTConnection /></div>
</SettingsGroup> </SettingsGroup>
<SettingsGroup label="Tools & services" testid="group-tool-keys"> <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 { Hint } from "@/components/ui/hint";
import { AppearanceSection, GraphSection } from "./appearance-section.tsx"; import { AppearanceSection, GraphSection } from "./appearance-section.tsx";
import { KeyboardSection } from "./keyboard-section.tsx"; import { KeyboardSection } from "./keyboard-section.tsx";
import { ChatGPTUIProvider } from "./chatgpt-connection.tsx";
/** Owner (2026-09-20): hints earn their place — mechanics-explaining /** Owner (2026-09-20): hints earn their place — mechanics-explaining
* prose is out; only the keyboard note stays (storage location is a * prose is out; only the keyboard note stays (storage location is a
* real question users ask). */ * real question users ask). */
const SECTIONS = [ const SECTIONS = [
{ id: "keys", label: "Model providers" },
{ id: "general", label: "General" }, { id: "general", label: "General" },
{ id: "keys", label: "Connections" },
{ id: "agent", label: "Agent" }, { id: "agent", label: "Agent" },
{ id: "appearance", label: "Appearance" }, { id: "appearance", label: "Appearance" },
{ id: "graph", label: "Graph" }, { id: "graph", label: "Graph" },
@@ -83,7 +84,7 @@ export function SettingsDialogProvider({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const [show, setShow] = useState(false); 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 [landOnRow, setLandOnRow] = useState<string | null>(null);
const open = useCallback((section?: string, row?: string) => { const open = useCallback((section?: string, row?: string) => {
if (section) setLandOn(section as SectionId); if (section) setLandOn(section as SectionId);
@@ -91,12 +92,12 @@ export function SettingsDialogProvider({
setShow(true); setShow(true);
}, []); }, []);
return ( return (
<DialogContext.Provider value={{ open }}> <ChatGPTUIProvider><DialogContext.Provider value={{ open }}>
{children} {children}
{show && ( {show && (
<SettingsDialog onClose={() => setShow(false)} initialSection={landOn} initialRow={landOnRow} /> <SettingsDialog onClose={() => setShow(false)} initialSection={landOn} initialRow={landOnRow} />
)} )}
</DialogContext.Provider> </DialogContext.Provider></ChatGPTUIProvider>
); );
} }
@@ -133,7 +134,7 @@ function SettingsDialog({
* (portals render nothing under renderToStaticMarkup). */ * (portals render nothing under renderToStaticMarkup). */
export function SettingsDialogBody({ export function SettingsDialogBody({
onClose, onClose,
initialSection = "general", initialSection = "keys",
initialRow = null, initialRow = null,
}: { }: {
onClose: () => void; onClose: () => void;
@@ -376,7 +377,7 @@ export function SettingsDialogBody({
</SettingsSection> </SettingsSection>
)} )}
{active === "keys" && ( {active === "keys" && (
<SettingsSection title="Connections" testid="section-keys"> <SettingsSection title="Model providers" testid="section-keys">
<KeysSection initialRow={initialRow ?? undefined} /> <KeysSection initialRow={initialRow ?? undefined} />
</SettingsSection> </SettingsSection>
)} )}
+3 -3
View File
@@ -44,7 +44,7 @@ import { notifySettingsSaved, useSettingsSaved } from "@/components/settings-sig
import { Hint } from "@/components/ui/hint"; import { Hint } from "@/components/ui/hint";
import { type ProviderKeyInfo } from "@/components/settings/keys-section"; import { type ProviderKeyInfo } from "@/components/settings/keys-section";
import { ProviderMenuList } from "@/components/settings/model-menu"; 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 { defaultOrderLevel, populateLevel, THINKING_LABEL } from "@/components/thinking-default";
import { ThinkingMenuList } from "@/components/settings/model-menu"; import { ThinkingMenuList } from "@/components/settings/model-menu";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
@@ -148,8 +148,6 @@ export function SetupKeysBody({
}) { }) {
return ( return (
<div className="flex flex-col gap-3 px-6 py-5" data-testid="setup-stage-keys"> <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"> <p className="text-xs text-muted-foreground">
Choose your AI provider and paste its API key. You’ll pick your models next. Choose your AI provider and paste its API key. You’ll pick your models next.
</p> </p>
@@ -213,6 +211,7 @@ export function SetupKeysBody({
</p> </p>
)} )}
</div> </div>
<div className="mt-2 border-t border-border/60 pt-4"><ChatGPTConnection setup /></div>
</div> </div>
); );
} }
@@ -478,6 +477,7 @@ export function SetupDialog({
)} )}
</span> </span>
<span className="text-xs text-muted-foreground/70">{SLOT_META[slot].roster}</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="flex items-center gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<button <button
+3 -3
View File
@@ -22,10 +22,10 @@ const publicError = (err) => ({
storage: "ChatGPT credentials could not be saved securely. Reconnect before making requests.", storage: "ChatGPT credentials could not be saved securely. Reconnect before making requests.",
identity: "ChatGPT sign-in could not be verified. Select the intended saved account and try again.", identity: "ChatGPT sign-in could not be verified. Select the intended saved account and try again.",
scope: "ChatGPT plan permission is missing. Reconnect and authorize ChatGPT plan use.", scope: "ChatGPT plan permission is missing. Reconnect and authorize ChatGPT plan use.",
disconnected: "This ChatGPT connection is disconnected. Reconnect in Connections.", disconnected: "This ChatGPT connection is disconnected. Reconnect in Model providers.",
background: "Background use is off for this ChatGPT account. Enable Allow background use in Connections, or explicitly select an API-key model for that workload.", background: "Background use is off for this ChatGPT account. Enable Allow background use in Model providers, or explicitly select an API-key model for that workload.",
catalog: "The ChatGPT account model catalog is unavailable. Retry the connection; no API-key fallback was used.", catalog: "The ChatGPT account model catalog is unavailable. Retry the connection; no API-key fallback was used.",
expired: "The ChatGPT session expired. Reconnect in Connections.", expired: "The ChatGPT session expired. Reconnect in Model providers.",
network: "ChatGPT could not be reached. Check your connection and try again.", network: "ChatGPT could not be reached. Check your connection and try again.",
pending: "A ChatGPT sign-in is already pending.", pending: "A ChatGPT sign-in is already pending.",
unavailable: "ChatGPT sign-in is unavailable in browser-only mode.", unavailable: "ChatGPT sign-in is unavailable in browser-only mode.",
+172
View File
@@ -0,0 +1,172 @@
import React, { act } from "react";
(globalThis as Record<string, unknown>).React = React;
import assert from "node:assert/strict";
import { afterEach, test } from "node:test";
import { createRoot, type Root } from "react-dom/client";
import { renderToStaticMarkup } from "react-dom/server";
import { parseHTML } from "linkedom";
import { ChatGPTBackgroundAccess, ChatGPTConnection, ChatGPTUIProvider, CHATGPT_NOTICE } from "../components/settings/chatgpt-connection.tsx";
import { SettingsDialogBody } from "../components/settings/settings-dialog.tsx";
import { ModelControl } from "../components/model-picker.tsx";
import type { ChatGPTStatus, ChatGPTControlBridge } from "../src/core/index.ts";
const dom = parseHTML("<html><body></body></html>");
Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, HTMLElement: dom.window.HTMLElement, CustomEvent: dom.window.CustomEvent, IS_REACT_ACT_ENVIRONMENT: true, localStorage: { getItem: () => null, setItem() {} } });
const originalFetch = globalThis.fetch;
let root: Root | undefined, host: HTMLDivElement | undefined;
const account = (id = "private-registration-id", email = "learner@example.invalid", connected = true, background = false) => ({ id, email, registration: "private-issued-client", connected, background, planEnabled: true });
const disconnected = (): ChatGPTStatus => ({ available: true, pending: false, activeId: null, accounts: [account("signed-out-record", "signed-out@example.invalid", false)], error: null });
const connected = (background = false): ChatGPTStatus => ({ ...disconnected(), activeId: "private-registration-id", accounts: [account(undefined, undefined, true, background), ...disconnected().accounts] });
function deferred<T>() { let resolve!: (v: T) => void; const promise = new Promise<T>((done) => { resolve = done; }); return { promise, resolve }; }
function fixture(initial = disconnected(), overrides: Partial<ChatGPTControlBridge> = {}) {
let status = initial;
const calls = { login: 0, cancel: 0, consent: [] as boolean[], logout: 0 };
const listeners = new Set<() => void>();
const bridge: ChatGPTControlBridge = {
status: async () => status,
login: async () => { calls.login++; return status = connected(); },
cancel: async () => { calls.cancel++; return status = disconnected(); },
select: async (id) => { status = { ...status, activeId: id }; return status; },
logout: async () => { calls.logout++; return status = disconnected(); },
consent: async (id, enabled) => { calls.consent.push(enabled); status = { ...status, accounts: status.accounts.map((a) => a.id === id ? { ...a, background: enabled } : a) }; return status; },
onChange: (cb) => { listeners.add(cb); return () => listeners.delete(cb); },
...overrides,
};
window.lh = { expandWindow() {}, chatgpt: bridge };
return { calls, bridge, push(next: ChatGPTStatus) { status = next; for (const cb of listeners) cb(); } };
}
async function mount(children: React.ReactNode = <ChatGPTConnection />) {
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
await act(async () => root!.render(<ChatGPTUIProvider>{children}</ChatGPTUIProvider>));
}
const byTest = (id: string) => document.querySelector<HTMLElement>(`[data-testid="${id}"]`)!;
async function click(element: HTMLElement) { assert(element); await act(async () => element.click()); }
function button(text: string, scope: ParentNode = document) { return [...scope.querySelectorAll<HTMLButtonElement>("button")].find((el) => el.textContent === text)!; }
afterEach(async () => { await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined; delete window.lh; globalThis.fetch = originalFetch; });
test("settings put Model providers first and select it by default", () => {
const html = renderToStaticMarkup(<SettingsDialogBody onClose={() => {}} />);
assert.match(html, /settings-nav-keys[^>]*aria-current="true"/);
assert(html.indexOf("settings-nav-keys") < html.indexOf("settings-nav-general"));
assert.match(html, />Model providers<\/button>/);
assert.doesNotMatch(html, />Connections<\/button>/);
});
test("setup and disconnected rows are secondary buttons, not disclosure walls; cancel never logs in or changes consent", async () => {
const f = fixture(); await mount(<ChatGPTConnection setup />);
assert(!document.body.textContent?.includes(CHATGPT_NOTICE));
assert(!document.body.textContent?.includes("Manage usage"));
assert(!document.body.textContent?.includes("signed-out@example.invalid"));
assert.equal(f.calls.login, 0);
const trigger = byTest("chatgpt-login");
assert.match(trigger.className, /cursor-pointer/);
assert.match(trigger.className, /hover:bg-accent/);
assert.match(trigger.className, /focus-visible:ring/);
await click(trigger);
assert.equal(f.calls.login, 0, "opening disclosure is not OAuth");
assert(document.body.textContent?.includes(CHATGPT_NOTICE));
const toggle = document.querySelector<HTMLElement>('[role="switch"][aria-label="Allow background use"]')!;
assert.equal(toggle.getAttribute("aria-checked"), "false");
await click(toggle); assert.equal(toggle.getAttribute("aria-checked"), "true");
await click(button("Cancel", byTest("chatgpt-account-dialog")));
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
assert.equal(f.calls.login, 0); assert.deepEqual(f.calls.consent, []); assert.equal(f.calls.cancel, 0);
await click(byTest("chatgpt-login"));
assert.equal(document.querySelector('[role="switch"]')?.getAttribute("aria-checked"), "false", "each initial connection choice starts OFF");
});
test("only explicit proceed invokes OAuth; initial background opt-in waits for verified connection", async () => {
const f = fixture(); await mount(); await click(byTest("chatgpt-login"));
await click(document.querySelector<HTMLElement>('[role="switch"]')!);
assert.deepEqual(f.calls.consent, []);
await click(byTest("chatgpt-proceed"));
assert.equal(f.calls.login, 1); assert.deepEqual(f.calls.consent, [true]);
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
assert(button("Manage…"));
});
test("closing pending OAuth cancels and a late login result cannot apply draft consent", async () => {
const login = deferred<ChatGPTStatus>();
const f = fixture(disconnected(), { login: () => { f.calls.login++; return login.promise; } });
await mount(); await click(byTest("chatgpt-login")); await click(document.querySelector<HTMLElement>('[role="switch"]')!);
await click(byTest("chatgpt-proceed"));
await click(button("Cancel", byTest("chatgpt-account-dialog")));
assert.equal(f.calls.cancel, 1);
await act(async () => login.resolve(connected()));
assert.deepEqual(f.calls.consent, []);
assert(!button("Manage…"));
});
test("connected account selector displays email only, excludes signed-out registrations, and retains safe fallback", async () => {
const status = connected(); status.accounts.push(account("second-id", "second@example.invalid"), account("missing-email", ""));
fixture(status); await mount();
const select = document.querySelector<HTMLSelectElement>('select[aria-label="ChatGPT account"]')!;
assert.deepEqual([...select.querySelectorAll("option")].map((o) => o.textContent), ["learner@example.invalid", "second@example.invalid", "ChatGPT account"]);
assert.doesNotMatch(document.body.textContent!, /private-registration-id|private-issued-client|second-id|signed-out@example| · active|registration /);
assert(!document.body.textContent?.includes(CHATGPT_NOTICE));
await click(button("Manage…"));
assert(document.body.textContent?.includes(CHATGPT_NOTICE));
assert(byTest("chatgpt-disconnect"));
});
test("successful local disconnect removes action, resets dialog, and prevents repeated pending clicks without deleting registration UI state", async () => {
const logout = deferred<ChatGPTStatus>();
const f = fixture(connected(), { logout: () => { f.calls.logout++; return logout.promise; } });
await mount(); await click(button("Manage…"));
const disconnect = byTest("chatgpt-disconnect") as HTMLButtonElement;
await act(async () => { disconnect.click(); disconnect.click(); });
assert.equal(f.calls.logout, 1); assert.equal(disconnect.disabled, true);
await act(async () => logout.resolve(disconnected()));
assert(!document.querySelector('[data-testid="chatgpt-disconnect"]'));
assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
assert(byTest("chatgpt-login"));
assert(!document.body.textContent?.includes("Signed out locally"));
assert(!document.querySelector('[role="alert"]'));
await click(byTest("chatgpt-login"));
assert(document.body.textContent?.includes("signed-out@example.invalid"), "retained registration can still be explicitly reauthorized inside sign-in dialog");
});
test("real unconfirmed revoke is a distinct contextual dismissible warning, not permanent global red error", async () => {
const warning = "Signed out locally. Remote revocation was not confirmed; disconnect LearnHarness in ChatGPT Settings.";
fixture(connected(), { logout: async () => ({ ...disconnected(), error: warning }) });
await mount(); await click(button("Manage…")); await click(byTest("chatgpt-disconnect"));
assert(byTest("chatgpt-login")); assert(!document.querySelector('[data-testid="chatgpt-disconnect"]'));
const alert = byTest("chatgpt-revocation-warning");
assert.equal(alert.textContent, warning); assert.doesNotMatch(alert.className, /text-red/);
await click(button("Dismiss", byTest("chatgpt-account-dialog")));
assert(!document.querySelector('[data-testid="chatgpt-revocation-warning"]'));
assert(!document.querySelector('[role="alert"]'));
});
test("background model choice has contextual Enable action only while connected, selected, and consent OFF", async () => {
const f = fixture(connected());
await mount(<><ChatGPTBackgroundAccess provider="chatgpt" /><ChatGPTBackgroundAccess provider="openai" /><ChatGPTConnection /></>);
assert.equal([...document.querySelectorAll("button")].filter((b) => b.textContent === "Enable background use").length, 1);
await click(button("Enable background use"));
assert.deepEqual(f.calls.consent, []);
await click(document.querySelector<HTMLElement>('[role="switch"]')!);
assert.deepEqual(f.calls.consent, [true]);
assert(!button("Enable background use"));
await act(async () => f.push(disconnected()));
assert(!button("Enable background use"));
});
test("browser-only sign-in is disabled without invoking OAuth", async () => {
await mount();
const trigger = byTest("chatgpt-login") as HTMLButtonElement;
assert(trigger.disabled); assert.match(trigger.className, /disabled:opacity/);
await click(trigger); assert(!document.querySelector('[data-testid="chatgpt-account-dialog"]'));
});
test("foreground model/dictation toolbar has no plan badge or background-consent action", async () => {
globalThis.fetch = async (input) => {
if (input === "/api/models") return Response.json({ models: [], providers: [], slots: {}, defaults: {} });
if (input === "/api/settings") return Response.json({ settings: { models: { strong: { model: { provider: "chatgpt", id: "gpt-5" } } } } });
throw new Error(`unexpected fixture request ${input}`);
};
fixture(connected()); await mount(<ModelControl />);
assert.match(byTest("stats-model").textContent!, /gpt/i);
assert(!document.body.textContent?.includes("Using ChatGPT plan"));
assert(!document.body.textContent?.includes("Enable background use"));
assert(!document.querySelector('a[href="https://chatgpt.com/settings/usage"]'));
});
+2 -2
View File
@@ -8,7 +8,7 @@ import { useModelCatalog, catalogLimits } from "../components/model-picker.tsx";
import { useZeroKeys, useStrongKeyless } from "../components/setup-cta.tsx"; import { useZeroKeys, useStrongKeyless } from "../components/setup-cta.tsx";
import { notifySettingsSaved } from "../components/settings-signal.ts"; import { notifySettingsSaved } from "../components/settings-signal.ts";
import { SidebarProvider } from "../components/sidebar.tsx"; import { SidebarProvider } from "../components/sidebar.tsx";
import { ChatGPTConnection } from "../components/settings/chatgpt-connection.tsx"; import { ChatGPTConnection, ChatGPTUIProvider } from "../components/settings/chatgpt-connection.tsx";
import { GraphCostControl } from "../components/graph-view.tsx"; import { GraphCostControl } from "../components/graph-view.tsx";
import { graphCostSummary } from "../src/core/cost-summary.ts"; import { graphCostSummary } from "../src/core/cost-summary.ts";
import type { ModelCatalogResponse, ChatGPTStatus } from "../src/core/index.ts"; import type { ModelCatalogResponse, ChatGPTStatus } from "../src/core/index.ts";
@@ -71,7 +71,7 @@ test("root-lifetime safe bridge publishes with Connections unmounted; commands/p
const before = fetches; const before = fetches;
await act(async () => { for (const callback of pushes) callback(); }); await act(async () => { for (const callback of pushes) callback(); });
assert.equal(fetches, before + 1); assert.equal(fetches, before + 1);
await act(async () => root!.render(<SidebarProvider><Reader id="root" /><ChatGPTConnection /></SidebarProvider>)); await act(async () => root!.render(<SidebarProvider><Reader id="root" /><ChatGPTUIProvider><ChatGPTConnection /></ChatGPTUIProvider></SidebarProvider>));
assert.equal(subscriptions, 2); assert.equal(subscriptions, 2);
const mounted = fetches; const mounted = fetches;
await act(async () => { for (const callback of pushes) callback(); }); await act(async () => { for (const callback of pushes) callback(); });
+4 -3
View File
@@ -41,7 +41,7 @@ describe("SettingsDialog (the designed surface, SSR)", () => {
assert.ok(!html.includes("settings-dialog")); assert.ok(!html.includes("settings-dialog"));
}); });
it("the dialog frame: role=dialog + the ✕ + the six-section nav, General active", () => { it("the dialog frame: role=dialog + the ✕ + the six-section nav, Model providers first and active", () => {
const html = renderToStaticMarkup(<SettingsDialogBody onClose={() => {}} />); const html = renderToStaticMarkup(<SettingsDialogBody onClose={() => {}} />);
assert.ok(html.includes('role="dialog"')); assert.ok(html.includes('role="dialog"'));
assert.ok(html.includes('aria-modal="true"')); assert.ok(html.includes('aria-modal="true"'));
@@ -49,8 +49,9 @@ describe("SettingsDialog (the designed surface, SSR)", () => {
for (const id of ["general", "keys", "agent", "appearance", "graph", "keyboard"]) { for (const id of ["general", "keys", "agent", "appearance", "graph", "keyboard"]) {
assert.ok(html.includes(`data-testid="settings-nav-${id}"`), id); assert.ok(html.includes(`data-testid="settings-nav-${id}"`), id);
} }
// General is the landing section; the others are not current. // Model providers is the first landing section; internal keys deep links survive.
assert.ok(tagOf(html, "settings-nav-general").includes('aria-current="true"')); assert.ok(tagOf(html, "settings-nav-keys").includes('aria-current="true"'));
assert.ok(html.indexOf('data-testid="settings-nav-keys"') < html.indexOf('data-testid="settings-nav-general"'));
assert.ok(tagOf(html, "settings-nav-keyboard").includes('aria-current="false"')); assert.ok(tagOf(html, "settings-nav-keyboard").includes('aria-current="false"'));
// Fetches don't run under SSR — the loading state IS the SSR state. // Fetches don't run under SSR — the loading state IS the SSR state.
assert.ok(html.includes("Loading settings…")); assert.ok(html.includes("Loading settings…"));
+2 -1
View File
@@ -76,7 +76,8 @@ describe("SetupKeysBody — stage 1's self-contained first-key screen", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
createElement(SetupKeysBody, { ...base, providers: PROVIDERS, provider: null }), createElement(SetupKeysBody, { ...base, providers: PROVIDERS, provider: null }),
); );
assert.match(html, /Or add an API key\./); assert.ok(html.indexOf('data-testid="setup-key-provider"') < html.indexOf('data-testid="chatgpt-login"'), "API keys remain primary, ChatGPT is secondary");
assert.doesNotMatch(html, /chatgpt-privacy-notice|OpenAI privacy|Manage usage/);
assert.ok(html.includes("Choose your AI provider and paste its API key. You’ll pick your models next.")); assert.ok(html.includes("Choose your AI provider and paste its API key. You’ll pick your models next."));
assert.doesNotMatch(html, /First, add|keys unlock|—/); assert.doesNotMatch(html, /First, add|keys unlock|—/);
assert.ok(html.includes('data-testid="setup-key-provider"')); assert.ok(html.includes('data-testid="setup-key-provider"'));