Austin Pickett 9cbc37e25b
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>
2026-06-04 03:03:42 -05:00

207 lines
7.1 KiB
TypeScript

import { IconDownload, IconRefresh, IconUpload } from '@tabler/icons-react'
import { useRef } from 'react'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, Globe, Info, KeyRound, Sparkles, Wrench, Zap } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { OverlayIconButton } from '../overlays/overlay-chrome'
import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout'
import { OverlayView } from '../overlays/overlay-view'
import { AboutSettings } from './about-settings'
import { AppearanceSettings } from './appearance-settings'
import { ConfigSettings } from './config-settings'
import { SECTIONS } from './constants'
import { GatewaySettings } from './gateway-settings'
import { KeysSettings } from './keys-settings'
import { McpSettings } from './mcp-settings'
import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings'
import { SessionsSettings } from './sessions-settings'
import type { SettingsPageProps, SettingsView as SettingsViewId } from './types'
const SETTINGS_VIEWS: readonly SettingsViewId[] = [
...SECTIONS.map(s => `config:${s.id}` as SettingsViewId),
'providers',
'gateway',
'keys',
'mcp',
'sessions',
'about'
]
export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
// Providers subnav (Accounts vs API keys) lives in its own param so each
// sub-view is deep-linkable and survives a refresh.
const [providerView, setProviderView] = useRouteEnumParam<ProviderView>('pview', PROVIDER_VIEWS, 'accounts')
const openProviderView = (view: ProviderView) => {
setActiveView('providers')
setProviderView(view)
}
const importInputRef = useRef<HTMLInputElement | null>(null)
const exportConfig = async () => {
try {
const cfg = await getHermesConfigRecord()
const blob = new Blob([JSON.stringify(cfg, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'hermes-config.json'
a.click()
URL.revokeObjectURL(url)
triggerHaptic('success')
} catch (err) {
notifyError(err, 'Export failed')
}
}
const resetConfig = async () => {
if (!window.confirm('Reset all settings to Hermes defaults?')) {
return
}
try {
await saveHermesConfig(await getHermesConfigDefaults())
triggerHaptic('success')
onConfigSaved?.()
} catch (err) {
notifyError(err, 'Reset failed')
}
}
return (
<OverlayView closeLabel="Close settings" onClose={onClose}>
<OverlaySplitLayout>
<OverlaySidebar>
{SECTIONS.map(s => {
const view = `config:${s.id}` as SettingsViewId
return (
<OverlayNavItem
active={activeView === view}
icon={s.icon}
key={s.id}
label={s.label}
onClick={() => setActiveView(view)}
/>
)
})}
<div className="my-2 h-px bg-border/30" />
<OverlayNavItem
active={activeView === 'providers'}
icon={Zap}
label="Providers"
onClick={() => setActiveView('providers')}
/>
{activeView === 'providers' && (
<div className="ml-3.5 flex flex-col gap-0.5 pl-1.5">
<OverlayNavItem
active={providerView === 'accounts'}
icon={Sparkles}
label="Accounts"
nested
onClick={() => openProviderView('accounts')}
/>
<OverlayNavItem
active={providerView === 'keys'}
icon={KeyRound}
label="API keys"
nested
onClick={() => openProviderView('keys')}
/>
</div>
)}
<OverlayNavItem
active={activeView === 'gateway'}
icon={Globe}
label="Gateway"
onClick={() => setActiveView('gateway')}
/>
<OverlayNavItem
active={activeView === 'keys'}
icon={KeyRound}
label="Tools & Keys"
onClick={() => setActiveView('keys')}
/>
<OverlayNavItem
active={activeView === 'mcp'}
icon={Wrench}
label="MCP"
onClick={() => setActiveView('mcp')}
/>
<OverlayNavItem
active={activeView === 'sessions'}
icon={Archive}
label="Archived Chats"
onClick={() => setActiveView('sessions')}
/>
<div className="my-2 h-px bg-border/30" />
<OverlayNavItem
active={activeView === 'about'}
icon={Info}
label="About"
onClick={() => setActiveView('about')}
/>
<div className="mt-auto flex items-center gap-1 pt-2">
<OverlayIconButton onClick={() => void exportConfig()} title="Export config">
<IconDownload className="size-3.5" />
</OverlayIconButton>
<OverlayIconButton
onClick={() => {
triggerHaptic('open')
importInputRef.current?.click()
}}
title="Import config"
>
<IconUpload className="size-3.5" />
</OverlayIconButton>
<OverlayIconButton
className="hover:text-destructive"
onClick={() => {
triggerHaptic('warning')
void resetConfig()
}}
title="Reset to defaults"
>
<IconRefresh className="size-3.5" />
</OverlayIconButton>
</div>
</OverlaySidebar>
<OverlayMain className="px-0 pb-0 pt-[calc(var(--titlebar-height)+1rem)]">
{activeView === 'config:appearance' ? (
<AppearanceSettings />
) : activeView === 'about' ? (
<AboutSettings />
) : activeView === 'gateway' ? (
<GatewaySettings />
) : activeView.startsWith('config:') ? (
<ConfigSettings
activeSectionId={activeView.slice('config:'.length)}
importInputRef={importInputRef}
onConfigSaved={onConfigSaved}
onMainModelChanged={onMainModelChanged}
/>
) : activeView === 'providers' ? (
<ProvidersSettings onViewChange={setProviderView} view={providerView} />
) : activeView === 'keys' ? (
<KeysSettings />
) : activeView === 'mcp' ? (
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} />
) : (
<SessionsSettings />
)}
</OverlayMain>
</OverlaySplitLayout>
</OverlayView>
)
}
export { SettingsView as SettingsPage }