From 55a6501a09b2c03b70132674bbeabe2faa747eb3 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sun, 25 Oct 2026 01:34:46 +0300 Subject: [PATCH] fix: streamline subscription connection dialogs and actions --- components/settings/chatgpt-connection.tsx | 48 +++++----- components/settings/connection-controls.tsx | 9 +- .../settings/subscription-connections.tsx | 43 +++++---- tests/chatgpt-connection-ui.test.tsx | 41 +++++++-- tests/subscription-connection-ui.test.tsx | 92 +++++++++++++++---- 5 files changed, 163 insertions(+), 70 deletions(-) diff --git a/components/settings/chatgpt-connection.tsx b/components/settings/chatgpt-connection.tsx index f811c70..a87d06d 100644 --- a/components/settings/chatgpt-connection.tsx +++ b/components/settings/chatgpt-connection.tsx @@ -2,7 +2,7 @@ import { createContext, useContext, useEffect, useRef, useState } from "react"; 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 { 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 visibleError = error || (status.error !== dismissedError ? status.error : null) || status.catalogError || null; const warning = visibleError === REVOCATION_WARNING ? visibleError : null; - function openConnect() { + function openConnect(accountId = "") { if (operation.current) return; setBackground(statusRef.current.allowBackgroundUse); setBackgroundDirty(false); - setRegistration(""); + setRegistration(accountId); setError(null); setDismissedError(status.error); showDialog({ kind: "connect" }); @@ -254,12 +254,13 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { }; return {children} - {dialog && + {dialog && +

ChatGPT

{dialog.kind === "warning" ? <>

{REVOCATION_WARNING}

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

{CHATGPT_DISCONNECT_COPY.title}

+

{CHATGPT_DISCONNECT_COPY.title}

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

{CHATGPT_DISCONNECT_COPY.body}

{context.error &&

{context.error}

} @@ -269,7 +270,6 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
: <> {dialog.kind === "connect" ? <> -

Continue with ChatGPT

{status.accounts.length > 0 &&
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) => )} - : {managed.email || "ChatGPT account"}} -
-
- -
- } + : null}
button:disabled]:cursor-auto" : ""}`} data-testid="chatgpt-global-background"> { if (dialog.kind === "connect") { setBackground(enabled); setBackgroundDirty(true); } @@ -295,7 +286,6 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { }} /> {BACKGROUND_LABEL}{CHATGPT_BACKGROUND_HINT}
- {dialog.kind === "manage" &&

{CHATGPT_NOTICE}

}
OpenAI privacy Manage usage @@ -303,11 +293,21 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { {!status.available &&

ChatGPT sign-in is available only in the desktop app. API-key connections remain available here.

} {status.pending &&

Waiting for browser sign-in…

} {context.error &&

{context.error}

} -
- {dialog.kind === "manage" && } - + {dialog.kind === "manage" && managed &&
+
+ {status.accounts.length > 1 && managedPresent && !busy ? : {managed.email || "ChatGPT account"}} +
+ +
} + + +
}> + {dialog.kind === "manage" && managedPresent && managed && !managed.connected && status.available && } + {dialog.kind === "connect" && void login()} disabled={!status.available || busy || status.pending} testid="chatgpt-proceed" />} -
+ }
}
; @@ -326,14 +326,14 @@ function AccountDialog({ children, onClose, busy }: { children: React.ReactNode; return {children}; } -/** Compact settings row, or setup's secondary action only. */ -export function ChatGPTConnection({ setup = false }: { setup?: boolean }) { +/** Shared compact settings/setup row; account selection precedes the quiet action. */ +export function ChatGPTConnection(_props: { 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
active ? ui.manage(active.id) : ui.connect()}> - {active && !setup && 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) => )} } diff --git a/components/settings/connection-controls.tsx b/components/settings/connection-controls.tsx index 2486cc0..395d604 100644 --- a/components/settings/connection-controls.tsx +++ b/components/settings/connection-controls.tsx @@ -8,7 +8,14 @@ export function ConnectionRow({ label, action, disabled, onAction, children, tes return
{label} {children} - + +
; +} + +export function ConnectionFooter({ leading, children, testid }: { leading?: React.ReactNode; children: React.ReactNode; testid?: string }) { + return
+ {leading} +
{children}
; } diff --git a/components/settings/subscription-connections.tsx b/components/settings/subscription-connections.tsx index b5c69a5..0120200 100644 --- a/components/settings/subscription-connections.tsx +++ b/components/settings/subscription-connections.tsx @@ -3,7 +3,7 @@ import { createContext, useContext, useEffect, useRef, useState } from "react"; import { NATIVE_SUBSCRIPTIONS, type NativeSubscriptionProviderId, type NativeSubscriptionStatus } from "@/src/core"; import { Button } from "@/components/ui/button"; 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 { 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 }) { const [statuses, setStatuses] = useState([]); const [dialog, setDialog] = useState(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 [operation, setOperation] = useState<"login" | "control" | null>(null), [error, setError] = useState(null); const mounted = useRef(false), sequence = useRef(0), generation = useRef(0), statusesRef = useRef(statuses); const descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.providerId === dialog); const status = statuses.find((s) => s.providerId === dialog) ?? (dialog ? emptySubscriptionStatus(dialog) : undefined); const interaction = status?.interaction, prompt = interaction?.prompt; + const management = view === "manage"; const busy = operation === "control" || status?.committing === true; async function refresh() { const request = ++sequence.current; @@ -47,7 +49,8 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea function open(providerId: NativeSubscriptionProviderId) { if (operation || statusesRef.current.some((s) => s.pending)) return; 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, close = false) { 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; } 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() { const bridge = window.lh?.subscriptions; 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; return {children} - {dialog && descriptor && status && -

{confirm ? `Disconnect ${descriptor.label}?` : status.connectionId ? descriptor.label : `Connect to ${descriptor.label}`}

- {confirm ?

{subscriptionDisconnectCopy(descriptor.label)}

: <> + {dialog && descriptor && status && +

{descriptor.label}

+ {confirm ? <> +

Disconnect {descriptor.label}?

+

{subscriptionDisconnectCopy(descriptor.label)}

+ : view === "notice" ? <>

{subscriptionNotice(descriptor.label)}

{dialog === "github-copilot" &&

{COPILOT_POLICY_NOTICE}

} + : <>
- { setBackground(enabled); if (status.connectionId && bridge) void control(() => bridge.consent(dialog, enabled)); else setBackgroundDirty(true); }} /> + { setBackground(enabled); if (management && bridge) void control(() => bridge.consent(dialog, enabled)); else setBackgroundDirty(true); }} /> Allow background use{SUBSCRIPTION_BACKGROUND_HINT}
{!status.available &&

Subscription sign-in requires the desktop app's private server connection. API-key connections remain available here.

} - {status.connected &&

Connected

} - {interaction &&
+ {view === "connection" && interaction &&
{interaction.progress &&

{interaction.progress}

} {interaction.userCode &&

Device code: {interaction.userCode}

} {interaction.browser && } @@ -97,15 +107,14 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea
} } {(error || status.error) &&

{error || status.error}

} -
- {!confirm && status.connectionId && !status.pending && <> - + setConfirm(true)}>Disconnect}> + {!confirm && management && status.connectionId && status.available && !status.connected && !!status.error && !status.pending && <> {status.error && } - + } - - {confirm ? : !status.connectionId && } -
+ + {confirm ? : view === "notice" ? : view === "connection" && } + } ; } @@ -114,9 +123,7 @@ export function NativeSubscriptionConnection({ providerId }: { providerId: Nativ const ui = useContext(Context), descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.providerId === providerId)!; const status = ui.statuses.find((s) => s.providerId === providerId) ?? emptySubscriptionStatus(providerId); return
- s.pending)} onAction={() => ui.open(providerId)}> - {status.connected && Connected} - + s.pending)} onAction={() => ui.open(providerId)} /> {status.error &&

{status.error}

}
; } diff --git a/tests/chatgpt-connection-ui.test.tsx b/tests/chatgpt-connection-ui.test.tsx index e26f335..d685900 100644 --- a/tests/chatgpt-connection-ui.test.tsx +++ b/tests/chatgpt-connection-ui.test.tsx @@ -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.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)); + 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-label"), "Allow background use for all ChatGPT accounts"); 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.doesNotMatch(byTest("chatgpt-connection").textContent!, /private-registration|private-issued| · active|signed-out/); 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 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; 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(button("Close")); await selectAccount("ChatGPT account", "private-registration-id"); assert.equal(f.status.allowBackgroundUse, true); 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." }); 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(!byTest("chatgpt-account-footer").contains(byTest("chatgpt-disconnect"))); + assert.equal(byTest("chatgpt-account-actions").textContent, "Disconnect"); assert.match(byTest("chatgpt-disconnect").className, /text-red/); + 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); + 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.body)); 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); 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…")); - assert.deepEqual([...byTest("chatgpt-account-footer").querySelectorAll("button")].map((b) => b.textContent), ["Add accounts", "Close"]); - assert.equal(byTest("chatgpt-add-accounts").getAttribute("data-variant"), "outline"); + assert.deepEqual([...byTest("chatgpt-account-footer").querySelectorAll("button")].map((b) => b.textContent), ["Disconnect", "Close"]); + 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/); await click(byTest("chatgpt-add-accounts")); 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, []); }); +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('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(); + 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 () => { const previous = HTMLElement.prototype.focus; const focused: HTMLElement[] = []; HTMLElement.prototype.focus = function () { focused.push(this); }; diff --git a/tests/subscription-connection-ui.test.tsx b/tests/subscription-connection-ui.test.tsx index 839a63a..880f0e9 100644 --- a/tests/subscription-connection-ui.test.tsx +++ b/tests/subscription-connection-ui.test.tsx @@ -15,7 +15,7 @@ function deferred() { let resolve!: () => void; const promise = new Promise ({ ...emptySubscriptionStatus(id as NativeSubscriptionProviderId), available: true, connected, connectionId: connected ? `local-${id}` : null })); 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) { 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), 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 }); }, - 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 }); }, consent: async (id, enabled) => { calls.consent.push([id, enabled]); push(id, { allowBackgroundUse: enabled }); }, 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); await act(async () => root!.render()); } -function button(text: string, scope: ParentNode = document) { const found = [...scope.querySelectorAll("button")].find((element) => element.textContent === text); assert(found, `missing button ${text}`); return found; } +function maybeButton(text: string, scope: ParentNode = document) { return [...scope.querySelectorAll("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(`[data-testid="${id}-connection"]`)!; const dialog = () => document.querySelector('[data-testid="subscription-connection-dialog"]')!; +const toggle = () => dialog().querySelector('[role="switch"]')!; +const footer = () => dialog().querySelector('[data-testid="subscription-connection-footer"]')!; 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; }); 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(!html.includes("@")); assert(!html.includes("Add accounts")); }); -test("Copilot disclosure and default-ON draft appear before login; browser action passes only opaque IDs", async () => { - const f = fixture(); await mount(); await click(button("Connect", row("github-copilot"))); - assert(dialog().textContent?.includes(COPILOT_POLICY_NOTICE)); assert.equal(f.calls.login.length, 0); - const toggle = dialog().querySelector('[role="switch"]')!; assert.equal(toggle.getAttribute("aria-checked"), "true"); - await click(toggle); await click(button("Connect", dialog())); assert.deepEqual(f.calls.login, [["github-copilot", false]]); - assert(dialog().textContent?.includes("PUBLIC-CODE")); await click(button("Open browser", dialog())); - assert.deepEqual(f.calls.open, [["github-copilot", "attempt", "opaque-action"]]); +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(id))); + assert.equal(dialog().querySelector("h2")?.textContent, label); + assert(dialog().querySelector('[data-testid="subscription-privacy-notice"]')); assert(!toggle()); + assert.equal(!!dialog().querySelector('[data-testid="copilot-policy-notice"]'), id === "github-copilot"); + if (id === "github-copilot") assert(dialog().textContent?.includes(COPILOT_POLICY_NOTICE)); + 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("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"); }); -test("single native connection management has no account controls; background changes are independent and disconnect is confirmed", async () => { - const f = fixture(true); await mount(); await click(button("Manage…", row("xai"))); - assert(!dialog().querySelector("select")); assert(!dialog().textContent?.includes("Add accounts")); assert(button("Reconnect", dialog())); - await click(dialog().querySelector('[role="switch"]')!); assert.deepEqual(f.calls.consent, [["xai", false]]); - assert.equal(f.statuses.find((s) => s.providerId === "github-copilot")!.allowBackgroundUse, true); +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(); await mount(); await click(button("Connect", row("xai"))); + if (phase === "connection") { await click(button("Continue", dialog())); await click(toggle()); } + await click(button("Cancel", dialog())); assert(!dialog()); assert.deepEqual(f.calls.login, []); assert.deepEqual(f.calls.consent, []); assert.equal(f.calls.cancel, 0); + 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); - assert(dialog().textContent?.includes("does not cancel your subscription or revoke access")); - await click(button("Cancel", dialog())); assert(button("Reconnect", dialog())); + 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(toggle()); assert(!maybeButton("Reconnect", 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 () => { - 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" } } })); 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 })); 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); }); assert(dialog()); assert.equal(f.calls.cancel, 0); - await act(async () => f.login.resolve()); + await act(async () => { + const backdrop = document.querySelector('[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()); });