fix: streamline subscription connection dialogs and actions

This commit is contained in:
amosblomqvist
2026-10-25 01:34:46 +03:00
parent d6e317c6fe
commit 55a6501a09
5 changed files with 163 additions and 70 deletions
+24 -24
View File
@@ -2,7 +2,7 @@
import { createContext, useContext, useEffect, useRef, useState } from "react"; import { createContext, useContext, useEffect, useRef, useState } from "react";
import type { ChatGPTStatus } from "@/src/core"; import type { ChatGPTStatus } from "@/src/core";
import { ConnectionDialog, ConnectionRow } from "./connection-controls"; import { ConnectionDialog, ConnectionFooter, ConnectionRow } from "./connection-controls";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Toggle } from "./controls"; import { Toggle } from "./controls";
@@ -137,11 +137,11 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
const managedPresent = !!managed && status.accounts.some((a) => a.id === managed.id); const managedPresent = !!managed && status.accounts.some((a) => a.id === managed.id);
const visibleError = error || (status.error !== dismissedError ? status.error : null) || status.catalogError || null; const visibleError = error || (status.error !== dismissedError ? status.error : null) || status.catalogError || null;
const warning = visibleError === REVOCATION_WARNING ? visibleError : null; const warning = visibleError === REVOCATION_WARNING ? visibleError : null;
function openConnect() { function openConnect(accountId = "") {
if (operation.current) return; if (operation.current) return;
setBackground(statusRef.current.allowBackgroundUse); setBackground(statusRef.current.allowBackgroundUse);
setBackgroundDirty(false); setBackgroundDirty(false);
setRegistration(""); setRegistration(accountId);
setError(null); setError(null);
setDismissedError(status.error); setDismissedError(status.error);
showDialog({ kind: "connect" }); showDialog({ kind: "connect" });
@@ -254,12 +254,13 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
}; };
return <Context.Provider value={context}> return <Context.Provider value={context}>
{children} {children}
{dialog && <AccountDialog key={dialog.kind} onClose={close} busy={busy && operation.current !== "login"}> {dialog && <AccountDialog key={`${dialog.kind}:${dialog.kind === "manage" && !!dialog.confirm}`} onClose={close} busy={busy && operation.current !== "login"}>
<h2 className="text-sm font-semibold text-foreground">ChatGPT</h2>
{dialog.kind === "warning" ? <> {dialog.kind === "warning" ? <>
<p role="alert" className="rounded-sm border border-border px-3 py-2 text-xs text-foreground" data-testid="chatgpt-revocation-warning">{REVOCATION_WARNING}</p> <p role="alert" className="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(); showDialog(null); }}>Dismiss</Button></div> <div className="mt-4 flex justify-end"><Button variant="outline" size="sm" onClick={() => { context.dismissWarning(); showDialog(null); }}>Dismiss</Button></div>
</> : dialog.kind === "manage" && dialog.confirm ? <div data-testid="chatgpt-disconnect-confirmation"> </> : dialog.kind === "manage" && dialog.confirm ? <div data-testid="chatgpt-disconnect-confirmation">
<h2 className="text-sm font-semibold text-red-400">{CHATGPT_DISCONNECT_COPY.title}</h2> <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-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">{CHATGPT_DISCONNECT_COPY.body}</p>
{context.error && <p role="alert" className="mt-3 text-xs text-red-400">{context.error}</p>} {context.error && <p role="alert" className="mt-3 text-xs text-red-400">{context.error}</p>}
@@ -269,7 +270,6 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
</div> </div>
</div> : <> </div> : <>
{dialog.kind === "connect" ? <> {dialog.kind === "connect" ? <>
<h2 className="text-sm font-semibold text-foreground">Continue with ChatGPT</h2>
{status.accounts.length > 0 && <div className="mt-3 flex items-center gap-2 text-xs text-muted-foreground"> {status.accounts.length > 0 && <div className="mt-3 flex items-center gap-2 text-xs text-muted-foreground">
<select aria-label="Saved ChatGPT account" value={registration} onChange={(event) => setRegistration(event.target.value)} disabled={busy || status.pending} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1.5 text-foreground"> <select aria-label="Saved ChatGPT account" value={registration} onChange={(event) => setRegistration(event.target.value)} disabled={busy || status.pending} className="min-w-0 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>
@@ -278,16 +278,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
{registration && <Button variant="ghost" size="xs" disabled={busy} onClick={() => context.manage(registration)}>Manage…</Button>} {registration && <Button variant="ghost" size="xs" disabled={busy} onClick={() => context.manage(registration)}>Manage…</Button>}
</div>} </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">{CHATGPT_NOTICE}</p>
</> : managed && <> </> : null}
<div data-testid="chatgpt-managed-identity" className="flex items-center gap-2 text-xs text-foreground">
{status.accounts.length > 1 && managedPresent && !busy ? <select aria-label="Manage ChatGPT account" value={managed.id} onChange={(event) => context.manage(event.target.value)} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1.5">
{status.accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
</select> : <span>{managed.email || "ChatGPT account"}</span>}
</div>
<div className="mt-2 flex items-center" data-testid="chatgpt-account-actions">
<Button variant="ghost" size="xs" className="cursor-pointer text-red-400 hover:bg-red-500/10 hover:text-red-300" data-testid="chatgpt-disconnect" disabled={busy || status.pending || !managedPresent} onClick={() => dialog.kind === "manage" && showDialog({ ...dialog, confirm: true })}>Disconnect account</Button>
</div>
</>}
<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"> <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) => { <Toggle label={BACKGROUND_LABEL} checked={dialog.kind === "connect" ? background : status.allowBackgroundUse} disabled={busy || status.pending || !status.available} onChange={(enabled) => {
if (dialog.kind === "connect") { setBackground(enabled); setBackgroundDirty(true); } if (dialog.kind === "connect") { setBackground(enabled); setBackgroundDirty(true); }
@@ -295,7 +286,6 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
}} /> }} />
<span>{BACKGROUND_LABEL}<span className="mt-1 block leading-relaxed text-muted-foreground">{CHATGPT_BACKGROUND_HINT}</span></span> <span>{BACKGROUND_LABEL}<span className="mt-1 block leading-relaxed text-muted-foreground">{CHATGPT_BACKGROUND_HINT}</span></span>
</div> </div>
{dialog.kind === "manage" && <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 items-center gap-3 text-xs text-muted-foreground"> <div className="mt-3 flex flex-wrap items-center 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://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> <a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="underline underline-offset-2">Manage usage</a>
@@ -303,11 +293,21 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
{!status.available && <p className="mt-3 text-xs text-muted-foreground">ChatGPT sign-in is available only in the desktop app. 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. 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>} {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>} {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" data-testid="chatgpt-account-footer"> {dialog.kind === "manage" && managed && <div className="mt-4 flex flex-wrap items-center justify-between gap-3" data-testid="chatgpt-account-strip">
{dialog.kind === "manage" && <Button variant="outline" size="sm" data-testid="chatgpt-add-accounts" className="cursor-pointer disabled:cursor-not-allowed" onClick={openConnect} disabled={busy || status.pending || !status.available}>Add accounts</Button>} <div data-testid="chatgpt-managed-identity" className="flex min-w-0 items-center gap-2 text-xs text-foreground">
<Button variant="ghost" size="sm" onClick={close} disabled={busy && operation.current !== "login"}>{dialog.kind === "connect" ? "Cancel" : "Close"}</Button> {status.accounts.length > 1 && managedPresent && !busy ? <select aria-label="Manage ChatGPT account" value={managed.id} onChange={(event) => context.manage(event.target.value)} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1.5">
{status.accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
</select> : <span className="break-all">{managed.email || "ChatGPT account"}</span>}
</div>
<Button variant="ghost" size="sm" data-testid="chatgpt-add-accounts" className="cursor-pointer text-muted-foreground disabled:cursor-not-allowed" onClick={() => openConnect()} disabled={busy || status.pending || !status.available}>Add accounts</Button>
</div>}
<ConnectionFooter testid="chatgpt-account-footer" leading={dialog.kind === "manage" && <div data-testid="chatgpt-account-actions">
<Button variant="ghost" size="sm" className="cursor-pointer text-red-400 hover:bg-red-500/10 hover:text-red-300" data-testid="chatgpt-disconnect" disabled={busy || status.pending || !managedPresent} onClick={() => showDialog({ ...dialog, confirm: true })}>Disconnect</Button>
</div>}>
{dialog.kind === "manage" && managedPresent && managed && !managed.connected && status.available && <Button variant="outline" size="sm" disabled={busy || status.pending || !status.available} onClick={() => openConnect(managed.id)}>Sign in again</Button>}
<Button variant={dialog.kind === "manage" ? "outline" : "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" />} {dialog.kind === "connect" && <ContinueButton onClick={() => void login()} disabled={!status.available || busy || status.pending} testid="chatgpt-proceed" />}
</div> </ConnectionFooter>
</>} </>}
</AccountDialog>} </AccountDialog>}
</Context.Provider>; </Context.Provider>;
@@ -326,14 +326,14 @@ function AccountDialog({ children, onClose, busy }: { children: React.ReactNode;
return <ConnectionDialog label="ChatGPT account" testid="chatgpt-account-dialog" backdropTestid="chatgpt-dialog-backdrop" onClose={onClose} busy={busy}>{children}</ConnectionDialog>; return <ConnectionDialog label="ChatGPT account" testid="chatgpt-account-dialog" backdropTestid="chatgpt-dialog-backdrop" onClose={onClose} busy={busy}>{children}</ConnectionDialog>;
} }
/** Compact settings row, or setup's secondary action only. */ /** Shared compact settings/setup row; account selection precedes the quiet action. */
export function ChatGPTConnection({ setup = false }: { setup?: boolean }) { export function ChatGPTConnection(_props: { setup?: boolean } = {}) {
const ui = useContext(Context); const ui = useContext(Context);
const active = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected); const active = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected);
const accounts = ui.status.accounts.filter((a) => a.connected); const accounts = ui.status.accounts.filter((a) => a.connected);
return <div className="flex flex-col gap-2" data-testid="chatgpt-connection"> return <div className="flex flex-col gap-2" data-testid="chatgpt-connection">
<ConnectionRow label="ChatGPT" actionTestid={active ? undefined : "chatgpt-login"} action={active ? "Manage…" : "Connect"} disabled={!ui.status.available || ui.busy || ui.status.pending} onAction={() => active ? ui.manage(active.id) : ui.connect()}> <ConnectionRow label="ChatGPT" actionTestid={active ? undefined : "chatgpt-login"} action={active ? "Manage…" : "Connect"} disabled={!ui.status.available || ui.busy || ui.status.pending} onAction={() => active ? ui.manage(active.id) : ui.connect()}>
{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"> {active && <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>)} {accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
</select>} </select>}
</ConnectionRow> </ConnectionRow>
+8 -1
View File
@@ -8,7 +8,14 @@ export function ConnectionRow({ label, action, disabled, onAction, children, tes
return <div className="flex flex-wrap items-center gap-3" data-testid={testid}> return <div className="flex flex-wrap items-center gap-3" data-testid={testid}>
<span className="font-ui text-xs text-foreground">{label}</span> <span className="font-ui text-xs text-foreground">{label}</span>
{children} {children}
<Button type="button" size="xs" variant={action === "Connect" ? "outline" : "ghost"} className="cursor-pointer disabled:cursor-not-allowed" data-testid={actionTestid} disabled={disabled} onClick={onAction}>{action}</Button> <Button type="button" size="xs" variant={action === "Connect" ? "outline" : "ghost"} className={`cursor-pointer disabled:cursor-not-allowed ${action === "Connect" ? "" : "text-muted-foreground hover:text-foreground"}`} data-testid={actionTestid} disabled={disabled} onClick={onAction}>{action}</Button>
</div>;
}
export function ConnectionFooter({ leading, children, testid }: { leading?: React.ReactNode; children: React.ReactNode; testid?: string }) {
return <div className="mt-4 flex flex-wrap items-center justify-between gap-3" data-testid={testid}>
{leading}
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">{children}</div>
</div>; </div>;
} }
@@ -3,7 +3,7 @@ import { createContext, useContext, useEffect, useRef, useState } from "react";
import { NATIVE_SUBSCRIPTIONS, type NativeSubscriptionProviderId, type NativeSubscriptionStatus } from "@/src/core"; import { NATIVE_SUBSCRIPTIONS, type NativeSubscriptionProviderId, type NativeSubscriptionStatus } from "@/src/core";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { notifySettingsSaved } from "@/components/settings-signal"; import { notifySettingsSaved } from "@/components/settings-signal";
import { ConnectionDialog, ConnectionRow } from "./connection-controls"; import { ConnectionDialog, ConnectionFooter, ConnectionRow } from "./connection-controls";
import { ChatGPTConnection } from "./chatgpt-connection"; import { ChatGPTConnection } from "./chatgpt-connection";
import { Toggle } from "./controls"; import { Toggle } from "./controls";
@@ -18,12 +18,14 @@ const CONTROL_ERROR = "The connection could not be changed. Try again.";
export function NativeSubscriptionUIProvider({ children }: { children: React.ReactNode }) { export function NativeSubscriptionUIProvider({ children }: { children: React.ReactNode }) {
const [statuses, setStatuses] = useState<NativeSubscriptionStatus[]>([]); const [statuses, setStatuses] = useState<NativeSubscriptionStatus[]>([]);
const [dialog, setDialog] = useState<NativeSubscriptionProviderId | null>(null); const [dialog, setDialog] = useState<NativeSubscriptionProviderId | null>(null);
const [view, setView] = useState<"notice" | "connection" | "manage">("notice");
const [confirm, setConfirm] = useState(false), [background, setBackground] = useState(true), [backgroundDirty, setBackgroundDirty] = useState(false), [promptValue, setPromptValue] = useState(""); const [confirm, setConfirm] = useState(false), [background, setBackground] = useState(true), [backgroundDirty, setBackgroundDirty] = useState(false), [promptValue, setPromptValue] = useState("");
const [operation, setOperation] = useState<"login" | "control" | null>(null), [error, setError] = useState<string | null>(null); const [operation, setOperation] = useState<"login" | "control" | null>(null), [error, setError] = useState<string | null>(null);
const mounted = useRef(false), sequence = useRef(0), generation = useRef(0), statusesRef = useRef(statuses); const mounted = useRef(false), sequence = useRef(0), generation = useRef(0), statusesRef = useRef(statuses);
const descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.providerId === dialog); const descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.providerId === dialog);
const status = statuses.find((s) => s.providerId === dialog) ?? (dialog ? emptySubscriptionStatus(dialog) : undefined); const status = statuses.find((s) => s.providerId === dialog) ?? (dialog ? emptySubscriptionStatus(dialog) : undefined);
const interaction = status?.interaction, prompt = interaction?.prompt; const interaction = status?.interaction, prompt = interaction?.prompt;
const management = view === "manage";
const busy = operation === "control" || status?.committing === true; const busy = operation === "control" || status?.committing === true;
async function refresh() { async function refresh() {
const request = ++sequence.current; const request = ++sequence.current;
@@ -47,7 +49,8 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea
function open(providerId: NativeSubscriptionProviderId) { function open(providerId: NativeSubscriptionProviderId) {
if (operation || statusesRef.current.some((s) => s.pending)) return; if (operation || statusesRef.current.some((s) => s.pending)) return;
generation.current++; setError(null); setConfirm(false); setBackgroundDirty(false); generation.current++; setError(null); setConfirm(false); setBackgroundDirty(false);
setBackground(statusesRef.current.find((s) => s.providerId === providerId)?.allowBackgroundUse ?? true); setDialog(providerId); const current = statusesRef.current.find((s) => s.providerId === providerId);
setBackground(current?.allowBackgroundUse ?? true); setView(current?.connectionId ? "manage" : "notice"); setDialog(providerId);
} }
async function control(action: () => Promise<void>, close = false) { async function control(action: () => Promise<void>, close = false) {
if (busy) return; if (busy) return;
@@ -63,6 +66,10 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea
if ((status?.pending || operation === "login") && bridge) { void control(() => bridge.cancel(dialog), true); return; } if ((status?.pending || operation === "login") && bridge) { void control(() => bridge.cancel(dialog), true); return; }
generation.current++; setDialog(null); setError(null); generation.current++; setDialog(null); setError(null);
} }
function beginSignIn() {
if (operation || busy || !status) return;
generation.current++; setError(null); setConfirm(false); setBackground(status.allowBackgroundUse); setBackgroundDirty(false); setView("notice");
}
async function login() { async function login() {
const bridge = window.lh?.subscriptions; const bridge = window.lh?.subscriptions;
if (!dialog || !bridge || operation || !status?.available) return; if (!dialog || !bridge || operation || !status?.available) return;
@@ -74,18 +81,21 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea
const bridge = typeof window === "undefined" ? undefined : window.lh?.subscriptions; const bridge = typeof window === "undefined" ? undefined : window.lh?.subscriptions;
return <Context.Provider value={{ statuses, busy: !!operation, open }}> return <Context.Provider value={{ statuses, busy: !!operation, open }}>
{children} {children}
{dialog && descriptor && status && <ConnectionDialog label={`${descriptor.label} connection`} testid="subscription-connection-dialog" backdropTestid="subscription-dialog-backdrop" busy={busy} onClose={close}> {dialog && descriptor && status && <ConnectionDialog key={`${dialog}:${view}:${confirm}`} label={`${descriptor.label} connection`} testid="subscription-connection-dialog" backdropTestid="subscription-dialog-backdrop" busy={busy} onClose={close}>
<h2 className={`text-sm font-semibold ${confirm ? "text-red-400" : "text-foreground"}`}>{confirm ? `Disconnect ${descriptor.label}?` : status.connectionId ? descriptor.label : `Connect to ${descriptor.label}`}</h2> <h2 className="text-sm font-semibold text-foreground">{descriptor.label}</h2>
{confirm ? <p className="mt-3 text-xs leading-relaxed text-muted-foreground">{subscriptionDisconnectCopy(descriptor.label)}</p> : <> {confirm ? <>
<p className="mt-3 text-sm font-semibold text-red-400">Disconnect {descriptor.label}?</p>
<p className="mt-3 text-xs leading-relaxed text-muted-foreground">{subscriptionDisconnectCopy(descriptor.label)}</p>
</> : view === "notice" ? <>
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="subscription-privacy-notice">{subscriptionNotice(descriptor.label)}</p> <p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="subscription-privacy-notice">{subscriptionNotice(descriptor.label)}</p>
{dialog === "github-copilot" && <p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="copilot-policy-notice">{COPILOT_POLICY_NOTICE}</p>} {dialog === "github-copilot" && <p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="copilot-policy-notice">{COPILOT_POLICY_NOTICE}</p>}
</> : <>
<div className="mt-4 flex items-center gap-3 text-xs text-foreground"> <div className="mt-4 flex items-center gap-3 text-xs text-foreground">
<Toggle label="Allow background use" checked={status.connectionId ? status.allowBackgroundUse : background} disabled={busy || !!operation || status.pending || !status.available} onChange={(enabled) => { setBackground(enabled); if (status.connectionId && bridge) void control(() => bridge.consent(dialog, enabled)); else setBackgroundDirty(true); }} /> <Toggle label="Allow background use" checked={management ? status.allowBackgroundUse : background} disabled={busy || !!operation || status.pending || !status.available} onChange={(enabled) => { setBackground(enabled); if (management && bridge) void control(() => bridge.consent(dialog, enabled)); else setBackgroundDirty(true); }} />
<span>Allow background use<span className="mt-1 block leading-relaxed text-muted-foreground">{SUBSCRIPTION_BACKGROUND_HINT}</span></span> <span>Allow background use<span className="mt-1 block leading-relaxed text-muted-foreground">{SUBSCRIPTION_BACKGROUND_HINT}</span></span>
</div> </div>
{!status.available && <p className="mt-3 text-xs text-muted-foreground">Subscription sign-in requires the desktop app's private server connection. API-key connections remain available here.</p>} {!status.available && <p className="mt-3 text-xs text-muted-foreground">Subscription sign-in requires the desktop app's private server connection. API-key connections remain available here.</p>}
{status.connected && <p className="mt-3 text-xs text-muted-foreground">Connected</p>} {view === "connection" && interaction && <div className="mt-3 space-y-3 text-xs" data-testid="subscription-login-interaction">
{interaction && <div className="mt-3 space-y-3 text-xs" data-testid="subscription-login-interaction">
{interaction.progress && <p role="status" className="text-muted-foreground">{interaction.progress}</p>} {interaction.progress && <p role="status" className="text-muted-foreground">{interaction.progress}</p>}
{interaction.userCode && <p>Device code: <code className="select-text font-mono">{interaction.userCode}</code></p>} {interaction.userCode && <p>Device code: <code className="select-text font-mono">{interaction.userCode}</code></p>}
{interaction.browser && <Button variant="outline" size="sm" disabled={busy} onClick={() => bridge && void bridge.open(dialog, interaction.attemptId, interaction.browser!.id).catch(() => setError("The browser could not be opened. Try again."))}>Open browser</Button>} {interaction.browser && <Button variant="outline" size="sm" disabled={busy} onClick={() => bridge && void bridge.open(dialog, interaction.attemptId, interaction.browser!.id).catch(() => setError("The browser could not be opened. Try again."))}>Open browser</Button>}
@@ -97,15 +107,14 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea
</div>} </div>}
</>} </>}
{(error || status.error) && <p role="alert" className="mt-3 text-xs text-red-400">{error || status.error}</p>} {(error || status.error) && <p role="alert" className="mt-3 text-xs text-red-400">{error || status.error}</p>}
<div className="mt-4 flex flex-wrap items-center justify-end gap-2"> <ConnectionFooter testid="subscription-connection-footer" leading={!confirm && management && status.connectionId && !status.pending && <Button variant="ghost" size="sm" className="cursor-pointer text-red-400 hover:bg-red-500/10 hover:text-red-300" disabled={busy || !!operation} onClick={() => setConfirm(true)}>Disconnect</Button>}>
{!confirm && status.connectionId && !status.pending && <> {!confirm && management && status.connectionId && status.available && !status.connected && !!status.error && !status.pending && <>
<Button variant="ghost" size="sm" disabled={busy || !!operation} onClick={() => setConfirm(true)}>Disconnect</Button>
{status.error && <Button variant="outline" size="sm" disabled={busy || !!operation} onClick={() => bridge && void control(() => bridge.retry(dialog))}>Retry</Button>} {status.error && <Button variant="outline" size="sm" disabled={busy || !!operation} onClick={() => bridge && void control(() => bridge.retry(dialog))}>Retry</Button>}
<Button variant="outline" size="sm" disabled={!status.available || busy || !!operation} onClick={() => void login()}>Reconnect</Button> <Button variant="outline" size="sm" disabled={!status.available || busy || !!operation} onClick={beginSignIn}>Sign in again</Button>
</>} </>}
<Button variant="ghost" size="sm" disabled={busy} onClick={close}>{confirm || !status.connectionId || status.pending ? "Cancel" : "Close"}</Button> <Button variant={!confirm && management ? "outline" : "ghost"} size="sm" disabled={busy} onClick={close}>{confirm || !management ? "Cancel" : "Close"}</Button>
{confirm ? <Button variant="destructive" size="sm" disabled={busy} onClick={() => bridge && void control(() => bridge.disconnect(dialog), true)}>Disconnect</Button> : !status.connectionId && <Button variant="outline" size="sm" disabled={!status.available || !!operation || status.pending} onClick={() => void login()}>Connect</Button>} {confirm ? <Button variant="destructive" size="sm" disabled={busy} onClick={() => bridge && void control(() => bridge.disconnect(dialog), true)}>Disconnect</Button> : view === "notice" ? <Button variant="outline" size="sm" disabled={!status.available || !!operation} onClick={() => setView("connection")}>Continue</Button> : view === "connection" && <Button variant="outline" size="sm" disabled={!status.available || !!operation || status.pending} onClick={() => void login()}>Connect</Button>}
</div> </ConnectionFooter>
</ConnectionDialog>} </ConnectionDialog>}
</Context.Provider>; </Context.Provider>;
} }
@@ -114,9 +123,7 @@ export function NativeSubscriptionConnection({ providerId }: { providerId: Nativ
const ui = useContext(Context), descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.providerId === providerId)!; const ui = useContext(Context), descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.providerId === providerId)!;
const status = ui.statuses.find((s) => s.providerId === providerId) ?? emptySubscriptionStatus(providerId); const status = ui.statuses.find((s) => s.providerId === providerId) ?? emptySubscriptionStatus(providerId);
return <div className="flex flex-col gap-2" data-testid={`${providerId}-connection`}> return <div className="flex flex-col gap-2" data-testid={`${providerId}-connection`}>
<ConnectionRow label={descriptor.label} action={status.connectionId ? "Manage…" : "Connect"} disabled={ui.busy || !status.available || ui.statuses.some((s) => s.pending)} onAction={() => ui.open(providerId)}> <ConnectionRow label={descriptor.label} action={status.connectionId ? "Manage…" : "Connect"} disabled={ui.busy || !status.available || ui.statuses.some((s) => s.pending)} onAction={() => ui.open(providerId)} />
{status.connected && <span className="text-xs text-muted-foreground">Connected</span>}
</ConnectionRow>
{status.error && <p role="alert" className="text-xs text-red-400">{status.error}</p>} {status.error && <p role="alert" className="text-xs text-red-400">{status.error}</p>}
</div>; </div>;
} }
+34 -7
View File
@@ -88,6 +88,9 @@ test("disconnected row uses shared Connect action; initial default is ON; cancel
assert(!document.body.textContent?.includes(CHATGPT_NOTICE)); const trigger = byTest("chatgpt-login"); assert(!document.body.textContent?.includes(CHATGPT_NOTICE)); 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/); 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.length, 0); assert(document.body.textContent?.includes(CHATGPT_NOTICE)); await click(trigger); assert.equal(f.calls.login.length, 0); assert(document.body.textContent?.includes(CHATGPT_NOTICE));
assert.equal(byTest("chatgpt-account-dialog").querySelector("h2")?.textContent, "ChatGPT");
assert.equal(byTest("chatgpt-proceed").textContent, "Continue with ChatGPT"); assert(byTest("chatgpt-proceed").querySelector('img[src="/siwc/chatgpt-logo-white.svg"]'));
assert(!button("Continue", byTest("chatgpt-account-dialog")), "ChatGPT remains one step, with disclosure and toggle together");
assert.equal(toggle().getAttribute("aria-checked"), "true"); assert.equal(toggle().getAttribute("aria-checked"), "true");
assert.equal(toggle().getAttribute("aria-label"), "Allow background use for all ChatGPT accounts"); assert.equal(toggle().getAttribute("aria-label"), "Allow background use for all ChatGPT accounts");
assert(byTest("chatgpt-global-background").textContent?.includes("Background work counts toward your ChatGPT plan usage.")); assert(byTest("chatgpt-global-background").textContent?.includes("Background work counts toward your ChatGPT plan usage."));
@@ -180,23 +183,33 @@ test("email-only connected selector and Manage remain responsive; account select
assert.deepEqual([...document.querySelectorAll('select[aria-label="ChatGPT account"] option')].map((o) => o.textContent), ["learner@example.invalid", "second@example.invalid", "ChatGPT account"]); assert.deepEqual([...document.querySelectorAll('select[aria-label="ChatGPT account"] option')].map((o) => o.textContent), ["learner@example.invalid", "second@example.invalid", "ChatGPT account"]);
assert.doesNotMatch(byTest("chatgpt-connection").textContent!, /private-registration|private-issued| · active|signed-out/); assert.doesNotMatch(byTest("chatgpt-connection").textContent!, /private-registration|private-issued| · active|signed-out/);
assert.match(byTest("chatgpt-connection").firstElementChild!.className, /items-center/); assert.match(byTest("chatgpt-connection").firstElementChild!.className, /items-center/);
const row = byTest("chatgpt-connection").firstElementChild!;
assert.equal(row.children[0]!.textContent, "ChatGPT"); assert.equal(row.children[1]!.tagName, "SELECT"); assert.equal(row.children[2]!.textContent, "Manage…");
assert.match(row.children[2]!.className, /text-muted-foreground/); assert(!row.textContent?.includes("Connected"));
await selectAccount("ChatGPT account", "second"); assert.deepEqual(f.calls.select, ["second"]); assert.equal(f.status.allowBackgroundUse, false); await selectAccount("ChatGPT account", "second"); assert.deepEqual(f.calls.select, ["second"]); assert.equal(f.status.allowBackgroundUse, false);
await click(button("Manage…")); assert(byTest("chatgpt-managed-identity").textContent?.includes("second@example.invalid")); await click(button("Manage…")); assert(byTest("chatgpt-managed-identity").textContent?.includes("second@example.invalid"));
assert.deepEqual([...byTest("chatgpt-account-footer").querySelectorAll("button")].map((b) => b.textContent), ["Add accounts", "Close"]); assert.deepEqual([...byTest("chatgpt-account-footer").querySelectorAll("button")].map((b) => b.textContent), ["Disconnect", "Close"]);
const dialog = byTest("chatgpt-account-dialog").innerHTML; const dialog = byTest("chatgpt-account-dialog").innerHTML;
assert(dialog.indexOf('data-testid="chatgpt-managed-identity"') < dialog.indexOf('data-testid="chatgpt-account-actions"')); assert(dialog.indexOf('data-testid="chatgpt-managed-identity"') < dialog.indexOf('data-testid="chatgpt-account-actions"'));
assert(dialog.indexOf('data-testid="chatgpt-account-actions"') < dialog.indexOf('data-testid="chatgpt-global-background"')); assert(dialog.indexOf('data-testid="chatgpt-global-background"') < dialog.indexOf('data-testid="chatgpt-managed-identity"'));
assert(!byTest("chatgpt-account-dialog").querySelector('[data-testid="chatgpt-privacy-notice"]'));
assert.equal(byTest("chatgpt-account-dialog").querySelector("h2")?.textContent, "ChatGPT");
assert(byTest("chatgpt-account-dialog").querySelector('a[href="https://openai.com/policies/privacy-policy/"]'));
assert(byTest("chatgpt-account-dialog").querySelector('a[href="https://chatgpt.com/settings/usage"]'));
await click(toggle()); assert.deepEqual(f.calls.consent, [true]); assert.equal(toggle().getAttribute("aria-checked"), "true"); await click(toggle()); assert.deepEqual(f.calls.consent, [true]); assert.equal(toggle().getAttribute("aria-checked"), "true");
await click(button("Close")); await selectAccount("ChatGPT account", "private-registration-id"); assert.equal(f.status.allowBackgroundUse, true); await click(button("Close")); await selectAccount("ChatGPT account", "private-registration-id"); assert.equal(f.status.allowBackgroundUse, true);
assert.equal(f.calls.logout, 0); assert.equal(f.calls.logout, 0);
}); });
test("Disconnect account stays under identity, outside the nondestructive Manage footer; confirmation Cancel never forgets", async () => { test("Disconnect is the quiet red left-footer action below identity; confirmation Cancel never forgets", async () => {
assert.deepEqual(CHATGPT_DISCONNECT_COPY, { title: "Disconnect account?", body: "This removes the saved account and its credentials from this device." }); assert.deepEqual(CHATGPT_DISCONNECT_COPY, { title: "Disconnect account?", body: "This removes the saved account and its credentials from this device." });
const f = fixture(connected()); await mount(); await click(button("Manage…")); const f = fixture(connected()); await mount(); await click(button("Manage…"));
assert.equal(byTest("chatgpt-account-actions").textContent, "Disconnect account"); assert.match(byTest("chatgpt-disconnect").className, /text-red/); assert.equal(byTest("chatgpt-account-actions").textContent, "Disconnect"); assert.match(byTest("chatgpt-disconnect").className, /text-red/);
assert(!byTest("chatgpt-account-footer").contains(byTest("chatgpt-disconnect"))); assert.equal(byTest("chatgpt-account-footer").firstElementChild, byTest("chatgpt-account-actions"));
assert.equal(byTest("chatgpt-account-footer").lastElementChild?.lastElementChild, button("Close"));
assert(!byTest("chatgpt-account-footer").contains(byTest("chatgpt-add-accounts")));
await click(byTest("chatgpt-disconnect")); assert.equal(f.calls.disconnect.length, 0); await click(byTest("chatgpt-disconnect")); assert.equal(f.calls.disconnect.length, 0);
assert.equal(byTest("chatgpt-account-dialog").querySelector("h2")?.textContent, "ChatGPT");
assert(byTest("chatgpt-disconnect-confirmation").textContent?.includes(CHATGPT_DISCONNECT_COPY.title)); assert(byTest("chatgpt-disconnect-confirmation").textContent?.includes(CHATGPT_DISCONNECT_COPY.title));
assert(byTest("chatgpt-disconnect-confirmation").textContent?.includes(CHATGPT_DISCONNECT_COPY.body)); assert(byTest("chatgpt-disconnect-confirmation").textContent?.includes(CHATGPT_DISCONNECT_COPY.body));
assert(!document.body.textContent?.includes("delete your OpenAI")); assert(!document.body.textContent?.includes("delete your OpenAI"));
@@ -208,8 +221,9 @@ for (const count of [1, 3]) test(`Add accounts reuses the actual add flow with $
const initial = connected(count === 3); const initial = connected(count === 3);
initial.accounts = [account(), ...Array.from({ length: count - 1 }, (_, i) => account(`saved-${i}`, `saved-${i}@example.invalid`))]; initial.accounts = [account(), ...Array.from({ length: count - 1 }, (_, i) => account(`saved-${i}`, `saved-${i}@example.invalid`))];
const f = fixture(initial); const before = structuredClone(initial); await mount(); await click(button("Manage…")); const f = fixture(initial); const before = structuredClone(initial); await mount(); await click(button("Manage…"));
assert.deepEqual([...byTest("chatgpt-account-footer").querySelectorAll("button")].map((b) => b.textContent), ["Add accounts", "Close"]); assert.deepEqual([...byTest("chatgpt-account-footer").querySelectorAll("button")].map((b) => b.textContent), ["Disconnect", "Close"]);
assert.equal(byTest("chatgpt-add-accounts").getAttribute("data-variant"), "outline"); assert.equal(byTest("chatgpt-add-accounts").getAttribute("data-variant"), "ghost");
assert.equal(byTest("chatgpt-add-accounts").parentElement, byTest("chatgpt-account-strip"));
assert.match(byTest("chatgpt-add-accounts").className, /cursor-pointer/); assert.match(byTest("chatgpt-add-accounts").className, /cursor-pointer/);
await click(byTest("chatgpt-add-accounts")); await click(byTest("chatgpt-add-accounts"));
assert(byTest("chatgpt-privacy-notice").textContent?.includes(CHATGPT_NOTICE)); assert(byTest("chatgpt-proceed")); assert(byTest("chatgpt-privacy-notice").textContent?.includes(CHATGPT_NOTICE)); assert(byTest("chatgpt-proceed"));
@@ -226,6 +240,19 @@ for (const count of [1, 3]) test(`Add accounts reuses the actual add flow with $
assert.deepEqual(f.status, before); assert.equal(f.calls.cancel, 1); assert.deepEqual(f.calls.consent, []); assert.deepEqual(f.calls.disconnect, []); assert.deepEqual(f.status, before); assert.equal(f.calls.cancel, 1); assert.deepEqual(f.calls.consent, []); assert.deepEqual(f.calls.disconnect, []);
}); });
test("ChatGPT Sign in again is reserved for a saved disconnected account and keeps its existing registration flow", async () => {
const f = fixture(connected(false)); await mount(); await click(button("Manage…"));
assert(!button("Sign in again")); await selectAccount("Manage ChatGPT account", "signed-out-record"); assert(button("Sign in again"));
await click(button("Sign in again")); assert.equal(byTest("chatgpt-account-dialog").querySelector("h2")?.textContent, "ChatGPT");
assert(byTest("chatgpt-privacy-notice")); assert(toggle()); assert.equal(toggle().getAttribute("aria-checked"), "false");
assert.equal(document.querySelector<HTMLSelectElement>('select[aria-label="Saved ChatGPT account"]')?.value, "signed-out-record"); assert.deepEqual(f.calls.login, []);
await click(byTest("chatgpt-proceed")); assert.deepEqual(f.calls.login, ["signed-out-record"]); assert.equal(f.status.activeId, "private-registration-id");
await click(button("Cancel", byTest("chatgpt-account-dialog"))); assert.equal(f.calls.cancel, 1); assert.deepEqual(f.calls.disconnect, []); assert.deepEqual(f.calls.consent, []);
});
test("connected setup row retains the email dropdown before muted Manage", async () => {
fixture(connected()); await mount(<ChatGPTConnection setup />);
const row = byTest("chatgpt-connection").firstElementChild!; assert.equal(row.children[1]!.tagName, "SELECT"); assert.equal(row.children[2]!.textContent, "Manage…"); assert.match(row.children[2]!.className, /text-muted-foreground/);
});
test("Add accounts reuses the existing dialog's initial focus for the pre-auth transition", async () => { test("Add accounts reuses the existing dialog's initial focus for the pre-auth transition", async () => {
const previous = HTMLElement.prototype.focus; const focused: HTMLElement[] = []; const previous = HTMLElement.prototype.focus; const focused: HTMLElement[] = [];
HTMLElement.prototype.focus = function () { focused.push(this); }; HTMLElement.prototype.focus = function () { focused.push(this); };
+72 -20
View File
@@ -15,7 +15,7 @@ function deferred() { let resolve!: () => void; const promise = new Promise<void
function fixture(connected = false) { function fixture(connected = false) {
let statuses: NativeSubscriptionStatus[] = ["github-copilot", "xai"].map((id) => ({ ...emptySubscriptionStatus(id as NativeSubscriptionProviderId), available: true, connected, connectionId: connected ? `local-${id}` : null })); let statuses: NativeSubscriptionStatus[] = ["github-copilot", "xai"].map((id) => ({ ...emptySubscriptionStatus(id as NativeSubscriptionProviderId), available: true, connected, connectionId: connected ? `local-${id}` : null }));
const listeners = new Set<() => void>(), login = deferred(); const listeners = new Set<() => void>(), login = deferred();
const calls = { login: [] as unknown[][], cancel: 0, open: [] as unknown[][], consent: [] as unknown[][], disconnect: [] as string[] }; const calls = { login: [] as unknown[][], cancel: 0, open: [] as unknown[][], consent: [] as unknown[][], disconnect: [] as string[], retry: [] as string[] };
function push(id: NativeSubscriptionProviderId, next: Partial<NativeSubscriptionStatus>) { function push(id: NativeSubscriptionProviderId, next: Partial<NativeSubscriptionStatus>) {
statuses = statuses.map((s) => s.providerId === id ? { ...s, revision: s.revision + 1, ...next } : s); for (const listener of listeners) listener(); statuses = statuses.map((s) => s.providerId === id ? { ...s, revision: s.revision + 1, ...next } : s); for (const listener of listeners) listener();
} }
@@ -23,7 +23,7 @@ function fixture(connected = false) {
status: async () => structuredClone(statuses), status: async () => structuredClone(statuses),
login: async (id, enabled) => { calls.login.push([id, enabled]); push(id, { pending: true, interaction: { attemptId: "attempt", userCode: "PUBLIC-CODE", browser: { id: "opaque-action", url: "https://auth.example/device" }, progress: "Complete sign-in in your browser." } }); await login.promise; }, login: async (id, enabled) => { calls.login.push([id, enabled]); push(id, { pending: true, interaction: { attemptId: "attempt", userCode: "PUBLIC-CODE", browser: { id: "opaque-action", url: "https://auth.example/device" }, progress: "Complete sign-in in your browser." } }); await login.promise; },
cancel: async (id) => { calls.cancel++; push(id, { pending: false, interaction: undefined }); }, cancel: async (id) => { calls.cancel++; push(id, { pending: false, interaction: undefined }); },
respond: async () => {}, open: async (...args) => { calls.open.push(args); }, retry: async () => {}, respond: async () => {}, open: async (...args) => { calls.open.push(args); }, retry: async (id) => { calls.retry.push(id); },
disconnect: async (id) => { calls.disconnect.push(id); push(id, { connected: false, connectionId: null }); }, disconnect: async (id) => { calls.disconnect.push(id); push(id, { connected: false, connectionId: null }); },
consent: async (id, enabled) => { calls.consent.push([id, enabled]); push(id, { allowBackgroundUse: enabled }); }, consent: async (id, enabled) => { calls.consent.push([id, enabled]); push(id, { allowBackgroundUse: enabled }); },
onChange: (listener) => { listeners.add(listener); return () => { listeners.delete(listener); }; }, onChange: (listener) => { listeners.add(listener); return () => { listeners.delete(listener); }; },
@@ -35,10 +35,14 @@ async function mount() {
host = document.createElement("div"); document.body.append(host); root = createRoot(host); host = document.createElement("div"); document.body.append(host); root = createRoot(host);
await act(async () => root!.render(<NativeSubscriptionUIProvider><NativeSubscriptionConnection providerId="github-copilot" /><NativeSubscriptionConnection providerId="xai" /></NativeSubscriptionUIProvider>)); await act(async () => root!.render(<NativeSubscriptionUIProvider><NativeSubscriptionConnection providerId="github-copilot" /><NativeSubscriptionConnection providerId="xai" /></NativeSubscriptionUIProvider>));
} }
function button(text: string, scope: ParentNode = document) { const found = [...scope.querySelectorAll<HTMLButtonElement>("button")].find((element) => element.textContent === text); assert(found, `missing button ${text}`); return found; } function maybeButton(text: string, scope: ParentNode = document) { return [...scope.querySelectorAll<HTMLButtonElement>("button")].find((element) => element.textContent === text); }
function button(text: string, scope: ParentNode = document) { const found = maybeButton(text, scope); assert(found, `missing button ${text}`); return found; }
const row = (id: string) => document.querySelector<HTMLElement>(`[data-testid="${id}-connection"]`)!; const row = (id: string) => document.querySelector<HTMLElement>(`[data-testid="${id}-connection"]`)!;
const dialog = () => document.querySelector<HTMLElement>('[data-testid="subscription-connection-dialog"]')!; const dialog = () => document.querySelector<HTMLElement>('[data-testid="subscription-connection-dialog"]')!;
const toggle = () => dialog().querySelector<HTMLElement>('[role="switch"]')!;
const footer = () => dialog().querySelector<HTMLElement>('[data-testid="subscription-connection-footer"]')!;
async function click(element: HTMLElement) { assert(element); await act(async () => element.click()); } async function click(element: HTMLElement) { assert(element); await act(async () => element.click()); }
async function connectionStep(id: string) { await click(button("Connect", row(id))); await click(button("Continue", dialog())); }
afterEach(async () => { await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined; delete window.lh; }); afterEach(async () => { await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined; delete window.lh; });
test("three compact rows render in approved order with no browser-only sign-in or fabricated identities", () => { test("three compact rows render in approved order with no browser-only sign-in or fabricated identities", () => {
@@ -48,34 +52,82 @@ test("three compact rows render in approved order with no browser-only sign-in o
assert.equal((html.match(/>Connect<\/button>/g) ?? []).length, 3); assert.equal((html.match(/>Connect<\/button>/g) ?? []).length, 3);
assert(!html.includes("@")); assert(!html.includes("Add accounts")); assert(!html.includes("@")); assert(!html.includes("Add accounts"));
}); });
test("Copilot disclosure and default-ON draft appear before login; browser action passes only opaque IDs", async () => { for (const [id, label] of [["github-copilot", "GitHub Copilot"], ["xai", "Grok"]] as const) test(`${id}: disclosure and connection are separate steps with no duplicated content or early authorization`, async () => {
const f = fixture(); await mount(); await click(button("Connect", row("github-copilot"))); const f = fixture(); await mount(); await click(button("Connect", row(id)));
assert(dialog().textContent?.includes(COPILOT_POLICY_NOTICE)); assert.equal(f.calls.login.length, 0); assert.equal(dialog().querySelector("h2")?.textContent, label);
const toggle = dialog().querySelector<HTMLElement>('[role="switch"]')!; assert.equal(toggle.getAttribute("aria-checked"), "true"); assert(dialog().querySelector('[data-testid="subscription-privacy-notice"]')); assert(!toggle());
await click(toggle); await click(button("Connect", dialog())); assert.deepEqual(f.calls.login, [["github-copilot", false]]); assert.equal(!!dialog().querySelector('[data-testid="copilot-policy-notice"]'), id === "github-copilot");
assert(dialog().textContent?.includes("PUBLIC-CODE")); await click(button("Open browser", dialog())); if (id === "github-copilot") assert(dialog().textContent?.includes(COPILOT_POLICY_NOTICE));
assert.deepEqual(f.calls.open, [["github-copilot", "attempt", "opaque-action"]]); assert(!maybeButton("Connect", dialog())); assert.deepEqual(f.calls.login, []); assert.deepEqual(f.calls.consent, []);
await click(button("Continue", dialog()));
assert.equal(dialog().querySelector("h2")?.textContent, label); assert.equal(toggle().getAttribute("aria-checked"), "true");
assert(!dialog().querySelector('[data-testid="subscription-privacy-notice"], [data-testid="copilot-policy-notice"]'));
assert.equal(f.calls.login.length, 0); await click(toggle()); assert.deepEqual(f.calls.consent, []);
await click(button("Connect", dialog())); assert.deepEqual(f.calls.login, [[id, false]]);
assert(dialog().textContent?.includes("PUBLIC-CODE")); assert(!dialog().querySelector('[data-testid="subscription-privacy-notice"]'));
await click(button("Open browser", dialog())); assert.deepEqual(f.calls.open, [[id, "attempt", "opaque-action"]]);
await click(button("Cancel", dialog())); assert.equal(f.calls.cancel, 1); assert(!dialog()); await click(button("Cancel", dialog())); assert.equal(f.calls.cancel, 1); assert(!dialog());
await click(button("Connect", row("xai"))); const newer = dialog(); await act(async () => f.login.resolve()); await click(button("Connect", row(id === "xai" ? "github-copilot" : "xai"))); const newer = dialog(); await act(async () => f.login.resolve());
assert(dialog() === newer, "old completion cannot close another provider dialog"); assert(dialog() === newer, "old completion cannot close another provider dialog");
}); });
test("single native connection management has no account controls; background changes are independent and disconnect is confirmed", async () => { for (const phase of ["notice", "connection"] as const) test(`cancelling the native ${phase} step starts no OAuth and saves no draft preference`, async () => {
const f = fixture(true); await mount(); await click(button("Manage…", row("xai"))); const f = fixture(); await mount(); await click(button("Connect", row("xai")));
assert(!dialog().querySelector("select")); assert(!dialog().textContent?.includes("Add accounts")); assert(button("Reconnect", dialog())); if (phase === "connection") { await click(button("Continue", dialog())); await click(toggle()); }
await click(dialog().querySelector<HTMLElement>('[role="switch"]')!); assert.deepEqual(f.calls.consent, [["xai", false]]); await click(button("Cancel", dialog())); assert(!dialog()); assert.deepEqual(f.calls.login, []); assert.deepEqual(f.calls.consent, []); assert.equal(f.calls.cancel, 0);
assert.equal(f.statuses.find((s) => s.providerId === "github-copilot")!.allowBackgroundUse, true); await connectionStep("xai"); assert.equal(toggle().getAttribute("aria-checked"), "true");
});
test("native step transition moves keyboard focus into the new connection controls", async () => {
const original = HTMLElement.prototype.focus, focused: HTMLElement[] = [];
HTMLElement.prototype.focus = function () { focused.push(this); };
try { fixture(); await mount(); await connectionStep("xai"); assert.equal(focused.at(-1), toggle()); }
finally { HTMLElement.prototype.focus = original; }
});
test("healthy management has only controls, quiet Manage, destructive action left and Close right", async () => {
const f = fixture(true); await mount();
assert(!row("xai").textContent?.includes("Connected")); assert.match(button("Manage…", row("xai")).className, /text-muted-foreground/);
await click(button("Manage…", row("xai")));
assert.equal(dialog().querySelector("h2")?.textContent, "Grok");
assert(!dialog().querySelector("select")); assert(!dialog().textContent?.includes("Add accounts"));
assert(!dialog().querySelector('[data-testid="subscription-privacy-notice"]')); assert(!maybeButton("Reconnect", dialog())); assert(!maybeButton("Sign in again", dialog()));
assert.equal(footer().firstElementChild, button("Disconnect", dialog())); assert.match(button("Disconnect", dialog()).className, /text-red/);
assert.equal(footer().lastElementChild?.lastElementChild, button("Close", dialog()));
await click(toggle()); assert.deepEqual(f.calls.consent, [["xai", false]]); assert.equal(f.statuses.find((s) => s.providerId === "github-copilot")!.allowBackgroundUse, true);
await click(button("Disconnect", dialog())); assert.equal(f.calls.disconnect.length, 0); await click(button("Disconnect", dialog())); assert.equal(f.calls.disconnect.length, 0);
assert(dialog().textContent?.includes("does not cancel your subscription or revoke access")); assert.equal(dialog().querySelector("h2")?.textContent, "Grok"); assert(dialog().textContent?.includes("does not cancel your subscription or revoke access"));
await click(button("Cancel", dialog())); assert(button("Reconnect", dialog())); await click(button("Cancel", dialog())); assert(toggle()); assert(!maybeButton("Reconnect", dialog()));
await click(button("Disconnect", dialog())); await click(button("Disconnect", dialog())); assert.deepEqual(f.calls.disconnect, ["xai"]); assert(!dialog()); await click(button("Disconnect", dialog())); await click(button("Disconnect", dialog())); assert.deepEqual(f.calls.disconnect, ["xai"]); assert(!dialog());
}); });
test("authorization trouble offers Retry and staged Sign in again without removing the saved connection", async () => {
const f = fixture(true); f.push("xai", { connected: false, allowBackgroundUse: false, error: "The connection could not be refreshed." });
await mount(); await click(button("Manage…", row("xai"))); await click(button("Retry", dialog())); assert.deepEqual(f.calls.retry, ["xai"]);
await click(button("Sign in again", dialog())); assert(dialog().querySelector('[data-testid="subscription-privacy-notice"]')); assert(!toggle());
assert.deepEqual(f.calls.login, []); assert.deepEqual(f.calls.disconnect, []); assert.equal(f.statuses.find((s) => s.providerId === "xai")!.connectionId, "local-xai");
await click(button("Continue", dialog())); assert.equal(toggle().getAttribute("aria-checked"), "false");
await click(toggle()); await click(button("Connect", dialog())); assert.deepEqual(f.calls.login, [["xai", true]]); assert.deepEqual(f.calls.consent, []);
await click(button("Cancel", dialog())); assert.equal(f.calls.cancel, 1); assert.deepEqual(f.calls.disconnect, []); assert.equal(f.statuses.find((s) => s.providerId === "xai")!.allowBackgroundUse, false);
await act(async () => f.login.resolve());
});
test("a non-authority error on a still-working connection does not expose a healthy reauthorization action", async () => {
const f = fixture(true); f.push("xai", { error: "The replacement sign-in did not complete." });
await mount(); await click(button("Manage…", row("xai"))); assert(!maybeButton("Sign in again", dialog())); assert(!maybeButton("Retry", dialog()));
});
test("storage/desktop unavailability is not presented as an authorization repair action", async () => {
const f = fixture(true); await mount(); await click(button("Manage…", row("xai")));
await act(async () => f.push("xai", { available: false, connected: false, error: "The saved connection could not be read or written safely." }));
assert(!maybeButton("Sign in again", dialog())); assert(!maybeButton("Retry", dialog())); assert(button("Close", dialog()));
});
test("required native prompts render and commit cannot be cancelled through button, Escape or backdrop", async () => { test("required native prompts render and commit cannot be cancelled through button, Escape or backdrop", async () => {
const f = fixture(); await mount(); await click(button("Connect", row("github-copilot"))); await click(button("Connect", dialog())); const f = fixture(); await mount(); await connectionStep("github-copilot"); await click(button("Connect", dialog()));
await act(async () => f.push("github-copilot", { interaction: { attemptId: "attempt", prompt: { id: "enterprise", type: "text", message: "Enterprise domain (leave blank for github.com)", placeholder: "company.ghe.com" } } })); await act(async () => f.push("github-copilot", { interaction: { attemptId: "attempt", prompt: { id: "enterprise", type: "text", message: "Enterprise domain (leave blank for github.com)", placeholder: "company.ghe.com" } } }));
assert.equal(dialog().querySelector("input")?.getAttribute("placeholder"), "company.ghe.com"); assert(button("Continue", dialog())); assert.equal(dialog().querySelector("input")?.getAttribute("placeholder"), "company.ghe.com"); assert(button("Continue", dialog()));
assert(!dialog().querySelector('[data-testid="subscription-privacy-notice"]'));
await act(async () => f.push("github-copilot", { committing: true, interaction: undefined })); await act(async () => f.push("github-copilot", { committing: true, interaction: undefined }));
assert(button("Cancel", dialog()).disabled); await click(button("Cancel", dialog())); assert.equal(f.calls.cancel, 0); assert(dialog()); assert(button("Cancel", dialog()).disabled); await click(button("Cancel", dialog())); assert.equal(f.calls.cancel, 0); assert(dialog());
await act(async () => { const event = new window.Event("keydown", { bubbles: true }); Object.defineProperty(event, "key", { value: "Escape" }); document.dispatchEvent(event); }); await act(async () => { const event = new window.Event("keydown", { bubbles: true }); Object.defineProperty(event, "key", { value: "Escape" }); document.dispatchEvent(event); });
assert(dialog()); assert.equal(f.calls.cancel, 0); assert(dialog()); assert.equal(f.calls.cancel, 0);
await act(async () => f.login.resolve()); await act(async () => {
const backdrop = document.querySelector<HTMLElement>('[data-testid="subscription-dialog-backdrop"]')!;
for (const type of ["pointerdown", "pointerup", "click"]) { const event = new window.Event(type, { bubbles: true }); Object.defineProperties(event, { pointerId: { value: 1 }, isPrimary: { value: true }, button: { value: 0 } }); backdrop.dispatchEvent(event); }
});
assert(dialog()); assert.equal(f.calls.cancel, 0); await act(async () => f.login.resolve());
}); });