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:
co-authored by
Cursor
Brooklyn Nicholson
parent
b36a30db20
commit
9cbc37e25b
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user