feat: enable subscription connections in web and dev

This commit is contained in:
amosblomqvist
2026-10-26 03:00:40 +02:00
parent fc3673466d
commit c0c670b810
29 changed files with 838 additions and 41 deletions
+12 -11
View File
@@ -5,8 +5,10 @@ import type { ChatGPTStatus } from "@/src/core";
import { ConnectionDialog, ConnectionFooter, ConnectionRow } from "./connection-controls";
import { Button } from "@/components/ui/button";
import { Toggle } from "./controls";
import { useChatGPTBridge } from "./subscription-client";
export const CHATGPT_NOTICE = "LearnHarness stores your ChatGPT account details and credentials in an unencrypted local file 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 function chatGPTNotice(web = false): string { return web ? `${CHATGPT_NOTICE.replace("on this device", "on the machine running LearnHarness")} Complete ChatGPT sign-in in a browser on that same machine.` : CHATGPT_NOTICE; }
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.";
@@ -41,6 +43,8 @@ const Context = createContext({
/** 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 bridge = useChatGPTBridge();
const web = typeof window !== "undefined" && bridge !== window.lh?.chatgpt;
const [status, setStatus] = useState<ChatGPTStatus>(empty);
const [dialog, setDialog] = useState<Dialog | null>(null);
const [busy, setBusy] = useState(false);
@@ -79,14 +83,14 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
if (attempt.backgroundDirty && next.allowBackgroundUse !== attempt.background) {
operation.current = "change";
const request = ++statusSequence.current;
const result: unknown = await window.lh!.chatgpt!.consent(attempt.background);
const result: unknown = await bridge!.consent(attempt.background);
if (!owns(attempt)) return;
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();
const value: unknown = await bridge!.status();
if (!owns(attempt)) return;
saved = refresh === statusSequence.current ? publishStatus(value) : statusRef.current;
} else saved = publishStatus(result);
@@ -120,7 +124,6 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
};
useEffect(() => {
mounted.current = true;
const bridge = window.lh?.chatgpt;
const refresh = async () => {
const request = ++statusSequence.current;
try {
@@ -131,7 +134,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
void refresh();
const off = bridge?.onChange(() => void refresh());
return () => { mounted.current = false; generation.current++; loginAttempt.current = null; off?.(); };
}, []);
}, [bridge]);
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);
@@ -161,13 +164,13 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
// Keep cancellation serialized even after login's pending IPC reply.
operation.current = "cancel"; setBusy(true);
const request = ++statusSequence.current;
void window.lh?.chatgpt?.cancel().then(async (value: unknown) => {
void bridge?.cancel().then(async (value: unknown) => {
if (!mounted.current || generation.current !== intent) return;
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();
const current: unknown = await bridge!.status();
if (!mounted.current || generation.current !== intent) return;
next = refresh === statusSequence.current ? publishStatus(current) : statusRef.current;
} else next = publishStatus(value);
@@ -195,7 +198,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
let next: ChatGPTStatus | null;
if (request !== statusSequence.current) {
const refresh = ++statusSequence.current;
const current: unknown = await window.lh!.chatgpt!.status();
const current: unknown = await bridge!.status();
if (!mounted.current || intent !== generation.current) return;
next = refresh === statusSequence.current ? publishStatus(current) : statusRef.current;
} else next = publishStatus(value);
@@ -214,7 +217,6 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
finally { if (intent === generation.current) { operation.current = null; if (mounted.current) setBusy(false); } }
}
async function login() {
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, backgroundDirty, pending: false, phase: "starting" };
@@ -241,7 +243,6 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
// No finally: pending OAuth outlives this IPC promise. Only its status
// completion, failure, or explicit cancellation releases the attempt.
}
const bridge = typeof window !== "undefined" ? window.lh?.chatgpt : undefined;
const context = {
status, busy, error: warning ? null : visibleError, inlineError: dialog || warning ? null : visibleError, warning: dialog?.kind === "warning" ? null : warning,
connect: openConnect,
@@ -262,7 +263,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
</> : dialog.kind === "manage" && dialog.confirm ? <div data-testid="chatgpt-disconnect-confirmation">
<p className="mt-3 text-sm font-semibold text-red-400">{CHATGPT_DISCONNECT_COPY.title}</p>
<p className="mt-3 text-xs text-foreground">{managed?.email || "ChatGPT account"}</p>
<p className="mt-3 text-xs text-muted-foreground">{CHATGPT_DISCONNECT_COPY.body}</p>
<p className="mt-3 text-xs text-muted-foreground">{web ? CHATGPT_DISCONNECT_COPY.body.replace("from this device", "from the machine running LearnHarness") : CHATGPT_DISCONNECT_COPY.body}</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" disabled={busy} onClick={close}>Cancel</Button>
@@ -277,7 +278,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
</select>
{registration && <Button variant="ghost" size="xs" disabled={busy} onClick={() => context.manage(registration)}>Manage…</Button>}
</div>}
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="chatgpt-privacy-notice">{CHATGPT_NOTICE}</p>
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="chatgpt-privacy-notice">{chatGPTNotice(web)}</p>
</> : null}
<div className={`mt-4 flex items-center gap-3 text-xs text-foreground ${busy && operation.current === "change" && status.available && !status.pending ? "[&>button:disabled]:cursor-auto" : ""}`} data-testid="chatgpt-global-background">
<Toggle label={BACKGROUND_LABEL} checked={dialog.kind === "connect" ? background : status.allowBackgroundUse} disabled={busy || status.pending || !status.available} onChange={(enabled) => {