Unify ChatGPT background use and account management
This commit is contained in:
@@ -8,19 +8,21 @@ import { Button } from "@/components/ui/button";
|
||||
import { Toggle } from "./controls";
|
||||
|
||||
export const CHATGPT_NOTICE = "LearnHarness stores your ChatGPT account details and encrypted credentials locally on this device. When this connection is used, relevant conversation context, learning material, and tool results are sent directly to OpenAI. ChatGPT plan limits apply; any additional credits are authorized and managed in ChatGPT, not LearnHarness.";
|
||||
export const CHATGPT_BACKGROUND_HINT = "Background work uses your ChatGPT allowance.";
|
||||
const REVOCATION_WARNING = "Signed out locally. Remote revocation was not confirmed; disconnect LearnHarness in ChatGPT Settings.";
|
||||
const empty: ChatGPTStatus = { available: false, pending: false, activeId: null, accounts: [], error: null };
|
||||
export const CHATGPT_BACKGROUND_HINT = "Background work counts toward your ChatGPT plan usage.";
|
||||
const BACKGROUND_LABEL = "Allow background use for all ChatGPT accounts";
|
||||
const REVOCATION_WARNING = "Account disconnected locally. Remote revocation could not be confirmed.";
|
||||
export const CHATGPT_DISCONNECT_COPY = { title: "Disconnect account?", body: "This removes the saved account and its credentials from this device." };
|
||||
const empty: ChatGPTStatus = { available: false, pending: false, allowBackgroundUse: true, activeId: null, accounts: [], error: null };
|
||||
type Account = ChatGPTStatus["accounts"][number];
|
||||
type Dialog = { kind: "connect" } | { kind: "manage"; accountId: string } | { kind: "warning" };
|
||||
type LoginAttempt = { generation: number; dialog: Dialog; registration: string; background: boolean; pending: boolean; accountId?: string; phase: "starting" | "pending" | "consent" };
|
||||
type Dialog = { kind: "connect" } | { kind: "manage"; accountId: string; account: Account; confirm?: boolean } | { kind: "warning" };
|
||||
type LoginAttempt = { generation: number; dialog: Dialog; registration: string; background: boolean; backgroundDirty: boolean; pending: boolean; accountId?: string; phase: "starting" | "pending" | "consent" };
|
||||
const CONTROL_ERROR = "The ChatGPT connection could not be changed. Try again.";
|
||||
function isStatus(value: unknown): value is ChatGPTStatus {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const s = value as ChatGPTStatus;
|
||||
return typeof s.available === "boolean" && typeof s.pending === "boolean" && (s.activeId === null || typeof s.activeId === "string") &&
|
||||
return typeof s.available === "boolean" && typeof s.pending === "boolean" && typeof s.allowBackgroundUse === "boolean" && (s.activeId === null || typeof s.activeId === "string") &&
|
||||
(s.error === null || typeof s.error === "string") && (s.catalogError == null || typeof s.catalogError === "string") && Array.isArray(s.accounts) &&
|
||||
s.accounts.every((a) => a && typeof a.id === "string" && typeof a.email === "string" && typeof a.registration === "string" && typeof a.connected === "boolean" && typeof a.background === "boolean" && (a.planEnabled === undefined || typeof a.planEnabled === "boolean"));
|
||||
s.accounts.every((a) => a && typeof a.id === "string" && typeof a.email === "string" && typeof a.registration === "string" && typeof a.connected === "boolean" && (a.planEnabled === undefined || typeof a.planEnabled === "boolean"));
|
||||
}
|
||||
function replyError(value: unknown): string {
|
||||
return value && typeof value === "object" && "error" in value && typeof value.error === "string" ? value.error : CONTROL_ERROR;
|
||||
@@ -45,9 +47,10 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dismissedError, setDismissedError] = useState<string | null>(null);
|
||||
const [background, setBackground] = useState(false);
|
||||
const [background, setBackground] = useState(true);
|
||||
const [backgroundDirty, setBackgroundDirty] = useState(false);
|
||||
const [registration, setRegistration] = useState("");
|
||||
const operation = useRef<"login" | "cancel" | "change" | "logout" | null>(null);
|
||||
const operation = useRef<"login" | "cancel" | "change" | "disconnect" | null>(null);
|
||||
const generation = useRef(0);
|
||||
const statusSequence = useRef(0);
|
||||
const mounted = useRef(false);
|
||||
@@ -72,18 +75,28 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
||||
finish(attempt); setError(next.error || CONTROL_ERROR); return;
|
||||
}
|
||||
attempt.phase = "consent"; attempt.accountId = account.id;
|
||||
let replyFailure: string | null = null;
|
||||
try {
|
||||
if (account.background !== attempt.background) {
|
||||
if (attempt.backgroundDirty && next.allowBackgroundUse !== attempt.background) {
|
||||
operation.current = "change";
|
||||
const result: unknown = await window.lh!.chatgpt!.consent(account.id, attempt.background);
|
||||
const request = ++statusSequence.current;
|
||||
const result: unknown = await window.lh!.chatgpt!.consent(attempt.background);
|
||||
if (!owns(attempt)) return;
|
||||
const saved = publishStatus(result);
|
||||
if (!isStatus(result)) { setError(replyError(result)); finish(attempt); return; }
|
||||
replyFailure = result.error;
|
||||
let saved: ChatGPTStatus | null;
|
||||
if (request !== statusSequence.current) {
|
||||
const refresh = ++statusSequence.current;
|
||||
const value: unknown = await window.lh!.chatgpt!.status();
|
||||
if (!owns(attempt)) return;
|
||||
saved = refresh === statusSequence.current ? publishStatus(value) : statusRef.current;
|
||||
} else saved = publishStatus(result);
|
||||
if (!saved) { finish(attempt); return; }
|
||||
next = saved;
|
||||
}
|
||||
if (!owns(attempt)) return;
|
||||
const current = next.accounts.find((a) => a.id === account.id && a.id === next.activeId && a.connected);
|
||||
if (next.error || next.pending || !next.available || !current || current.background !== attempt.background) setError(next.error || CONTROL_ERROR);
|
||||
if (replyFailure || next.error || next.pending || !next.available || !current || (attempt.backgroundDirty && next.allowBackgroundUse !== attempt.background)) setError(replyFailure || next.error || CONTROL_ERROR);
|
||||
else if (dialogRef.current === attempt.dialog) showDialog(null);
|
||||
} catch { if (owns(attempt)) setError(CONTROL_ERROR); }
|
||||
finally { finish(attempt); }
|
||||
@@ -121,19 +134,23 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
||||
return () => { mounted.current = false; generation.current++; loginAttempt.current = null; off?.(); };
|
||||
}, []);
|
||||
|
||||
const managed = dialog?.kind === "manage" ? status.accounts.find((a) => a.id === dialog.accountId && a.connected) : undefined;
|
||||
const managed = dialog?.kind === "manage" ? status.accounts.find((a) => a.id === dialog.accountId) ?? dialog.account : undefined;
|
||||
const managedPresent = !!managed && status.accounts.some((a) => a.id === managed.id);
|
||||
const visibleError = error || (status.error !== dismissedError ? status.error : null) || status.catalogError || null;
|
||||
const warning = visibleError === REVOCATION_WARNING ? visibleError : null;
|
||||
function openConnect() {
|
||||
if (operation.current) return;
|
||||
setBackground(false);
|
||||
setBackground(statusRef.current.allowBackgroundUse);
|
||||
setBackgroundDirty(false);
|
||||
setRegistration("");
|
||||
setError(null);
|
||||
setDismissedError(status.error);
|
||||
showDialog({ kind: "connect" });
|
||||
}
|
||||
function close() {
|
||||
if (operation.current === "logout" || operation.current === "change" || operation.current === "cancel") return;
|
||||
if (operation.current === "disconnect" || operation.current === "change" || operation.current === "cancel") return;
|
||||
const current = dialogRef.current;
|
||||
if (current?.kind === "manage" && current.confirm) { showDialog({ ...current, confirm: false }); setError(null); return; }
|
||||
const cancel = loginAttempt.current !== null || statusRef.current.pending;
|
||||
const intent = ++generation.current;
|
||||
const owner = dialogRef.current;
|
||||
@@ -156,19 +173,36 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
||||
});
|
||||
}
|
||||
}
|
||||
async function control(kind: "change" | "logout", action: () => Promise<ChatGPTStatus>) {
|
||||
async function control(kind: "change" | "disconnect", action: () => Promise<ChatGPTStatus>, target?: string) {
|
||||
if (operation.current) return;
|
||||
const intent = ++generation.current;
|
||||
const request = ++statusSequence.current;
|
||||
const owner = dialogRef.current;
|
||||
operation.current = kind;
|
||||
setBusy(true); setError(null); setDismissedError(null);
|
||||
setBusy(true); setError(null);
|
||||
if (kind === "disconnect") setDismissedError(null);
|
||||
try {
|
||||
const value: unknown = await action();
|
||||
if (!mounted.current || intent !== generation.current) return;
|
||||
const next = publishStatus(value);
|
||||
if (!next) return; // Error-only IPC replies never replace safe status.
|
||||
if (kind === "logout" && !next.error && !next.accounts.find((a) => a.id === status.activeId && a.connected)) showDialog(null);
|
||||
else if (kind === "logout" && next.error === REVOCATION_WARNING) showDialog({ kind: "warning" });
|
||||
else setError(next.error);
|
||||
if (!isStatus(value)) { setError(replyError(value)); return; }
|
||||
let next: ChatGPTStatus | null;
|
||||
if (request !== statusSequence.current) {
|
||||
const refresh = ++statusSequence.current;
|
||||
const current: unknown = await window.lh!.chatgpt!.status();
|
||||
if (!mounted.current || intent !== generation.current) return;
|
||||
next = refresh === statusSequence.current ? publishStatus(current) : statusRef.current;
|
||||
} else next = publishStatus(value);
|
||||
if (!next) return;
|
||||
// Dismissed status warnings belong to an earlier action. Only a new
|
||||
// disconnect outcome (or an explicit IPC error envelope above) reopens one.
|
||||
const failure = [value.error, next.error].find((message) => message && (kind === "disconnect" || message !== dismissedError)) ?? null;
|
||||
if (kind === "disconnect" && !next.accounts.some((a) => a.id === target)) {
|
||||
if (dialogRef.current === owner) {
|
||||
if (failure === REVOCATION_WARNING) showDialog({ kind: "warning" });
|
||||
else if (!failure) showDialog(null);
|
||||
}
|
||||
setError(failure);
|
||||
} else setError(failure || (kind === "disconnect" ? CONTROL_ERROR : null));
|
||||
} catch { if (mounted.current && intent === generation.current) setError(CONTROL_ERROR); }
|
||||
finally { if (intent === generation.current) { operation.current = null; if (mounted.current) setBusy(false); } }
|
||||
}
|
||||
@@ -176,7 +210,7 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
||||
const bridge = window.lh?.chatgpt;
|
||||
const owner = dialogRef.current;
|
||||
if (!bridge || operation.current || !status.available || status.pending || owner?.kind !== "connect") return;
|
||||
const attempt: LoginAttempt = { generation: ++generation.current, dialog: owner, registration, background, pending: false, phase: "starting" };
|
||||
const attempt: LoginAttempt = { generation: ++generation.current, dialog: owner, registration, background, backgroundDirty, pending: false, phase: "starting" };
|
||||
loginAttempt.current = attempt; operation.current = "login";
|
||||
const request = ++statusSequence.current;
|
||||
setBusy(true); setError(null); setDismissedError(null);
|
||||
@@ -204,45 +238,67 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
|
||||
const context = {
|
||||
status, busy, error: warning ? null : visibleError, inlineError: dialog || warning ? null : visibleError, warning: dialog?.kind === "warning" ? null : warning,
|
||||
connect: openConnect,
|
||||
manage: (id: string) => { if (!operation.current) { setError(null); showDialog({ kind: "manage", accountId: id }); } },
|
||||
manage: (id: string) => {
|
||||
const account = statusRef.current.accounts.find((a) => a.id === id);
|
||||
if (!operation.current && account) { setError(null); showDialog({ kind: "manage", accountId: id, account }); }
|
||||
},
|
||||
select: (id: string) => { if (bridge) void control("change", () => bridge.select(id)); },
|
||||
dismissWarning: () => { setDismissedError(REVOCATION_WARNING); setError(null); },
|
||||
};
|
||||
return <Context.Provider value={context}>
|
||||
{children}
|
||||
{dialog && <AccountDialog onClose={close} busy={busy && operation.current !== "login"}>
|
||||
<h2 className="text-sm font-semibold text-foreground">{dialog.kind === "connect" ? "Continue with ChatGPT" : "ChatGPT account"}</h2>
|
||||
{dialog.kind === "warning" ? <>
|
||||
<p role="alert" className="mt-3 rounded-sm border border-border px-3 py-2 text-xs text-foreground" data-testid="chatgpt-revocation-warning">{REVOCATION_WARNING}</p>
|
||||
<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>
|
||||
</> : <>
|
||||
{dialog.kind === "connect" && status.accounts.length > 0 && <label className="mt-3 flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
Saved ChatGPT account
|
||||
<select aria-label="Saved ChatGPT account" value={registration} onChange={(event) => setRegistration(event.target.value)} disabled={busy || status.pending} className="rounded-sm border border-border bg-background px-2 py-1.5 text-foreground">
|
||||
<option value="">Add another account or workspace</option>
|
||||
{status.accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
|
||||
</select>
|
||||
</label>}
|
||||
{managed && <p className="mt-3 text-xs text-foreground">{managed.email || "ChatGPT account"}</p>}
|
||||
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="chatgpt-privacy-notice">{CHATGPT_NOTICE}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||
</> : 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-xs text-foreground">{managed?.email || "ChatGPT account"}</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>}
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={close}>Cancel</Button>
|
||||
<Button variant="destructive" size="sm" data-testid="chatgpt-confirm-disconnect" disabled={busy || !managedPresent} onClick={() => bridge && managed && void control("disconnect", () => bridge.disconnect(managed.id), managed.id)}>Disconnect account</Button>
|
||||
</div>
|
||||
</div> : <>
|
||||
{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">
|
||||
<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>
|
||||
{status.accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
|
||||
</select>
|
||||
{registration && <Button variant="ghost" size="xs" disabled={busy} onClick={() => context.manage(registration)}>Manage…</Button>}
|
||||
</div>}
|
||||
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="chatgpt-privacy-notice">{CHATGPT_NOTICE}</p>
|
||||
</> : managed && <>
|
||||
<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" data-testid="chatgpt-global-background">
|
||||
<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); }
|
||||
else if (bridge) void control("change", () => bridge.consent(enabled));
|
||||
}} />
|
||||
<span>{BACKGROUND_LABEL}<span className="mt-1 block leading-relaxed text-muted-foreground">{CHATGPT_BACKGROUND_HINT}</span></span>
|
||||
</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">
|
||||
<a href="https://openai.com/policies/privacy-policy/" target="_blank" rel="noreferrer" className="underline underline-offset-2">OpenAI privacy</a>
|
||||
<a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="underline underline-offset-2">Manage usage</a>
|
||||
</div>
|
||||
{(dialog.kind === "connect" || managed) && <div className="mt-4 flex items-start gap-3 text-xs text-foreground">
|
||||
<Toggle label="Allow background use" checked={dialog.kind === "connect" ? background : managed!.background} disabled={busy || status.pending || !status.available} onChange={(enabled) => {
|
||||
if (dialog.kind === "connect") setBackground(enabled);
|
||||
else if (managed && bridge) void control("change", () => bridge.consent(managed.id, enabled));
|
||||
}} />
|
||||
<span>Allow background use<span className="mt-1 block leading-relaxed text-muted-foreground">{CHATGPT_BACKGROUND_HINT}</span></span>
|
||||
</div>}
|
||||
{!status.available && <p className="mt-3 text-xs text-muted-foreground">ChatGPT sign-in is available only in the desktop app with secure OS credential storage. API-key connections remain available here.</p>}
|
||||
{status.pending && <p role="status" className="mt-3 text-xs text-muted-foreground">Waiting for browser sign-in…</p>}
|
||||
{context.error && <p role="alert" className="mt-3 text-xs text-red-400">{context.error}</p>}
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<div className="mt-4 flex items-center justify-end gap-2" data-testid="chatgpt-account-footer">
|
||||
<Button variant="ghost" size="sm" onClick={close} disabled={busy && operation.current !== "login"}>{dialog.kind === "connect" ? "Cancel" : "Close"}</Button>
|
||||
{dialog.kind === "connect" && <ContinueButton onClick={() => void login()} disabled={!status.available || busy || status.pending} testid="chatgpt-proceed" />}
|
||||
{managed && <Button variant="outline" size="sm" data-testid="chatgpt-disconnect" disabled={busy || status.pending} onClick={() => bridge && void control("logout", () => bridge.logout(managed.id))}>Disconnect</Button>}
|
||||
</div>
|
||||
</>}
|
||||
</AccountDialog>}
|
||||
@@ -293,7 +349,7 @@ export function ChatGPTConnection({ setup = false }: { setup?: boolean }) {
|
||||
const accounts = ui.status.accounts.filter((a) => a.connected);
|
||||
return <div className="flex flex-col gap-2" data-testid="chatgpt-connection">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{!setup && <span className="font-ui text-xs text-foreground">ChatGPT</span>}
|
||||
{!setup && active && <span className="font-ui text-xs text-foreground">ChatGPT</span>}
|
||||
{active && !setup ? <>
|
||||
<select aria-label="ChatGPT account" value={active.id} disabled={ui.busy || ui.status.pending} onChange={(event) => ui.select(event.target.value)} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1 text-xs text-foreground">
|
||||
{accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
|
||||
@@ -310,6 +366,6 @@ export function ChatGPTConnection({ setup = false }: { setup?: boolean }) {
|
||||
export function ChatGPTBackgroundAccess({ provider }: { provider: string | undefined }) {
|
||||
const ui = useContext(Context);
|
||||
const account: Account | undefined = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected);
|
||||
if (provider !== "chatgpt" || !account || account.background) return null;
|
||||
if (provider !== "chatgpt" || !account || ui.status.allowBackgroundUse) return null;
|
||||
return <Button type="button" size="xs" variant="ghost" className="cursor-pointer" disabled={ui.busy} onClick={() => ui.manage(account.id)}>Enable background use</Button>;
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
* a paste box on a key the UI cannot make live would mislead.
|
||||
* - OpenRouter is NOT special: one provider among 33, a row only when
|
||||
* stored, otherwise it lives in the Add dropdown like everyone else.
|
||||
* - Tools & services (Deepgram, the Google pair) keep their own group,
|
||||
* same look, same interaction.
|
||||
* - Providers groups API keys, subscriptions, dictation and search.
|
||||
* Unknown variables stay masked/read-only at the end of Model API keys.
|
||||
*
|
||||
* API: GET /api/keys (masked statuses + the providers array), PUT
|
||||
* /api/keys/:id (shape-gated 400s land inline), DELETE. Saves take
|
||||
@@ -413,7 +413,7 @@ export function KeysSection({
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsGroup label="Model providers" testid="group-provider-keys">
|
||||
<SettingsGroup label="Model API keys" testid="group-provider-keys">
|
||||
{providerRows.map((status) => (
|
||||
<KeyTableRow
|
||||
key={status.id}
|
||||
@@ -447,11 +447,20 @@ export function KeysSection({
|
||||
<AddProviderEditor provider={adding} onSave={(v) => void saveNewProvider(adding, v)} onCancel={() => setAdding(null)} error={addError} />
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 border-t border-border/60 pt-3" data-testid="group-chatgpt"><ChatGPTConnection /></div>
|
||||
{unknownRows.length > 0 && <div data-testid="group-unknown-keys">
|
||||
{unknownRows.map((status) => <div key={status.id} data-testid={`unknown-${status.id}`} className="flex items-center justify-between gap-3 px-1 py-1.5 opacity-40">
|
||||
<span className="font-mono text-xs text-foreground">{status.id}</span>
|
||||
<span className="font-mono text-[11px] text-muted-foreground">{status.masked}</span>
|
||||
</div>)}
|
||||
</div>}
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup label="Tools & services" testid="group-tool-keys">
|
||||
{toolRows.map((status) => (
|
||||
<SettingsGroup label="Subscriptions" testid="group-subscriptions">
|
||||
<div data-testid="group-chatgpt"><ChatGPTConnection /></div>
|
||||
</SettingsGroup>
|
||||
|
||||
{(["Dictation", "Search"] as const).map((label) => <SettingsGroup key={label} label={label} testid={label === "Dictation" ? "group-dictation" : "group-search"}>
|
||||
{toolRows.filter((status) => label === "Dictation" ? status.id === "DEEPGRAM_API_KEY" : status.id !== "DEEPGRAM_API_KEY").map((status) => (
|
||||
<KeyTableRow
|
||||
key={status.id}
|
||||
label={status.info?.label ?? status.id}
|
||||
@@ -469,22 +478,8 @@ export function KeysSection({
|
||||
testid={`key-${status.id.toLowerCase()}`}
|
||||
/>
|
||||
))}
|
||||
</SettingsGroup>
|
||||
</SettingsGroup>)}
|
||||
|
||||
{unknownRows.length > 0 && (
|
||||
<SettingsGroup label="Other stored variables" testid="group-unknown-keys">
|
||||
{unknownRows.map((status) => (
|
||||
<div
|
||||
key={status.id}
|
||||
data-testid={`unknown-${status.id}`}
|
||||
className="flex items-center justify-between gap-3 px-1 py-1.5 opacity-40"
|
||||
>
|
||||
<span className="font-mono text-xs text-foreground">{status.id}</span>
|
||||
<span className="font-mono text-[11px] text-muted-foreground">{status.masked}</span>
|
||||
</div>
|
||||
))}
|
||||
</SettingsGroup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -55,12 +55,12 @@ import { ChatGPTUIProvider } from "./chatgpt-connection.tsx";
|
||||
* prose is out; only the keyboard note stays (storage location is a
|
||||
* real question users ask). */
|
||||
const SECTIONS = [
|
||||
{ id: "keys", label: "Model providers" },
|
||||
{ id: "general", label: "General" },
|
||||
{ id: "keys", label: "Providers" },
|
||||
{ id: "agent", label: "Agent" },
|
||||
{ id: "appearance", label: "Appearance" },
|
||||
{ id: "graph", label: "Graph" },
|
||||
{ id: "keyboard", label: "Keyboard", hint: "Stored on this device" },
|
||||
{ id: "general", label: "General" },
|
||||
] as const;
|
||||
type SectionId = (typeof SECTIONS)[number]["id"];
|
||||
|
||||
@@ -87,7 +87,7 @@ export function SettingsDialogProvider({
|
||||
const [landOn, setLandOn] = useState<SectionId>("keys");
|
||||
const [landOnRow, setLandOnRow] = useState<string | null>(null);
|
||||
const open = useCallback((section?: string, row?: string) => {
|
||||
if (section) setLandOn(section as SectionId);
|
||||
setLandOn(section ? section as SectionId : "keys");
|
||||
setLandOnRow(row ?? null);
|
||||
setShow(true);
|
||||
}, []);
|
||||
@@ -377,7 +377,7 @@ export function SettingsDialogBody({
|
||||
</SettingsSection>
|
||||
)}
|
||||
{active === "keys" && (
|
||||
<SettingsSection title="Model providers" testid="section-keys">
|
||||
<SettingsSection title="Providers" testid="section-keys">
|
||||
<KeysSection initialRow={initialRow ?? undefined} />
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user