feat(desktop): dedicated Providers settings + polished Accounts/API-keys UX (#38551)

* feat(desktop): dedicated Providers settings with Accounts/API-keys subnav

Rework provider configuration in the desktop app into its own Providers
page that mirrors the first-run onboarding picker, instead of burying
provider keys in the generic Tools & Keys list.

- Add a Providers settings page (providers-settings.tsx) reusing the
  onboarding picker cards/ApiKeyForm so the two surfaces stay identical
- Add a sidebar subnav (Accounts vs API keys) backed by a deep-linkable
  `pview` URL param; nested OverlayNavItem variant for a lighter active
  state so children don't compete with the parent item
- Scope provider search to the active sub-view in its native card format
  (no more accordion fallback); collapse the API-key grid to the top
  providers behind a "Show all" toggle to cut scrolling
- Launch real in-app OAuth from settings via startManualProviderOAuth;
  fix the misleading red "reason" banner that showed during an active
  connect (neutral style, hidden during a flow, omitted for direct
  per-provider launches)
- Expand PROVIDER_GROUPS and add longest-prefix matching so providers
  like xAI/Ollama group correctly instead of landing under "Other"
- Drop redundant messaging API keys from Tools & Keys (channel_managed)

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(desktop): Cursor-style provider key list with inline inputs

Replace the card-grid API-key form on the Providers page with a
per-provider list (mirrors Cursor's API keys section):

- One row per vendor with its primary key input inline; rows with extra
  vars (base URL, region, alt tokens) expand to reveal those on focus
- Set keys show their redacted value as the placeholder; Save appears on
  edit, Remove on a set key
- Hide redundant alias key fields (e.g. ANTHROPIC_TOKEN vs
  ANTHROPIC_API_KEY) unless already set, and label set aliases by env var
  name so they're unambiguous
- Smaller mono input text + compact height

Co-authored-by: Cursor <cursoragent@cursor.com>

* style(desktop): flatten providers settings UI chrome

Tighten the providers settings surface to match the newer desktop style:
remove extra card rails/borders in API-key rows, reduce visual noise in the
providers subnav, replace bespoke link-like controls with shared text-button
variants, and improve key input readability.

* feat(desktop): rework providers settings UI

- Flatten the shared OAuth picker rows (accounts + onboarding): drop the
  rounded-2xl/border cards for flat hover-bg rows; Nous hero keeps a subtle
  tint plus an animated blue→purple arc border.
- Key fields collapse to a single input: a set key reads read-only (redacted)
  and edits in place on focus/click — no Replace/Cancel chrome. Save on type,
  Esc cancels (without closing the overlay), "Remove or esc to cancel" hint.
- Non-key overrides render boxless, content-sized (field-sizing) and
  right-anchored; advanced fields align under the primary key column.
- Add `xs` control size; size fields via padding (no fixed heights).
- Cards expand on key-input focus; chevron shows on hover/expanded; expanded
  state uses a ring + softer bg tier so hover ≠ focus.
- Relocate "Get a key" to the bottom-right of the expanded panel; drop the
  redundant provider description.
- Cmd+K: add Providers (accounts) and Provider API keys deep-links.

* fix(desktop): flatten provider fields, drop input shadows, fix Cmd+K provider rank

- KeyField: collapse to one stacked label-above-input form field (drop the
  bespoke `naked`/inline/column branches); empty advanced overrides fade until
  hover/focus/set
- styles: kill the resting + focus drop shadow on shared input chrome so form
  inputs sit flat (composer keeps its own shadow)
- Cmd+K: drop stray `providers` keyword from Skills & Tools so the Providers
  settings entry ranks first for "provider"

* fix(desktop): nous portal arc blue → orange

* fix(desktop): rank appearance above settings in Cmd+K

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com>
This commit is contained in:
Austin Pickett
2026-06-04 03:03:42 -05:00
committed by GitHub
co-authored by Cursor Brooklyn Nicholson
parent b36a30db20
commit 9cbc37e25b
16 changed files with 1468 additions and 469 deletions
@@ -24,12 +24,14 @@ import { $desktopBoot, type DesktopBootState } from '@/store/boot'
import {
$desktopOnboarding,
cancelOnboardingFlow,
clearPendingProviderOAuth,
closeManualOnboarding,
confirmOnboardingModel,
copyDeviceCode,
copyExternalCommand,
type OnboardingContext,
type OnboardingFlow,
peekPendingProviderOAuth,
recheckExternalSignin,
refreshOnboarding,
saveOnboardingApiKey,
@@ -47,7 +49,7 @@ interface DesktopOnboardingOverlayProps {
requestGateway: OnboardingContext['requestGateway']
}
interface ApiKeyOption {
export interface ApiKeyOption {
description: string
docsUrl: string
envKey: string
@@ -125,7 +127,7 @@ const FLOW_SUBTITLES: Record<OAuthProvider['flow'], string> = {
const providerTitle = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.title ?? p.name
const orderOf = (p: OAuthProvider) => PROVIDER_DISPLAY[p.id]?.order ?? 99
const sortProviders = (providers: OAuthProvider[]) =>
export const sortProviders = (providers: OAuthProvider[]) =>
[...providers].sort((a, b) => orderOf(a) - orderOf(b) || a.name.localeCompare(b.name))
export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway }: DesktopOnboardingOverlayProps) {
@@ -148,6 +150,36 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
}
}, [ctx, enabled, onboarding.requested])
// When the Providers settings page asked to connect a specific provider, the
// store stashed its id. Once the provider list has loaded and we're back at
// an idle picker, launch that exact OAuth flow so the user lands directly in
// sign-in instead of the picker they just came from.
useEffect(() => {
if (!onboarding.manual || onboarding.providers === null || onboarding.flow.status !== 'idle') {
return
}
const pendingId = peekPendingProviderOAuth()
if (!pendingId) {
return
}
const provider = onboarding.providers.find(p => p.id === pendingId)
if (provider) {
// Only clear once we've committed to launching it, so a failed/empty
// provider fetch doesn't silently drop the hand-off.
clearPendingProviderOAuth()
void startProviderOAuth(provider, ctx)
} else if (onboarding.providers.length > 0) {
// The list loaded but the id isn't a real provider — drop the stale
// hand-off. An empty list means the fetch isn't ready yet, so keep it
// and let a later refresh retry.
clearPendingProviderOAuth()
}
}, [ctx, onboarding.flow.status, onboarding.manual, onboarding.providers])
// Mount from frame 1 so we replace the boot overlay seamlessly. The
// configured field stays null until the runtime check resolves; only then
// do we know whether to dismiss (true) or surface the picker (false).
@@ -190,9 +222,12 @@ export function DesktopOnboardingOverlay({ enabled, onCompleted, requestGateway
)
}
// The launch reason is a prompt ("why am I seeing this"), not an error — real
// provider-setup failures are filtered out upstream and surfaced by FlowPanel.
// Keep it neutral so it never reads as a failure.
function ReasonNotice({ reason }: { reason: string }) {
return (
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
<div className="rounded-2xl border border-(--ui-stroke-tertiary) bg-(--ui-bg-tertiary)/40 px-4 py-3 text-sm text-muted-foreground">
{reason}
</div>
)
@@ -246,7 +281,7 @@ function Header() {
)
}
const FEATURED_ID = 'nous'
export const FEATURED_ID = 'nous'
const FEATURED_PITCH = 'One subscription, 300+ frontier models the recommended way to run Hermes'
const SHOW_ALL_KEY = 'hermes-onboarding-show-all-v1'
@@ -275,7 +310,13 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
const hasOauth = ordered.length > 0
if (mode === 'apikey' || !hasOauth) {
return <ApiKeyForm canGoBack={hasOauth} ctx={ctx} />
return (
<ApiKeyForm
canGoBack={hasOauth}
onBack={() => setOnboardingMode('oauth')}
onSave={(envKey, value, name) => saveOnboardingApiKey(envKey, value, name, ctx)}
/>
)
}
if (providers === null) {
@@ -324,7 +365,7 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
)
}
function FeaturedProviderRow({
export function FeaturedProviderRow({
onSelect,
provider
}: {
@@ -335,17 +376,17 @@ function FeaturedProviderRow({
return (
<button
className={cn(
'group flex w-full items-center justify-between gap-4 rounded-2xl border-2 border-primary/50 bg-primary/5 p-4 text-left transition hover:border-primary hover:bg-primary/10',
loggedIn && 'border-primary'
)}
className="group relative flex w-full items-center justify-between gap-4 rounded-[8px] bg-primary/[0.06] px-3 py-2.5 text-left transition-colors hover:bg-primary/10"
onClick={() => onSelect(provider)}
type="button"
>
<span aria-hidden className="arc-border arc-reverse arc-nous" />
<div className="min-w-0">
<div className="flex items-center gap-2">
<img alt="" className="size-5 shrink-0 rounded" src={assetPath('apple-touch-icon.png')} />
<span className="text-base font-semibold">{providerTitle(provider)}</span>
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">
{providerTitle(provider)}
</span>
{loggedIn ? (
<ConnectedTag />
) : (
@@ -357,7 +398,7 @@ function FeaturedProviderRow({
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{FEATURED_PITCH}</p>
</div>
<ChevronRight className="size-5 shrink-0 text-primary transition group-hover:translate-x-0.5" />
<ChevronRight className="size-4 shrink-0 text-primary transition group-hover:translate-x-0.5" />
</button>
)
}
@@ -371,15 +412,15 @@ function ConnectedTag() {
)
}
function KeyProviderRow({ onClick }: { onClick: () => void }) {
export function KeyProviderRow({ onClick }: { onClick: () => void }) {
return (
<button
className="group flex w-full items-center justify-between gap-3 rounded-2xl border border-border bg-background/60 p-3 text-left transition hover:border-primary/40 hover:bg-accent/40"
className="group flex w-full items-center justify-between gap-3 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)"
onClick={onClick}
type="button"
>
<div className="min-w-0">
<span className="text-sm font-semibold">OpenRouter</span>
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">OpenRouter</span>
<p className="mt-1 text-xs leading-5 text-muted-foreground">One key, hundreds of models — a solid default</p>
</div>
<ChevronRight className="size-4 text-muted-foreground transition group-hover:text-foreground" />
@@ -387,22 +428,27 @@ function KeyProviderRow({ onClick }: { onClick: () => void }) {
)
}
function ProviderRow({ onSelect, provider }: { onSelect: (provider: OAuthProvider) => void; provider: OAuthProvider }) {
export function ProviderRow({
onSelect,
provider
}: {
onSelect: (provider: OAuthProvider) => void
provider: OAuthProvider
}) {
const loggedIn = provider.status?.logged_in
const Trail = provider.flow === 'external' ? Terminal : ChevronRight
return (
<button
className={cn(
'group flex w-full items-center justify-between gap-3 rounded-2xl border border-border bg-background/60 p-3 text-left transition hover:border-primary/40 hover:bg-accent/40',
loggedIn && 'border-primary/30'
)}
className="group flex w-full items-center justify-between gap-3 rounded-[6px] px-3 py-2.5 text-left transition-colors hover:bg-(--ui-control-hover-background)"
onClick={() => onSelect(provider)}
type="button"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">{providerTitle(provider)}</span>
<span className="text-[length:var(--conversation-text-font-size)] font-semibold">
{providerTitle(provider)}
</span>
{loggedIn ? <ConnectedTag /> : null}
</div>
<p className="mt-1 text-xs leading-5 text-muted-foreground">{FLOW_SUBTITLES[provider.flow]}</p>
@@ -412,13 +458,62 @@ function ProviderRow({ onSelect, provider }: { onSelect: (provider: OAuthProvide
)
}
function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingContext }) {
const [option, setOption] = useState<ApiKeyOption>(API_KEY_OPTIONS[0])
// Presentational two-column key picker. Onboarding feeds it its curated
// options + a ctx-bound save; the Providers settings page feeds it the full
// provider catalog + a setEnvVar-backed save (plus `isSet`/`onClear` so it can
// double as a manage surface). Keep it free of store/ctx coupling so both
// surfaces render the identical form.
export function ApiKeyForm({
canGoBack,
isSet,
onBack,
onClear,
onSave,
options = API_KEY_OPTIONS,
redactedValue
}: {
canGoBack: boolean
isSet?: (envKey: string) => boolean
onBack: () => void
onClear?: (envKey: string) => void
onSave: (envKey: string, value: string, name: string) => Promise<{ message?: string; ok: boolean }>
options?: ApiKeyOption[]
redactedValue?: (envKey: string) => null | string | undefined
}) {
const [option, setOption] = useState<ApiKeyOption>(options[0])
const [value, setValue] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<null | string>(null)
// `options` can change at runtime when callers filter the catalog (e.g. the
// Providers page wiring its search into this grid). Keep the selection valid
// by snapping back to the first remaining option when the current one drops.
useEffect(() => {
if (options.length > 0 && !options.some(o => o.id === option.id)) {
setOption(options[0])
setValue('')
setError(null)
}
}, [option.id, options])
// The catalog grid can be tall, leaving the entry field far below the fold.
// On selection we scroll the field into view and focus it so it's always
// obvious where to paste next.
const entryRef = useRef<HTMLDivElement>(null)
const pick = (o: ApiKeyOption) => {
setOption(o)
setValue('')
setError(null)
requestAnimationFrame(() => {
entryRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
entryRef.current?.querySelector('input')?.focus()
})
}
const isLocal = option.envKey === 'OPENAI_BASE_URL'
const alreadySet = isSet?.(option.envKey) ?? false
// When set, surface the backend's redacted value (e.g. "sk-12…wxyz") as the
// placeholder so users can eyeball that the right key is in place.
const currentRedacted = alreadySet ? (redactedValue?.(option.envKey) ?? null) : null
// Only require a non-empty value — no length/format validation, so a short
// or unusual key can't block the user from continuing.
const canSave = value.trim().length >= 1
@@ -430,7 +525,7 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
setSaving(true)
setError(null)
const result = await saveOnboardingApiKey(option.envKey, value, option.name, ctx)
const result = await onSave(option.envKey, value, option.name)
if (result.ok) {
setValue('')
@@ -446,7 +541,7 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
{canGoBack ? (
<button
className="-mt-1 flex items-center gap-1 self-start text-xs font-medium text-muted-foreground hover:text-foreground"
onClick={() => setOnboardingMode('oauth')}
onClick={onBack}
type="button"
>
<ChevronLeft className="size-3" />
@@ -455,30 +550,30 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
) : null}
<div className="grid gap-2 sm:grid-cols-2">
{API_KEY_OPTIONS.map(o => (
{options.map(o => (
<button
className={cn(
'rounded-2xl border bg-background/60 p-3 text-left transition hover:bg-accent/50',
option.id === o.id ? 'border-primary ring-2 ring-primary/20' : 'border-border'
)}
key={o.id}
onClick={() => {
setOption(o)
setValue('')
setError(null)
}}
onClick={() => pick(o)}
type="button"
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{o.name}</span>
{option.id === o.id ? <Check className="size-4 text-primary" /> : null}
{option.id === o.id ? (
<Check className="size-4 text-primary" />
) : isSet?.(o.envKey) ? (
<Check className="size-3.5 text-muted-foreground" />
) : null}
</div>
{o.short ? <p className="mt-1 text-xs text-muted-foreground">{o.short}</p> : null}
</button>
))}
</div>
<div className="grid gap-2">
<div className="grid scroll-mt-4 gap-2" ref={entryRef}>
<div className="flex items-center justify-between gap-3">
<p className="text-sm leading-6 text-muted-foreground">{option.description}</p>
{option.docsUrl ? <DocsLink href={option.docsUrl}>Get a key</DocsLink> : null}
@@ -489,17 +584,24 @@ function ApiKeyForm({ canGoBack, ctx }: { canGoBack: boolean; ctx: OnboardingCon
className="font-mono"
onChange={e => setValue(e.target.value)}
onKeyDown={e => e.key === 'Enter' && void submit()}
placeholder={option.placeholder || 'Paste API key'}
placeholder={currentRedacted ?? (alreadySet ? 'Replace current value' : option.placeholder || 'Paste API key')}
type={isLocal ? 'text' : 'password'}
value={value}
/>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
</div>
<div className="flex justify-end">
<div className="flex items-center justify-between gap-3">
<div>
{alreadySet && onClear ? (
<Button onClick={() => onClear(option.envKey)} size="sm" variant="ghost">
Remove
</Button>
) : null}
</div>
<Button disabled={!canSave || saving} onClick={() => void submit()}>
{saving ? <Loader2 className="size-4 animate-spin" /> : <KeyRound className="size-4" />}
{saving ? 'Connecting' : 'Connect'}
{saving ? 'Connecting' : alreadySet ? 'Update' : 'Connect'}
</Button>
</div>
</div>
@@ -10,6 +10,7 @@ export const controlVariants = cva(
{
variants: {
size: {
xs: 'px-2 py-0.5 text-[0.6875rem] leading-4',
sm: 'px-2 py-1',
default: 'px-2.5 py-1.5',
lg: 'px-3 py-2 text-sm leading-5'