Compare commits

...

1 Commits

Author SHA1 Message Date
Nikhil Sonti
727136f92f feat: refresh chatgpt plus pro provider ui 2026-03-18 11:49:01 -07:00
4 changed files with 460 additions and 89 deletions

View File

@@ -31,6 +31,7 @@ import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import { useLlmProviders } from '@/lib/llm-providers/useLlmProviders'
import { useOAuthStatus } from '@/lib/llm-providers/useOAuthStatus'
import { track } from '@/lib/metrics/track'
import { ChatGPTProFeatureCard } from './ChatGPTProFeatureCard'
import { ConfiguredProvidersList } from './ConfiguredProvidersList'
import {
DeleteRemoteLlmProviderDocument,
@@ -114,9 +115,12 @@ export const AISettingsPage: FC = () => {
// OAuth status for ChatGPT Plus/Pro
const {
status: chatgptProStatus,
isPolling: isChatGPTProPolling,
startPolling: startChatGPTProPolling,
disconnect: disconnectChatGPTPro,
} = useOAuthStatus('chatgpt-pro')
const chatgptProProvider = providers.find((p) => p.type === 'chatgpt-pro')
const isChatGPTProDefault = defaultProviderId === chatgptProProvider?.id
// Track whether user explicitly started an OAuth flow this session
const oauthFlowStartedRef = useRef(false)
@@ -329,6 +333,25 @@ export const AISettingsPage: FC = () => {
onAddProvider={handleAddProvider}
/>
<ChatGPTProFeatureCard
provider={chatgptProProvider}
email={chatgptProStatus?.email}
isAuthenticated={chatgptProStatus?.authenticated === true}
isPolling={isChatGPTProPolling}
isDefault={isChatGPTProDefault}
onConnect={handleStartChatGPTProOAuth}
onDisconnect={() => {
if (chatgptProProvider) {
handleDeleteProvider(chatgptProProvider)
}
}}
onMakeDefault={() => {
if (chatgptProProvider) {
handleSelectProvider(chatgptProProvider.id)
}
}}
/>
<ProviderTemplatesSection onUseTemplate={handleUseTemplate} />
<ConfiguredProvidersList

View File

@@ -0,0 +1,298 @@
import {
ArrowRight,
CheckCircle2,
ExternalLink,
Loader2,
ShieldCheck,
Sparkles,
Unplug,
} from 'lucide-react'
import type { FC } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ProviderIcon } from '@/lib/llm-providers/providerIcons'
import { getProviderTemplate } from '@/lib/llm-providers/providerTemplates'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import { cn } from '@/lib/utils'
interface ChatGPTProFeatureCardProps {
provider?: LlmProviderConfig
email?: string
isAuthenticated: boolean
isPolling: boolean
isDefault: boolean
onConnect: () => void
onDisconnect: () => void
onMakeDefault: () => void
}
type ChatGPTProState =
| 'disconnected'
| 'connecting'
| 'provisioning'
| 'connected'
function getChatGPTProState(
isAuthenticated: boolean,
isPolling: boolean,
provider?: LlmProviderConfig,
): ChatGPTProState {
if (isPolling) return 'connecting'
if (isAuthenticated && provider) return 'connected'
if (isAuthenticated) return 'provisioning'
return 'disconnected'
}
function getStateCopy(state: ChatGPTProState, isDefault: boolean) {
switch (state) {
case 'connecting':
return {
badge: 'Waiting for sign-in',
title: 'Finish the login in the opened ChatGPT tab',
description:
'BrowserOS is polling for completion and will finish setup automatically once your ChatGPT account is authenticated.',
}
case 'provisioning':
return {
badge: 'Finalizing setup',
title: 'Authentication succeeded. Creating your provider now.',
description:
'The OAuth handshake is done. BrowserOS is applying the local provider configuration so the account can be used inside the extension.',
}
case 'connected':
return {
badge: isDefault ? 'Connected and default' : 'Connected',
title: isDefault
? 'Your ChatGPT Plus/Pro account is ready to use'
: 'Your ChatGPT Plus/Pro account is connected',
description: isDefault
? 'This provider is already the default model route for BrowserOS chats.'
: 'You can make it the default provider or keep it available alongside your other models.',
}
default:
return {
badge: 'Not connected',
title: 'Connect ChatGPT Plus/Pro without managing API keys',
description:
'Use your ChatGPT subscription directly inside BrowserOS with managed OAuth, GPT-5/Codex-ready models, and the same provider list as the rest of your setup.',
}
}
}
export const ChatGPTProFeatureCard: FC<ChatGPTProFeatureCardProps> = ({
provider,
email,
isAuthenticated,
isPolling,
isDefault,
onConnect,
onDisconnect,
onMakeDefault,
}) => {
const state = getChatGPTProState(isAuthenticated, isPolling, provider)
const copy = getStateCopy(state, isDefault)
const setupGuideUrl = getProviderTemplate('chatgpt-pro')?.setupGuideUrl
const detailChips = [
provider?.modelId ?? 'GPT-5 / Codex-ready',
email ?? 'Managed OAuth',
'No local API key',
]
return (
<section className="relative overflow-hidden rounded-[28px] border border-orange-300/70 bg-[linear-gradient(135deg,rgba(255,247,239,0.98),rgba(240,251,247,0.96))] p-6 shadow-[0_20px_50px_-34px_rgba(191,98,22,0.45)] dark:border-orange-400/20 dark:bg-[linear-gradient(135deg,rgba(63,38,23,0.82),rgba(18,43,38,0.88))]">
<div className="pointer-events-none absolute inset-y-0 right-0 w-1/2 bg-[radial-gradient(circle_at_top_right,rgba(16,163,127,0.18),transparent_60%)] dark:bg-[radial-gradient(circle_at_top_right,rgba(16,163,127,0.22),transparent_62%)]" />
<div className="relative flex flex-col gap-6 xl:flex-row xl:items-end xl:justify-between">
<div className="max-w-3xl space-y-5">
<div className="flex flex-wrap items-center gap-2">
<Badge className="border-transparent bg-foreground text-background">
Featured integration
</Badge>
<Badge
variant="outline"
className="border-orange-400/40 bg-white/[0.65] text-foreground dark:bg-white/10"
>
{copy.badge}
</Badge>
</div>
<div className="flex items-start gap-4">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-white/60 bg-white/80 shadow-sm dark:border-white/10 dark:bg-white/10">
<ProviderIcon
type="chatgpt-pro"
size={30}
className="text-[#10a37f]"
/>
</div>
<div className="space-y-3">
<div>
<p className="font-semibold text-lg tracking-tight">
ChatGPT Plus/Pro
</p>
<h3 className="font-semibold text-3xl leading-tight tracking-tight">
{copy.title}
</h3>
</div>
<p className="max-w-2xl text-[15px] text-foreground/75 leading-6 dark:text-foreground/80">
{copy.description}
</p>
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
<div className="rounded-2xl border border-white/60 bg-white/70 p-4 shadow-sm dark:border-white/10 dark:bg-white/[0.08]">
<div className="mb-2 flex items-center gap-2 font-medium text-sm">
<ShieldCheck className="h-4 w-4 text-[#10a37f]" />
Managed access
</div>
<p className="text-muted-foreground text-sm leading-5">
Start with an OAuth login instead of copying keys or endpoint
values into the extension.
</p>
</div>
<div className="rounded-2xl border border-white/60 bg-white/70 p-4 shadow-sm dark:border-white/10 dark:bg-white/[0.08]">
<div className="mb-2 flex items-center gap-2 font-medium text-sm">
<Sparkles className="h-4 w-4 text-[var(--accent-orange)]" />
Premium models
</div>
<p className="text-muted-foreground text-sm leading-5">
Keep ChatGPT Plus/Pro available for GPT-5 and Codex-style work
inside BrowserOS.
</p>
</div>
<div className="rounded-2xl border border-white/60 bg-white/70 p-4 shadow-sm dark:border-white/10 dark:bg-white/[0.08]">
<div className="mb-2 flex items-center gap-2 font-medium text-sm">
<CheckCircle2 className="h-4 w-4 text-[var(--accent-orange)]" />
Ready in settings
</div>
<p className="text-muted-foreground text-sm leading-5">
Connect, confirm the account, and keep it alongside your other
configured providers.
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
{detailChips.map((chip) => (
<Badge
key={chip}
variant="outline"
className="rounded-full border-white/70 bg-white/[0.65] px-3 py-1 text-foreground/80 dark:border-white/[0.12] dark:bg-white/[0.08] dark:text-foreground/85"
>
{chip}
</Badge>
))}
</div>
</div>
<div className="w-full max-w-sm rounded-3xl border border-white/60 bg-white/[0.78] p-5 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/[0.08]">
<p className="font-medium text-foreground/70 text-sm uppercase tracking-[0.18em]">
Current status
</p>
<div className="mt-3 space-y-3">
<div className="rounded-2xl border border-border/70 bg-background/80 p-4 dark:bg-background/30">
<div className="flex items-center justify-between gap-3">
<div>
<p className="font-medium text-sm">Account</p>
<p className="text-muted-foreground text-sm">
{email ?? 'Not signed in'}
</p>
</div>
<div
className={cn(
'rounded-full px-3 py-1 font-medium text-xs',
state === 'connected'
? 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300'
: state === 'connecting' || state === 'provisioning'
? 'bg-orange-500/12 text-orange-700 dark:text-orange-300'
: 'bg-muted text-muted-foreground',
)}
>
{copy.badge}
</div>
</div>
</div>
<div className="rounded-2xl border border-border/70 bg-background/80 p-4 dark:bg-background/30">
<p className="font-medium text-sm">Provider</p>
<p className="mt-1 text-muted-foreground text-sm">
{provider
? `${provider.name} with ${provider.modelId}`
: 'A local provider entry will be created automatically after authentication.'}
</p>
</div>
</div>
<div className="mt-5 flex flex-col gap-3">
{(state === 'disconnected' || state === 'connecting') && (
<Button
size="lg"
onClick={onConnect}
className="w-full bg-[var(--accent-orange)] text-white hover:bg-[var(--accent-orange)]/90"
>
{state === 'connecting' ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Reopen login tab
</>
) : (
<>
Connect ChatGPT Plus/Pro
<ArrowRight className="h-4 w-4" />
</>
)}
</Button>
)}
{state === 'provisioning' && (
<Button size="lg" disabled className="w-full">
<Loader2 className="h-4 w-4 animate-spin" />
Finishing setup
</Button>
)}
{state === 'connected' && !isDefault && (
<Button
size="lg"
onClick={onMakeDefault}
className="w-full bg-[var(--accent-orange)] text-white hover:bg-[var(--accent-orange)]/90"
>
Make default provider
</Button>
)}
{state === 'connected' && isDefault && (
<Button size="lg" variant="secondary" disabled className="w-full">
Default provider selected
</Button>
)}
<div className="flex flex-col gap-2 sm:flex-row">
{setupGuideUrl && (
<Button variant="outline" className="flex-1" asChild>
<a
href={setupGuideUrl}
target="_blank"
rel="noopener noreferrer"
>
Setup guide
<ExternalLink className="h-4 w-4" />
</a>
</Button>
)}
{state === 'connected' && (
<Button
variant="outline"
className="flex-1"
onClick={onDisconnect}
>
<Unplug className="h-4 w-4" />
Disconnect
</Button>
)}
</div>
</div>
</div>
</div>
</section>
)
}

View File

@@ -1,9 +1,10 @@
import { Check, Loader2, Trash2 } from 'lucide-react'
import { Check, Loader2, PencilLine, Trash2 } from 'lucide-react'
import type { FC } from 'react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { useKimiLaunch } from '@/lib/feature-flags/useKimiLaunch'
import { BrowserOSIcon, ProviderIcon } from '@/lib/llm-providers/providerIcons'
import { getProviderTemplate } from '@/lib/llm-providers/providerTemplates'
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
import { cn } from '@/lib/utils'
@@ -31,15 +32,26 @@ export const ProviderCard: FC<ProviderCardProps> = ({
}) => {
const inputId = `provider-${provider.id}`
const kimiLaunch = useKimiLaunch()
const providerLabel = isBuiltIn
? 'BrowserOS hosted'
: (getProviderTemplate(provider.type)?.name ?? provider.type)
const providerHost = getProviderHost(provider.baseUrl)
const detailBadges = [providerLabel, provider.modelId, providerHost].filter(
Boolean,
)
const description = getProviderDescription({
provider,
isBuiltIn,
kimiLaunch,
})
return (
<label
htmlFor={inputId}
<div
className={cn(
'group flex w-full cursor-pointer items-center gap-4 rounded-xl border p-4 text-left transition-all',
'group rounded-2xl border bg-card p-4 transition-all',
isSelected
? 'border-[var(--accent-orange)] bg-[var(--accent-orange)]/5 shadow-md'
: 'border-border bg-card hover:border-[var(--accent-orange)]/50 hover:shadow-sm',
: 'border-border hover:border-[var(--accent-orange)]/50 hover:shadow-sm',
)}
>
<input
@@ -50,90 +62,127 @@ export const ProviderCard: FC<ProviderCardProps> = ({
checked={isSelected}
onChange={() => onSelect()}
/>
<div
className={cn(
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-all',
isSelected
? 'border-[var(--accent-orange)] bg-[var(--accent-orange)]'
: 'border-border',
)}
>
{isSelected && <Check className="h-3 w-3 text-white" />}
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[var(--accent-orange)]/10 text-[var(--accent-orange)]">
{isBuiltIn ? (
<BrowserOSIcon size={24} />
) : (
<ProviderIcon type={provider.type} size={24} />
)}
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<span className="font-semibold">{provider.name}</span>
{isSelected && (
<Badge
variant="secondary"
className="rounded bg-[var(--accent-orange)]/10 text-[var(--accent-orange)]"
>
DEFAULT
</Badge>
)}
</div>
{isBuiltIn && provider.type === 'browseros' && kimiLaunch && (
<span className="mb-1 inline-block rounded-full border border-orange-300/60 bg-orange-100/70 px-3 py-0.5 font-semibold text-orange-700 text-xs dark:border-orange-400/40 dark:bg-orange-500/15 dark:text-orange-300">
In partnership with Moonshot AI
</span>
)}
<p className="truncate text-muted-foreground text-sm">
{isBuiltIn ? (
kimiLaunch ? (
'Extended usage limits for the next 2 weeks!'
<div className="flex flex-col gap-4 lg:flex-row lg:items-center">
<button
type="button"
onClick={onSelect}
className="flex min-w-0 flex-1 items-start gap-4 text-left"
>
<div
className={cn(
'mt-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-all',
isSelected
? 'border-[var(--accent-orange)] bg-[var(--accent-orange)]'
: 'border-border bg-background',
)}
>
{isSelected && <Check className="h-3 w-3 text-white" />}
</div>
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-[var(--accent-orange)]/10 text-[var(--accent-orange)]">
{isBuiltIn ? (
<BrowserOSIcon size={24} />
) : (
<>
BrowserOS-hosted model with strict rate limits.{' '}
<a
href="https://docs.browseros.com/features/bring-your-own-llm"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
onClick={(e) => e.stopPropagation()}
<ProviderIcon type={provider.type} size={24} />
)}
</div>
<div className="min-w-0 flex-1 space-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-[15px]">{provider.name}</span>
{isSelected && (
<Badge
variant="secondary"
className="rounded-full bg-[var(--accent-orange)]/10 px-3 py-1 text-[var(--accent-orange)]"
>
Bring your own key
</a>{' '}
for better performance.
</>
)
) : (
provider.baseUrl
? `${provider.modelId}${provider.baseUrl}`
: provider.modelId
)}
</p>
Default
</Badge>
)}
{provider.type === 'chatgpt-pro' && (
<Badge
variant="outline"
className="rounded-full border-emerald-500/30 bg-emerald-500/[0.08] px-3 py-1 text-emerald-700 dark:text-emerald-300"
>
Managed OAuth
</Badge>
)}
</div>
<p className="text-muted-foreground text-sm leading-5">
{description}
</p>
<div className="flex flex-wrap gap-2">
{detailBadges.map((item) => (
<Badge
key={item}
variant="outline"
className="rounded-full px-3 py-1 text-muted-foreground"
>
{item}
</Badge>
))}
</div>
</div>
</button>
{!isBuiltIn && (
<div className="flex shrink-0 flex-wrap gap-2 lg:justify-end">
<Button
variant="outline"
size="sm"
disabled={isTesting}
onClick={() => onTest?.()}
>
{isTesting && <Loader2 className="h-4 w-4 animate-spin" />}
{isTesting ? 'Testing...' : 'Test'}
</Button>
<Button variant="outline" size="sm" onClick={() => onEdit?.()}>
<PencilLine className="h-4 w-4" />
Edit
</Button>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={() => onDelete?.()}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
{!isBuiltIn && (
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={isTesting}
onClick={() => onTest?.()}
>
{isTesting && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
{isTesting ? 'Testing...' : 'Test'}
</Button>
<Button variant="outline" size="sm" onClick={() => onEdit?.()}>
Edit
</Button>
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={() => onDelete?.()}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</label>
</div>
)
}
function getProviderHost(baseUrl?: string): string | null {
if (!baseUrl) return null
try {
return new URL(baseUrl).host
} catch {
return baseUrl.replace(/^https?:\/\//, '')
}
}
function getProviderDescription({
provider,
isBuiltIn,
kimiLaunch,
}: {
provider: LlmProviderConfig
isBuiltIn: boolean
kimiLaunch: boolean
}) {
if (isBuiltIn) {
if (kimiLaunch) {
return 'Extended usage limits are enabled through the Moonshot AI partnership.'
}
return 'BrowserOS-hosted model with stricter shared limits than bring-your-own-provider setups.'
}
if (provider.type === 'chatgpt-pro') {
return 'Connected through your ChatGPT account so you can use the managed BrowserOS flow without local API keys.'
}
if (provider.baseUrl) {
return `Configured against ${provider.baseUrl}.`
}
return 'Custom provider configuration.'
}

View File

@@ -26,6 +26,7 @@ export const ProviderTemplatesSection: FC<ProviderTemplatesSectionProps> = ({
const kimiLaunch = useKimiLaunch()
const filteredTemplates = providerTemplates.filter((template) => {
if (template.id === 'chatgpt-pro') return false
if (template.id === 'moonshot') return kimiLaunch
if (template.id === 'openai-compatible') {
return supports(Feature.OPENAI_COMPATIBLE_SUPPORT)
@@ -38,9 +39,9 @@ export const ProviderTemplatesSection: FC<ProviderTemplatesSectionProps> = ({
<div className="rounded-xl border border-border bg-card p-6 shadow-sm transition-all hover:shadow-md">
<CollapsibleTrigger className="mb-4 flex w-full items-center justify-between text-left">
<div>
<h3 className="font-semibold text-lg">Quick provider templates</h3>
<h3 className="font-semibold text-lg">More provider templates</h3>
<p className="text-muted-foreground text-sm">
{filteredTemplates.length} templates available
Manual and API-key based providers
</p>
</div>
<ChevronDown