diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index ffabbc864be..31a034417fd 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -36,7 +36,8 @@ import { Settings, Sun, Users, - Wrench + Wrench, + Zap } from '@/lib/icons' import { cn } from '@/lib/utils' import { $commandPaletteOpen, closeCommandPalette, setCommandPaletteOpen } from '@/store/command-palette' @@ -98,8 +99,20 @@ const toSessionEntry = (session: SessionRow): SessionEntry => ({ }) const NON_CONFIG_SETTINGS: ReadonlyArray<{ icon: IconComponent; keywords?: string[]; label: string; tab: string }> = [ + { + icon: Zap, + keywords: ['accounts', 'sign in', 'oauth', 'login', 'subscription', 'models', 'anthropic', 'openai'], + label: 'Providers', + tab: 'providers&pview=accounts' + }, + { + icon: KeyRound, + keywords: ['providers', 'api key', 'keys', 'secrets', 'tokens'], + label: 'Provider API keys', + tab: 'providers&pview=keys' + }, { icon: Globe, keywords: ['connection', 'messaging'], label: 'Gateway', tab: 'gateway' }, - { icon: KeyRound, keywords: ['api', 'secrets', 'tokens', 'credentials'], label: 'API Keys', tab: 'keys' }, + { icon: KeyRound, keywords: ['api', 'secrets', 'tokens', 'credentials'], label: 'Tools & Keys', tab: 'keys' }, { icon: Wrench, keywords: ['servers', 'tools'], label: 'MCP', tab: 'mcp' }, { icon: Archive, keywords: ['history', 'archived'], label: 'Archived Chats', tab: 'sessions' }, { icon: Info, keywords: ['version', 'about'], label: 'About', tab: 'about' } @@ -169,7 +182,7 @@ export function CommandPalette() { { icon: Wrench, id: 'nav-skills', - keywords: ['tools', 'toolsets', 'providers'], + keywords: ['tools', 'toolsets'], label: 'Skills & Tools', run: go(SKILLS_ROUTE) }, @@ -207,25 +220,9 @@ export function CommandPalette() { ] }, { - heading: 'Settings', - items: [ - ...SECTIONS.map(section => ({ - icon: section.icon, - id: `set-config-${section.id}`, - keywords: ['settings', section.label], - label: section.label, - run: go(settingsTab(`config:${section.id}`)) - })), - ...NON_CONFIG_SETTINGS.map(entry => ({ - icon: entry.icon, - id: `set-${entry.tab}`, - keywords: ['settings', ...(entry.keywords ?? [])], - label: entry.label, - run: go(settingsTab(entry.tab)) - })) - ] - }, - { + // Declared before Settings: cmdk keeps group order, so this keeps the + // theme/mode pickers on top for "theme"/"color" queries instead of + // buried under a fuzzy Settings match. heading: 'Appearance', items: [ { @@ -243,6 +240,25 @@ export function CommandPalette() { to: 'color-mode' } ] + }, + { + heading: 'Settings', + items: [ + ...SECTIONS.map(section => ({ + icon: section.icon, + id: `set-config-${section.id}`, + keywords: ['settings', section.label], + label: section.label, + run: go(settingsTab(`config:${section.id}`)) + })), + ...NON_CONFIG_SETTINGS.map(entry => ({ + icon: entry.icon, + id: `set-${entry.tab}`, + keywords: ['settings', ...(entry.keywords ?? [])], + label: entry.label, + run: go(settingsTab(entry.tab)) + })) + ] } ] }, [go]) diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 86a6cca19ea..020455b3314 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -316,7 +316,7 @@ export function DesktopController() { }) const openProviderSettings = useCallback(() => { - navigate(`${SETTINGS_ROUTE}?tab=keys`) + navigate(`${SETTINGS_ROUTE}?tab=providers`) }, [navigate]) const modelMenuContent = useMemo( diff --git a/apps/desktop/src/app/overlays/overlay-split-layout.tsx b/apps/desktop/src/app/overlays/overlay-split-layout.tsx index a70e9fc37ff..e713e4ea49e 100644 --- a/apps/desktop/src/app/overlays/overlay-split-layout.tsx +++ b/apps/desktop/src/app/overlays/overlay-split-layout.tsx @@ -24,6 +24,9 @@ interface OverlayNavItemProps { active: boolean icon: IconComponent label: string + // Renders as an indented child of another nav item: smaller icon and a + // lighter active state so it never competes with the boxed parent item. + nested?: boolean onClick: () => void trailing?: ReactNode } @@ -70,19 +73,29 @@ export function OverlayMain({ children, className }: OverlayMainProps) { ) } -export function OverlayNavItem({ active, icon: Icon, label, onClick, trailing }: OverlayNavItemProps) { +export function OverlayNavItem({ active, icon: Icon, label, nested, onClick, trailing }: OverlayNavItemProps) { return ( diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 09c75d1b17e..1a2891d7257 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -15,9 +15,21 @@ import type { ThemeMode } from '@/themes/context' import type { DesktopConfigSection } from './types' +// Provider group definitions used to fold raw env-var names like +// ``XAI_API_KEY`` into a single "xAI" card with a friendly label, short +// description, and signup URL. Membership is determined by longest +// prefix match (see ``providerGroup`` in helpers.ts) so more specific +// prefixes (``MINIMAX_CN_``) correctly beat their general parents +// (``MINIMAX_``). New providers should be added here so they get their +// own card in Settings → Keys instead of being lumped into "Other". interface ProviderPrefix { prefix: string name: string + /** Optional one-line tagline shown beneath the group name. */ + description?: string + /** Optional canonical signup/console URL surfaced from the card header. */ + docsUrl?: string + /** Lower numbers float to the top of the providers list. */ priority: number } @@ -25,24 +37,180 @@ export const EMPTY_SELECT_VALUE = '__hermes_empty__' export const CONTROL_TEXT = 'text-xs' export const PROVIDER_GROUPS: ProviderPrefix[] = [ - { prefix: 'NOUS_', name: 'Nous Portal', priority: 0 }, - { prefix: 'ANTHROPIC_', name: 'Anthropic', priority: 1 }, - { prefix: 'DASHSCOPE_', name: 'DashScope (Qwen)', priority: 2 }, - { prefix: 'HERMES_QWEN_', name: 'DashScope (Qwen)', priority: 2 }, - { prefix: 'DEEPSEEK_', name: 'DeepSeek', priority: 3 }, - { prefix: 'GOOGLE_', name: 'Gemini', priority: 4 }, + { + prefix: 'NOUS_', + name: 'Nous Portal', + description: 'Hosted Hermes & Nous-trained models', + docsUrl: 'https://portal.nousresearch.com', + priority: 0 + }, + { + prefix: 'OPENROUTER_', + name: 'OpenRouter', + description: 'Aggregator for hundreds of frontier models', + docsUrl: 'https://openrouter.ai/keys', + priority: 1 + }, + { + prefix: 'ANTHROPIC_', + name: 'Anthropic', + description: 'Claude API access (Sonnet, Opus, Haiku)', + docsUrl: 'https://console.anthropic.com/settings/keys', + priority: 2 + }, + { + prefix: 'XAI_', + name: 'xAI', + description: 'Grok models (use OAuth for SuperGrok / Premium+)', + docsUrl: 'https://console.x.ai/', + priority: 3 + }, + { + prefix: 'GOOGLE_', + name: 'Gemini', + description: 'Google AI Studio (Gemini 1.5 / 2.0 / 2.5)', + docsUrl: 'https://aistudio.google.com/app/apikey', + priority: 4 + }, { prefix: 'GEMINI_', name: 'Gemini', priority: 4 }, - { prefix: 'GLM_', name: 'GLM / Z.AI', priority: 5 }, - { prefix: 'ZAI_', name: 'GLM / Z.AI', priority: 5 }, - { prefix: 'Z_AI_', name: 'GLM / Z.AI', priority: 5 }, - { prefix: 'HF_', name: 'Hugging Face', priority: 6 }, - { prefix: 'KIMI_', name: 'Kimi / Moonshot', priority: 7 }, - { prefix: 'MINIMAX_', name: 'MiniMax', priority: 8 }, - { prefix: 'MINIMAX_CN_', name: 'MiniMax (China)', priority: 9 }, - { prefix: 'OPENCODE_GO_', name: 'OpenCode Go', priority: 10 }, - { prefix: 'OPENCODE_ZEN_', name: 'OpenCode Zen', priority: 11 }, - { prefix: 'OPENROUTER_', name: 'OpenRouter', priority: 12 }, - { prefix: 'XIAOMI_', name: 'Xiaomi MiMo', priority: 13 } + { prefix: 'HERMES_GEMINI_', name: 'Gemini', priority: 4 }, + { + prefix: 'DEEPSEEK_', + name: 'DeepSeek', + description: 'Direct DeepSeek API (V3.x, R1)', + docsUrl: 'https://platform.deepseek.com/api_keys', + priority: 5 + }, + { + prefix: 'DASHSCOPE_', + name: 'DashScope (Qwen)', + description: 'Alibaba Cloud DashScope — Qwen and multi-vendor models', + docsUrl: 'https://modelstudio.console.alibabacloud.com/', + priority: 6 + }, + { prefix: 'HERMES_QWEN_', name: 'DashScope (Qwen)', priority: 6 }, + { + prefix: 'GLM_', + name: 'GLM / Z.AI', + description: 'Zhipu GLM-4.6 and Z.AI hosted endpoints', + docsUrl: 'https://z.ai/', + priority: 7 + }, + { prefix: 'ZAI_', name: 'GLM / Z.AI', priority: 7 }, + { prefix: 'Z_AI_', name: 'GLM / Z.AI', priority: 7 }, + { + prefix: 'KIMI_', + name: 'Kimi / Moonshot', + description: 'Moonshot Kimi K2 / coding endpoints', + docsUrl: 'https://platform.moonshot.cn/', + priority: 8 + }, + { + prefix: 'KIMI_CN_', + name: 'Kimi (China)', + description: 'Moonshot China endpoint', + docsUrl: 'https://platform.moonshot.cn/', + priority: 9 + }, + { + prefix: 'MINIMAX_', + name: 'MiniMax', + description: 'MiniMax-M2 and Hailuo international endpoints', + docsUrl: 'https://www.minimax.io/', + priority: 10 + }, + { + prefix: 'MINIMAX_CN_', + name: 'MiniMax (China)', + description: 'MiniMax mainland China endpoint', + docsUrl: 'https://www.minimaxi.com/', + priority: 11 + }, + { + prefix: 'HF_', + name: 'Hugging Face', + description: 'Inference Providers — 20+ open models via router.huggingface.co', + docsUrl: 'https://huggingface.co/settings/tokens', + priority: 12 + }, + { + prefix: 'OPENCODE_ZEN_', + name: 'OpenCode Zen', + description: 'Pay-as-you-go access to curated coding models', + docsUrl: 'https://opencode.ai/auth', + priority: 13 + }, + { + prefix: 'OPENCODE_GO_', + name: 'OpenCode Go', + description: '$10/month subscription for open coding models', + docsUrl: 'https://opencode.ai/auth', + priority: 14 + }, + { + prefix: 'NVIDIA_', + name: 'NVIDIA NIM', + description: 'build.nvidia.com or your own local NIM endpoint', + docsUrl: 'https://build.nvidia.com/', + priority: 15 + }, + { + prefix: 'OLLAMA_', + name: 'Ollama Cloud', + description: 'Cloud-hosted open models from ollama.com', + docsUrl: 'https://ollama.com/settings', + priority: 16 + }, + { + prefix: 'LM_', + name: 'LM Studio', + description: 'Local LM Studio server (OpenAI-compatible)', + docsUrl: 'https://lmstudio.ai/docs/local-server', + priority: 17 + }, + { + prefix: 'STEPFUN_', + name: 'StepFun', + description: 'StepFun Step Plan coding models', + docsUrl: 'https://platform.stepfun.com/', + priority: 18 + }, + { + prefix: 'XIAOMI_', + name: 'Xiaomi MiMo', + description: 'MiMo-V2.5 and Xiaomi proprietary models', + docsUrl: 'https://platform.xiaomimimo.com', + priority: 19 + }, + { + prefix: 'ARCEEAI_', + name: 'Arcee AI', + description: 'Arcee-hosted small + medium models', + docsUrl: 'https://chat.arcee.ai/', + priority: 20 + }, + { prefix: 'ARCEE_', name: 'Arcee AI', priority: 20 }, + { + prefix: 'GMI_', + name: 'GMI Cloud', + description: 'GMI Cloud GPU + model serving', + docsUrl: 'https://www.gmicloud.ai/', + priority: 21 + }, + { + prefix: 'AZURE_FOUNDRY_', + name: 'Azure Foundry', + description: 'Azure AI Foundry custom endpoints (OpenAI / Anthropic-compatible)', + docsUrl: 'https://ai.azure.com/', + priority: 22 + }, + { + prefix: 'AWS_', + name: 'AWS Bedrock', + description: 'Authenticate via AWS profile + region', + docsUrl: 'https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-regions.html', + priority: 23 + } ] export const BUILTIN_PERSONALITIES = [ diff --git a/apps/desktop/src/app/settings/env-credentials.tsx b/apps/desktop/src/app/settings/env-credentials.tsx new file mode 100644 index 00000000000..5bcfd8f9baf --- /dev/null +++ b/apps/desktop/src/app/settings/env-credentials.tsx @@ -0,0 +1,354 @@ +import { useEffect, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { Input } from '@/components/ui/input' +import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes' +import { Check, Eye, EyeOff, type IconComponent, Save, Trash2 } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { notify, notifyError } from '@/store/notifications' +import type { EnvVarInfo } from '@/types/hermes' + +import { CONTROL_TEXT } from './constants' +import { asText, includesQuery, redactedValue, withoutKey } from './helpers' +import { Pill } from './primitives' +import type { EnvRowProps } from './types' + +// Shared filter used by every credential surface (Providers + Keys pages): +// category gate first, then a free-text match across key name + description. +export function filterEnv(info: EnvVarInfo, key: string, q: string, cat: string, extra?: string): boolean { + if (asText(info.category) !== cat) { + return false + } + + if (!q) { + return true + } + + return ( + key.toLowerCase().includes(q) || + includesQuery(info.description, q) || + Boolean(extra && extra.toLowerCase().includes(q)) + ) +} + +function EnvActions({ + varKey, + info, + saving, + onEdit, + onClear, + onReveal, + isRevealed, + showReveal = true +}: EnvActionsProps) { + return ( +
+ {info.url && ( + + )} + {info.is_set && showReveal && ( + + )} + + {info.is_set && ( + + )} +
+ ) +} + +export function EnvVarRow({ + varKey, + info, + edits, + revealed, + saving, + setEdits, + onSave, + onClear, + onReveal, + compact = false +}: EnvRowProps) { + const isEditing = edits[varKey] !== undefined + const isRevealed = revealed[varKey] !== undefined + const value = isRevealed ? revealed[varKey] : info.redacted_value + const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' })) + + if (compact && !isEditing) { + return ( +
+
+
{varKey}
+
{info.description}
+
+ +
+ ) + } + + return ( +
+
+
+
+ {varKey} + + {info.is_set && } + {info.is_set ? 'Set' : 'Not set'} + +
+

{info.description}

+
+ +
+ + {!isEditing && info.is_set && ( +
+ {value || '---'} +
+ )} + + {isEditing && ( +
+ setEdits(c => ({ ...c, [varKey]: e.target.value }))} + placeholder={info.is_set ? 'Replace current value' : 'Enter value'} + type={info.is_password ? 'password' : 'text'} + value={edits[varKey]} + /> + + +
+ )} +
+ ) +} + +export function SettingsCategoryHeading({ count, icon: Icon, title }: CategoryHeadingProps) { + return ( +
+ + {title} + {count && {count}} +
+ ) +} + +// Owns the env-var fetch + the edit/reveal/save/delete lifecycle so multiple +// credential pages (Providers, Keys) share one source of truth and one set of +// mutation handlers instead of duplicating the plumbing. +export function useEnvCredentials(): UseEnvCredentials { + const [vars, setVars] = useState | null>(null) + const [edits, setEdits] = useState>({}) + const [revealed, setRevealed] = useState>({}) + const [saving, setSaving] = useState(null) + + // Best-effort cleanup of a retired localStorage flag (global "Show + // advanced" toggle) — everything in these views is configuration-level. + useEffect(() => { + try { + window.localStorage.removeItem('desktop.settings.keys.show_advanced') + } catch { + // Ignore — old key cleanup is best-effort. + } + }, []) + + useEffect(() => { + let cancelled = false + + void (async () => { + try { + const next = await getEnvVars() + + if (!cancelled) { + setVars(next) + } + } catch (err) { + notifyError(err, 'API keys failed to load') + } + })() + + return () => void (cancelled = true) + }, []) + + function patchVar(key: string, patch: Partial>) { + setVars(c => (c ? { ...c, [key]: { ...c[key], ...patch } } : c)) + } + + function clearLocalState(key: string) { + setEdits(c => withoutKey(c, key)) + setRevealed(c => withoutKey(c, key)) + } + + async function handleSave(key: string) { + const value = edits[key] + + if (!value) { + return + } + + setSaving(key) + + try { + await setEnvVar(key, value) + patchVar(key, { is_set: true, redacted_value: redactedValue(value) }) + clearLocalState(key) + notify({ kind: 'success', title: 'Credential saved', message: `${key} updated.` }) + } catch (err) { + notifyError(err, `Failed to save ${key}`) + } finally { + setSaving(null) + } + } + + // Direct save for a known value (no edit-state round-trip) — used by the + // onboarding-style key form, which owns its own input. Returns a result so + // the form can surface inline errors instead of only toasting. + async function saveValue(key: string, value: string): Promise<{ message?: string; ok: boolean }> { + const trimmed = value.trim() + + if (!trimmed) { + return { message: 'Enter a value first.', ok: false } + } + + setSaving(key) + + try { + await setEnvVar(key, trimmed) + patchVar(key, { is_set: true, redacted_value: redactedValue(trimmed) }) + clearLocalState(key) + notify({ kind: 'success', message: `${key} updated.`, title: 'Credential saved' }) + + return { ok: true } + } catch (err) { + notifyError(err, `Failed to save ${key}`) + + return { message: err instanceof Error ? err.message : 'Could not save credential.', ok: false } + } finally { + setSaving(null) + } + } + + async function handleClear(key: string) { + if (!window.confirm(`Remove ${key} from .env?`)) { + return + } + + setSaving(key) + + try { + await deleteEnvVar(key) + patchVar(key, { is_set: false, redacted_value: null }) + clearLocalState(key) + notify({ kind: 'success', title: 'Credential removed', message: `${key} removed.` }) + } catch (err) { + notifyError(err, `Failed to remove ${key}`) + } finally { + setSaving(null) + } + } + + async function handleReveal(key: string) { + if (revealed[key]) { + setRevealed(c => withoutKey(c, key)) + + return + } + + try { + const result = await revealEnvVar(key) + setRevealed(c => ({ ...c, [key]: result.value })) + } catch (err) { + notifyError(err, `Failed to reveal ${key}`) + } + } + + return { + saveValue, + vars, + rowProps: { + edits, + revealed, + saving, + setEdits, + onSave: handleSave, + onClear: handleClear, + onReveal: handleReveal + } + } +} + +interface CategoryHeadingProps { + count?: string + icon: IconComponent + title: string +} + +interface EnvActionsProps { + varKey: string + info: EnvVarInfo + saving: string | null + onEdit: () => void + onClear: (key: string) => void + onReveal: (key: string) => void + isRevealed: boolean + showReveal?: boolean +} + +interface UseEnvCredentials { + rowProps: Omit + saveValue: (key: string, value: string) => Promise<{ message?: string; ok: boolean }> + vars: Record | null +} diff --git a/apps/desktop/src/app/settings/helpers.test.ts b/apps/desktop/src/app/settings/helpers.test.ts index 87ff47bb25e..097b9cfed41 100644 --- a/apps/desktop/src/app/settings/helpers.test.ts +++ b/apps/desktop/src/app/settings/helpers.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import type { HermesConfigRecord } from '@/types/hermes' -import { getNested, setNested } from './helpers' +import { getNested, providerGroup, setNested } from './helpers' describe('settings helpers', () => { it('reads and writes nested config paths', () => { @@ -20,4 +20,28 @@ describe('settings helpers', () => { expect(() => setNested(config, 'constructor.prototype.polluted', true)).toThrow('Unsafe config path') expect(({} as Record).polluted).toBeUndefined() }) + + describe('providerGroup', () => { + it('maps a provider env var to its labeled group', () => { + expect(providerGroup('XAI_API_KEY')).toBe('xAI') + expect(providerGroup('NOUS_API_KEY')).toBe('Nous Portal') + expect(providerGroup('OPENROUTER_API_KEY')).toBe('OpenRouter') + }) + + it('prefers the longest matching prefix so CN/regional buckets win', () => { + // MINIMAX_CN_ must beat the generic MINIMAX_ prefix. + expect(providerGroup('MINIMAX_CN_API_KEY')).toBe('MiniMax (China)') + expect(providerGroup('MINIMAX_API_KEY')).toBe('MiniMax') + // KIMI_CN_ likewise must beat KIMI_. + expect(providerGroup('KIMI_CN_API_KEY')).toBe('Kimi (China)') + expect(providerGroup('KIMI_API_KEY')).toBe('Kimi / Moonshot') + // HERMES_QWEN_ and HERMES_GEMINI_ both share the HERMES_ stem. + expect(providerGroup('HERMES_QWEN_BASE_URL')).toBe('DashScope (Qwen)') + expect(providerGroup('HERMES_GEMINI_CLIENT_ID')).toBe('Gemini') + }) + + it('falls back to "Other" for un-grouped env vars', () => { + expect(providerGroup('SOMETHING_RANDOM')).toBe('Other') + }) + }) }) diff --git a/apps/desktop/src/app/settings/helpers.ts b/apps/desktop/src/app/settings/helpers.ts index f27db8478db..1c4f61f9a56 100644 --- a/apps/desktop/src/app/settings/helpers.ts +++ b/apps/desktop/src/app/settings/helpers.ts @@ -19,9 +19,30 @@ export const withoutKey = (record: Record, key: string) => { export const redactedValue = (v: string) => (v.length <= 8 ? '••••' : `${v.slice(0, 4)}...${v.slice(-4)}`) -export const providerGroup = (key: string) => PROVIDER_GROUPS.find(g => key.startsWith(g.prefix))?.name ?? 'Other' +// Longest-prefix match so a more specific group like ``MINIMAX_CN_`` is +// chosen over its shorter parent ``MINIMAX_``. Falls back to the bucket +// "Other" used by the Keys settings view for un-grouped env vars. +export const providerGroup = (key: string) => { + let best: (typeof PROVIDER_GROUPS)[number] | undefined -export const providerPriority = (name: string) => PROVIDER_GROUPS.find(g => g.name === name)?.priority ?? 99 + for (const candidate of PROVIDER_GROUPS) { + if (!key.startsWith(candidate.prefix)) { + continue + } + + if (!best || candidate.prefix.length > best.prefix.length) { + best = candidate + } + } + + return best?.name ?? 'Other' +} + +export const providerMeta = (name: string) => + PROVIDER_GROUPS.find(g => g.name === name && (g.description || g.docsUrl)) ?? + PROVIDER_GROUPS.find(g => g.name === name) + +export const providerPriority = (name: string) => providerMeta(name)?.priority ?? 99 const POLLUTING_PATH_PARTS = new Set(['__proto__', 'constructor', 'prototype']) diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index 46c2081ec8f..a2580723ca9 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -3,7 +3,7 @@ import { useRef } from 'react' import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes' import { triggerHaptic } from '@/lib/haptics' -import { Archive, Globe, Info, KeyRound, Wrench } from '@/lib/icons' +import { Archive, Globe, Info, KeyRound, Sparkles, Wrench, Zap } from '@/lib/icons' import { notifyError } from '@/store/notifications' import { useRouteEnumParam } from '../hooks/use-route-enum-param' @@ -18,11 +18,13 @@ 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', @@ -32,6 +34,14 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [ 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('pview', PROVIDER_VIEWS, 'accounts') + + const openProviderView = (view: ProviderView) => { + setActiveView('providers') + setProviderView(view) + } const importInputRef = useRef(null) @@ -83,6 +93,30 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang ) })}
+ setActiveView('providers')} + /> + {activeView === 'providers' && ( +
+ openProviderView('accounts')} + /> + openProviderView('keys')} + /> +
+ )} setActiveView('keys')} /> + ) : activeView === 'providers' ? ( + ) : activeView === 'keys' ? ( ) : activeView === 'mcp' ? ( diff --git a/apps/desktop/src/app/settings/keys-settings.tsx b/apps/desktop/src/app/settings/keys-settings.tsx index 2dbf228e6ae..a09950f1cdf 100644 --- a/apps/desktop/src/app/settings/keys-settings.tsx +++ b/apps/desktop/src/app/settings/keys-settings.tsx @@ -1,425 +1,162 @@ -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' -import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes' -import { Check, Eye, EyeOff, Save, Settings2, Trash2, Zap } from '@/lib/icons' +import { Settings2, Wrench } from '@/lib/icons' import { cn } from '@/lib/utils' -import { notify, notifyError } from '@/store/notifications' import type { EnvVarInfo } from '@/types/hermes' -import { CONTROL_TEXT } from './constants' -import { asText, prettyName, providerGroup, providerPriority, redactedValue, withoutKey } from './helpers' -import { LoadingState, Pill, SectionHeading, SettingsContent } from './primitives' -import type { EnvPatch, EnvRowProps, ProviderGroup } from './types' -import { useDeepLinkHighlight } from './use-deep-link-highlight' +import { EnvVarRow, useEnvCredentials } from './env-credentials' +import { asText } from './helpers' +import { LoadingState, SettingsContent } from './primitives' -interface EnvActionsProps { - varKey: string - info: EnvVarInfo - saving: string | null - onEdit: () => void - onClear: (key: string) => void - onReveal: (key: string) => void - isRevealed: boolean - showReveal?: boolean +// Providers live on their own page; messaging-platform credentials live on the +// dedicated Messaging page (and are hidden here via `channel_managed`). This +// view covers tool API keys plus server/setting env vars (API server, webhook, +// gateway), which fold into the Settings tab. +const KEY_TABS = [ + { icon: Wrench, id: 'tool', label: 'Tools' }, + { icon: Settings2, id: 'setting', label: 'Settings' } +] as const + +type KeyCategoryId = (typeof KEY_TABS)[number]['id'] + +const CATEGORY_LABELS: Record = { + setting: 'Settings', + tool: 'Tools' } -function EnvActions({ - varKey, - info, - saving, - onEdit, - onClear, - onReveal, - isRevealed, - showReveal = true -}: EnvActionsProps) { - return ( -
- {info.url && ( - - )} - {info.is_set && showReveal && ( - - )} - - {info.is_set && ( - - )} -
- ) +// Backend categories that surface under each tab. Server/gateway vars carry the +// `messaging` category server-side but belong with general settings here, since +// the platform-credential half of `messaging` is owned by the Messaging page. +const TAB_CATEGORIES: Record = { + setting: ['setting', 'messaging'], + tool: ['tool'] } -function EnvVarRow({ - varKey, - info, - edits, - revealed, - saving, - setEdits, - onSave, - onClear, - onReveal, - compact = false -}: EnvRowProps) { - const isEditing = edits[varKey] !== undefined - const isRevealed = revealed[varKey] !== undefined - const value = isRevealed ? revealed[varKey] : info.redacted_value - const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' })) - - if (compact && !isEditing) { - return ( -
-
-
{varKey}
-
{info.description}
-
- -
- ) +function tabForCategory(category: string): KeyCategoryId | null { + for (const tab of KEY_TABS) { + if (TAB_CATEGORIES[tab.id].includes(category)) { + return tab.id + } } - return ( -
-
-
-
- {varKey} - - {info.is_set && } - {info.is_set ? 'Set' : 'Not set'} - -
-

{info.description}

-
- -
- - {!isEditing && info.is_set && ( -
- {value || '---'} -
- )} - - {isEditing && ( -
- setEdits(c => ({ ...c, [varKey]: e.target.value }))} - placeholder={info.is_set ? 'Replace current value' : 'Enter value'} - type={info.is_password ? 'password' : 'text'} - value={edits[varKey]} - /> - - -
- )} -
- ) + return null } -function EnvProviderGroup({ - group, - rowProps, - forceExpand = false +function CategoryTabs({ + active, + counts, + onSelect }: { - group: ProviderGroup - rowProps: Omit - forceExpand?: boolean + active: KeyCategoryId + counts: Record + onSelect: (id: KeyCategoryId) => void }) { - const setCount = group.entries.filter(([, info]) => info.is_set).length - // Default-expand providers that already have at least one key set; the - // user is much more likely to be coming back to edit those than to start - // configuring a fresh provider from scratch. - const [expanded, setExpanded] = useState(setCount > 0 || forceExpand) - - useEffect(() => { - if (forceExpand) { - setExpanded(true) - } - }, [forceExpand]) - return ( -
- - {expanded && ( -
- {group.entries.map(([key, info]) => ( -
- -
- ))} -
- )} +
+ {KEY_TABS.map(tab => { + const isActive = active === tab.id + const count = counts[tab.id] + + return ( + + ) + })}
) } export function KeysSettings() { - const [vars, setVars] = useState | null>(null) - const [edits, setEdits] = useState>({}) - const [revealed, setRevealed] = useState>({}) - const [saving, setSaving] = useState(null) + const { rowProps, vars } = useEnvCredentials() + const [activeCategory, setActiveCategory] = useState('tool') - // Deep-link from the command palette (?key=): force-expand the - // matching provider group, scroll the row in, and flash it. - const highlightKey = useDeepLinkHighlight({ - elementId: key => `env-var-${key}`, - param: 'key', - ready: key => Boolean(vars?.[key]) - }) - - // We used to hide ~80% of rows behind a global "Show advanced" toggle, but - // everything in this view is configuration-level — "advanced" was a poor - // distinction. The full list is rendered now and provider groups - // default-collapsed-unless-set keep the surface manageable. - useEffect(() => { - try { - window.localStorage.removeItem('desktop.settings.keys.show_advanced') - } catch { - // Ignore — old key cleanup is best-effort. - } - }, []) - - useEffect(() => { - let cancelled = false - - void (async () => { - try { - const next = await getEnvVars() - - if (!cancelled) { - setVars(next) - } - } catch (err) { - notifyError(err, 'API keys failed to load') - } - })() - - return () => void (cancelled = true) - }, []) - - const providerGroups = useMemo(() => { + const groups = useMemo(() => { if (!vars) { return [] } - const entries = Object.entries(vars).filter(([, info]) => asText(info.category) === 'provider') + return KEY_TABS.map(t => t.id).flatMap(tab => { + const cats = TAB_CATEGORIES[tab] - const groups = new Map() - - for (const entry of entries) { - const name = providerGroup(entry[0]) - groups.set(name, [...(groups.get(name) ?? []), entry]) - } - - return Array.from(groups, ([name, entries]) => ({ - name, - priority: providerPriority(name), - entries: entries.sort(([a], [b]) => a.localeCompare(b)), - hasAnySet: entries.some(([, info]) => info.is_set) - })).sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name)) - }, [vars]) - - const otherGroups = useMemo(() => { - if (!vars) { - return [] - } - - const labels: Record = { - tool: 'Tools', - messaging: 'Messaging', - setting: 'Settings' - } - - return ['tool', 'messaging', 'setting'].flatMap(cat => { const entries = Object.entries(vars) - .filter(([, info]) => asText(info.category) === cat) + .filter(([, info]) => !info.channel_managed && cats.includes(asText(info.category))) .sort(([a], [b]) => a.localeCompare(b)) - return entries.length === 0 ? [] : [{ category: cat, label: labels[cat] ?? prettyName(cat), entries }] + return entries.length === 0 ? [] : [{ category: tab, label: CATEGORY_LABELS[tab], entries }] }) }, [vars]) - function patchVar(key: string, patch: EnvPatch) { - setVars(c => (c ? { ...c, [key]: { ...c[key], ...patch } } : c)) - } + // Tab badge counts reflect how many keys are set per tab. Channel-managed + // credentials are owned by the Messaging page and excluded here. + const categoryCounts = useMemo>(() => { + const counts: Record = { setting: 0, tool: 0 } - function clearLocalState(key: string) { - setEdits(c => withoutKey(c, key)) - setRevealed(c => withoutKey(c, key)) - } - - async function handleSave(key: string) { - const value = edits[key] - - if (!value) { - return + if (!vars) { + return counts } - setSaving(key) + for (const info of Object.values(vars)) { + if (!info.is_set || info.channel_managed) { + continue + } - try { - await setEnvVar(key, value) - patchVar(key, { is_set: true, redacted_value: redactedValue(value) }) - clearLocalState(key) - notify({ kind: 'success', title: 'Credential saved', message: `${key} updated.` }) - } catch (err) { - notifyError(err, `Failed to save ${key}`) - } finally { - setSaving(null) - } - } + const tab = tabForCategory(asText(info.category)) - async function handleClear(key: string) { - if (!window.confirm(`Remove ${key} from .env?`)) { - return + if (tab) { + counts[tab] += 1 + } } - setSaving(key) - - try { - await deleteEnvVar(key) - patchVar(key, { is_set: false, redacted_value: null }) - clearLocalState(key) - notify({ kind: 'success', title: 'Credential removed', message: `${key} removed.` }) - } catch (err) { - notifyError(err, `Failed to remove ${key}`) - } finally { - setSaving(null) - } - } - - async function handleReveal(key: string) { - if (revealed[key]) { - setRevealed(c => withoutKey(c, key)) - - return - } - - try { - const result = await revealEnvVar(key) - setRevealed(c => ({ ...c, [key]: result.value })) - } catch (err) { - notifyError(err, `Failed to reveal ${key}`) - } - } + return counts + }, [vars]) if (!vars) { return } - const rowProps = { - edits, - revealed, - saving, - setEdits, - onSave: handleSave, - onClear: handleClear, - onReveal: handleReveal - } - - const configuredCount = providerGroups.filter(g => g.hasAnySet).length + const visible = groups.filter(g => g.category === activeCategory) return ( -
- -
- {providerGroups.map(group => ( - key === highlightKey)} - group={group} - key={group.name} - rowProps={rowProps} - /> - ))} -
-
+ - {otherGroups.map(group => ( -
- i.is_set).length} of ${group.entries.length} set`} - title={group.label} - /> + {visible.map(group => ( +
- {group.entries.map(([key, info]) => ( -
- -
+ {group.entries.map(([key, info]: [string, EnvVarInfo]) => ( + ))}
-
+ ))} + + {visible.length === 0 && ( +
+ Nothing configured in this category yet. +
+ )}
) } diff --git a/apps/desktop/src/app/settings/providers-settings.tsx b/apps/desktop/src/app/settings/providers-settings.tsx new file mode 100644 index 00000000000..74c91de2ff6 --- /dev/null +++ b/apps/desktop/src/app/settings/providers-settings.tsx @@ -0,0 +1,489 @@ +import { useStore } from '@nanostores/react' +import { type ChangeEvent, type KeyboardEvent, useEffect, useMemo, useState } from 'react' + +import { + FEATURED_ID, + FeaturedProviderRow, + KeyProviderRow, + ProviderRow, + sortProviders +} from '@/components/desktop-onboarding-overlay' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { listOAuthProviders } from '@/hermes' +import { ChevronDown, ExternalLink, KeyRound, Loader2, Save } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { $desktopOnboarding, startManualProviderOAuth } from '@/store/onboarding' +import type { EnvVarInfo, OAuthProvider } from '@/types/hermes' + +import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials' +import { providerGroup, providerMeta, providerPriority, withoutKey } from './helpers' +import { LoadingState, SettingsContent } from './primitives' +import type { EnvRowProps } from './types' + +// Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys. +export const PROVIDER_VIEWS = ['accounts', 'keys'] as const + +export type ProviderView = (typeof PROVIDER_VIEWS)[number] + +const isKeyVar = (key: string, info: EnvVarInfo) => info.is_password || /(?:_API_KEY|_TOKEN|_KEY)$/.test(key) + +const friendlyFieldLabel = (key: string, info: EnvVarInfo) => + info.description?.trim() || key.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase()) + +// Advanced (non-primary) fields are mostly base-URL / endpoint overrides, not +// keys — so don't reuse the "Paste key" placeholder that makes them read as a +// duplicate key input. URL-ish vars get a URL hint; everything else stays optional. +const advancedPlaceholder = (key: string, info: EnvVarInfo): string => + isKeyVar(key, info) ? 'Paste key' : /URL$/i.test(key) ? 'https://…' : 'Optional' + +// Group the env catalog by provider so the keys view can render one collapsible +// row per vendor: a primary key field inline, with any secondary / advanced vars +// (base URL overrides, alt tokens) revealed when the row is focused/expanded. +// Mirrors what Cursor's API-keys section does. Groups without a key field (e.g. +// Nous Portal's lone base-URL override) and the "Other" bucket are skipped. +function buildProviderKeyGroups(vars: Record): ProviderKeyGroup[] { + const buckets = new Map() + + for (const [key, info] of Object.entries(vars)) { + if (info.category !== 'provider') { + continue + } + + const name = providerGroup(key) + + if (name === 'Other') { + continue + } + + buckets.set(name, [...(buckets.get(name) ?? []), [key, info]]) + } + + const groups: ProviderKeyGroup[] = [] + + for (const [name, entries] of buckets) { + const primary = entries.find(([k, i]) => !i.advanced && isKeyVar(k, i)) ?? entries.find(([k, i]) => isKeyVar(k, i)) + + if (!primary) { + continue + } + + const meta = providerMeta(name) + + groups.push({ + // Advanced = the provider's non-key knobs (base URL, region, deployment). + // Skip redundant alias key vars (e.g. ANTHROPIC_TOKEN vs ANTHROPIC_API_KEY) + // so we never render a second "Paste key" input — unless one is already + // set, in which case keep it visible so it stays clearable. + advanced: entries + .filter(([k, i]) => k !== primary[0] && (!isKeyVar(k, i) || i.is_set)) + .sort(([a], [b]) => a.localeCompare(b)), + description: meta?.description ?? primary[1].description, + docsUrl: meta?.docsUrl ?? primary[1].url ?? undefined, + hasAnySet: entries.some(([, i]) => i.is_set), + name, + primary, + priority: providerPriority(name) + }) + } + + return groups.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name)) +} + +// A single credential field: a set key shows as a filled read-only input +// (redacted value) that edits in place on click. Save appears once typed; a set +// key also offers Remove, and Esc cancels without closing the overlay. +function KeyField({ + compact = false, + info, + label, + placeholder, + rowProps, + varKey +}: { + compact?: boolean + info: EnvVarInfo + label?: string + placeholder?: string + rowProps: KeyRowProps + varKey: string +}) { + const { edits, onClear, onSave, saving, setEdits } = rowProps + const editing = edits[varKey] !== undefined + const draft = edits[varKey] ?? '' + const dirty = draft.trim().length > 0 + const busy = saving === varKey + const masked = info.redacted_value ?? '••••••••' + const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' })) + const cancel = () => setEdits(c => withoutKey(c, varKey)) + const update = (e: ChangeEvent) => setEdits(c => ({ ...c, [varKey]: e.target.value })) + + // Enter saves; Esc cancels in place without bubbling to the overlay's window + // Escape listener (which would otherwise close the whole settings panel). + const keydown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && dirty) { + void onSave(varKey) + } else if (e.key === 'Escape' && editing) { + e.preventDefault() + e.stopPropagation() + cancel() + } + } + + // Advanced overrides render quieter (xs) than the primary key field so the key + // stays the visual anchor. Padding-driven sizing — no fixed heights. + const inputSize = compact ? 'xs' : 'sm' + const editType = info.is_password ? 'password' : 'text' + + // A set value reads as a single filled, read-only field (showing the redacted + // value). Clicking it drops into edit mode in place — no Replace/Cancel chrome. + const control = + info.is_set && !editing ? ( + + ) : ( +
+
+ + {dirty && ( + + )} +
+ {editing && ( +
+ {info.is_set && ( + <> + + or + + )} + esc to cancel +
+ )} +
+ ) + + // Standard stacked form field: small muted label above, input below. Same shape + // for the primary key and every advanced override — just smaller when compact. + // Empty advanced inputs (not labels) fade back, brightening on hover/focus/set. + const dim = compact && !info.is_set + + return ( +
+ {label && ( + + )} + {dim ? ( +
{control}
+ ) : ( + control + )} +
+ ) +} + +function ProviderKeyCard({ + expanded, + group, + onExpand, + onToggle, + rowProps +}: { + expanded: boolean + group: ProviderKeyGroup + onExpand: () => void + onToggle: () => void + rowProps: KeyRowProps +}) { + // Expandable when there's anything to reveal — advanced overrides and/or a + // "Get a key" docs link (which lives at the bottom of the expanded panel). + const expandable = group.advanced.length > 0 || Boolean(group.docsUrl) + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onToggle() + } + } + : undefined + } + role={expandable ? 'button' : undefined} + tabIndex={expandable ? 0 : undefined} + > +
+
+ + {group.name} + {expandable && ( + + )} +
+
e.stopPropagation()} + onFocus={() => { + if (expandable && !expanded) { + onExpand() + } + }} + > + +
+
+ {expandable && expanded && ( +
e.stopPropagation()}> + {group.advanced.map(([key, info]) => ( + + ))} + {group.docsUrl && ( + e.stopPropagation()} + rel="noreferrer" + target="_blank" + > + Get a key + + + )} +
+ )} +
+ ) +} + +// Deliberately a near-1:1 replica of the first-run onboarding picker +// (`Picker` in desktop-onboarding-overlay): same recommended card, same +// provider rows, same "Other providers" disclosure, same OpenRouter quick-key +// row, and the same bottom-right "I have an API key" affordance. The leaf cards +// are the exact shared components, so the two surfaces stay visually identical. +// Selecting a provider hands off to the shared onboarding overlay, which runs +// that provider's real sign-in flow; the key affordances open the API-key +// catalog below. +function OAuthPicker({ onWantApiKey, providers }: { onWantApiKey: () => void; providers: OAuthProvider[] }) { + const [showAll, setShowAll] = useState(false) + const ordered = useMemo(() => sortProviders(providers), [providers]) + + if (ordered.length === 0) { + return null + } + + const select = (p: OAuthProvider) => startManualProviderOAuth(p.id) + + const featured = ordered.find(p => p.id === FEATURED_ID) ?? null + const rest = featured ? ordered.filter(p => p.id !== FEATURED_ID) : ordered + // Keep connected accounts grouped and always visible; only the unconnected + // providers hide behind the disclosure, so the page leads with what's set up. + const connected = rest.filter(p => p.status?.logged_in) + const others = rest.filter(p => !p.status?.logged_in) + const collapsible = others.length > 0 + const showOthers = !collapsible || showAll + + return ( +
+
+ + +
+

+ Sign in with a subscription — no API key to copy. Hermes runs the browser sign-in for you, right here in the + app. +

+ {featured && } + {connected.length > 0 && ( + <> +

+ Connected +

+ {connected.map(p => ( + + ))} + + )} + {showOthers && ( + <> + {others.map(p => ( + + ))} + + + )} + {collapsible && ( + + )} +
+ ) +} + +function NoProviderKeys() { + return ( +
+ No provider API keys available. +
+ ) +} + +export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) { + const { rowProps, vars } = useEnvCredentials() + const [oauthProviders, setOauthProviders] = useState([]) + // Single-open accordion for the per-provider "advanced options" panels. + const [openProvider, setOpenProvider] = useState(null) + // The onboarding overlay owns the OAuth flow. Watch its `manual` flag so we + // re-read connection state when the user finishes (or dismisses) a sign-in + // they launched from this page — otherwise the cards keep their stale status. + const onboardingActive = useStore($desktopOnboarding).manual + + useEffect(() => { + if (onboardingActive) { + return + } + + let cancelled = false + + // OAuth providers are best-effort — a failure here just hides the panel. + void (async () => { + try { + const { providers } = await listOAuthProviders() + + if (!cancelled) { + setOauthProviders(providers) + } + } catch { + // Ignore — the OAuth panel just won't render. + } + })() + + return () => void (cancelled = true) + }, [onboardingActive]) + + if (!vars) { + return + } + + const hasOauth = oauthProviders.length > 0 + // The sidebar subnav owns the Accounts/API-keys split now; with no OAuth + // providers there's nothing for the "Accounts" view to show, so fall to keys. + const showApiKeys = view === 'keys' || !hasOauth + + const keyGroups = buildProviderKeyGroups(vars) + + if (showApiKeys) { + return ( + + {keyGroups.length > 0 ? ( +
+ {keyGroups.map(group => ( + setOpenProvider(group.name)} + onToggle={() => setOpenProvider(prev => (prev === group.name ? null : group.name))} + rowProps={rowProps} + /> + ))} +
+ ) : ( + + )} +
+ ) + } + + return ( + + onViewChange('keys')} providers={oauthProviders} /> + + ) +} + +type KeyRowProps = Omit + +interface ProviderKeyGroup { + advanced: [string, EnvVarInfo][] + description?: string + docsUrl?: string + hasAnySet: boolean + name: string + primary: [string, EnvVarInfo] + priority: number +} + +interface ProvidersSettingsProps { + onViewChange: (view: ProviderView) => void + view: ProviderView +} diff --git a/apps/desktop/src/app/settings/types.ts b/apps/desktop/src/app/settings/types.ts index 665ce65cb87..33c88e761c1 100644 --- a/apps/desktop/src/app/settings/types.ts +++ b/apps/desktop/src/app/settings/types.ts @@ -4,7 +4,7 @@ import type { HermesGateway } from '@/hermes' import type { IconComponent } from '@/lib/icons' import type { EnvVarInfo } from '@/types/hermes' -export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'sessions' | `config:${string}` +export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'providers' | 'sessions' | `config:${string}` export type EnvPatch = Partial> export interface SettingsPageProps { diff --git a/apps/desktop/src/components/desktop-onboarding-overlay.tsx b/apps/desktop/src/components/desktop-onboarding-overlay.tsx index 6b430ee7b64..2f335d8b251 100644 --- a/apps/desktop/src/components/desktop-onboarding-overlay.tsx +++ b/apps/desktop/src/components/desktop-onboarding-overlay.tsx @@ -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 = { 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 ( -
+
{reason}
) @@ -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 + return ( + 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 ( ) } @@ -371,15 +412,15 @@ function ConnectedTag() { ) } -function KeyProviderRow({ onClick }: { onClick: () => void }) { +export function KeyProviderRow({ onClick }: { onClick: () => void }) { return ( ))}
-
+

{option.description}

{option.docsUrl ? Get a key : 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 ?

{error}

: null}
-
+
+
+ {alreadySet && onClear ? ( + + ) : null} +
diff --git a/apps/desktop/src/components/ui/control.ts b/apps/desktop/src/components/ui/control.ts index c4d2721090a..473d43700b7 100644 --- a/apps/desktop/src/components/ui/control.ts +++ b/apps/desktop/src/components/ui/control.ts @@ -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' diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts index aa41435094a..4ede830baa6 100644 --- a/apps/desktop/src/store/onboarding.ts +++ b/apps/desktop/src/store/onboarding.ts @@ -346,20 +346,49 @@ export function requestDesktopOnboarding(reason = DEFAULT_ONBOARDING_REASON) { // onboarding flow (OAuth rows, API-key form, model-confirm) instead of // duplicating provider UI. Sets manual=true so the overlay shows the picker // even though configured===true, and refreshes the provider list. -export function startManualOnboarding(reason = 'Add or switch inference provider.') { +export function startManualOnboarding(reason: null | string = 'Add or switch inference provider.') { patch({ manual: true, requested: true, - reason: reason.trim() || DEFAULT_ONBOARDING_REASON, + // `null` opts out of the prompt banner entirely (e.g. when the user already + // picked a specific provider and we auto-start its sign-in). + reason: reason ? reason.trim() || DEFAULT_ONBOARDING_REASON : null, flow: { status: 'idle' } }) void refreshProviders() } +// One-shot hand-off used when the dedicated Providers settings page launches a +// specific provider's sign-in: we open the manual onboarding overlay AND +// remember which provider to start, so the overlay drives that exact OAuth +// flow instead of re-showing the picker the user just clicked through. +// Module-level (not store state) because it's consumed immediately on the next +// overlay render and never needs to persist or re-render anything itself. +let pendingProviderOAuthId: null | string = null + +export function startManualProviderOAuth(providerId: string, reason: null | string = null) { + pendingProviderOAuthId = providerId + startManualOnboarding(reason) +} + +// Read the pending provider id without clearing it. The overlay only clears it +// (via clearPendingProviderOAuth) once it has actually launched that provider, +// so a transient empty/failed provider fetch doesn't drop the hand-off and the +// deep-link can still auto-start after the list loads. +export function peekPendingProviderOAuth(): null | string { + return pendingProviderOAuthId +} + +export function clearPendingProviderOAuth() { + pendingProviderOAuthId = null +} + // Dismiss a manually-opened provider selector without touching the existing // (working) configuration. Only valid in the manual path — the unconfigured // first-run flow has no close affordance because the app can't run yet. export function closeManualOnboarding() { + pendingProviderOAuthId = null + patch({ manual: false, requested: false, flow: { status: 'idle' } }) } diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index ab483818a63..2ca8efd4437 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -562,6 +562,19 @@ animation: arc-border var(--arc-duration) linear infinite; } +/* Flip the arc's travel direction (e.g. the Nous Portal hero row). */ +.arc-border.arc-reverse::before { + animation-direction: reverse; +} + +/* Nous Portal hero: slower, blue → orange arc. */ +.arc-border.arc-nous, +:root.dark .arc-border.arc-nous { + --arc-c1: #4f8cff; + --arc-c2: #ff8c42; + --arc-duration: 3.27s; +} + @media (prefers-reduced-motion: reduce) { .arc-border::before { animation: none; @@ -669,11 +682,10 @@ canvas { var(--dt-composer-ring) calc(var(--ring-pct) * var(--composer-ring-strength)), var(--ring-fall) ); - box-shadow: var(--shadow-composer); + box-shadow: none; transition: background-color 200ms ease-out, - border-color 200ms ease-out, - box-shadow 200ms ease-out; + border-color 200ms ease-out; } .desktop-input-chrome:hover { @@ -685,7 +697,7 @@ canvas { --ring-pct: 45%; --ring-fall: transparent; background: var(--dt-card); - box-shadow: var(--shadow-composer-focus); + box-shadow: none; outline: none; } @@ -693,13 +705,6 @@ canvas { border-color: var(--dt-destructive); } -.desktop-input-chrome[aria-invalid='true']:focus { - box-shadow: - 0 0 0 0.125rem color-mix(in srgb, var(--dt-destructive) 18%, transparent), - 0 0 0 0.0625rem color-mix(in srgb, var(--dt-destructive) 34%, transparent), - 0 0.1875rem 0.625rem color-mix(in srgb, var(--dt-destructive) 12%, transparent); -} - @layer components { .scrollbar-dt, .scrollbar-dt * { diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 4d92d72223c..d5819ea24ad 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -96,6 +96,10 @@ export interface OAuthPollResponse { export interface EnvVarInfo { advanced: boolean category: string + // True when this var is a messaging-platform credential owned by a card on + // the dedicated Messaging page. The Keys page hides these to avoid + // duplicating the richer channel-configuration UI. + channel_managed?: boolean description: string is_password: boolean is_set: boolean