From ee0de638d719515d679cbda561bf03ee9f298251 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Fri, 19 Jun 2026 08:35:50 -0400 Subject: [PATCH] feat(desktop): add API-keys search; keep provider lists priority-sorted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API-keys tab: a SearchField filters provider cards by name / env-var key / description, with a 'no providers match' empty state. Card order stays priority-then-name (curated PROVIDER_GROUPS priority floats recommended providers up; equal priority falls back to alphabetical). - Accounts tab: 'Other providers' keep sortProviders order (priority, then name) — unchanged. Adds searchKeys/noKeysMatch i18n strings across all four locales. Vitest covers priority/name ordering + live filtering + empty state. --- .../app/settings/providers-settings.test.tsx | 48 ++++++++++++++++ .../src/app/settings/providers-settings.tsx | 55 +++++++++++++++---- apps/desktop/src/i18n/en.ts | 2 + apps/desktop/src/i18n/ja.ts | 2 + apps/desktop/src/i18n/types.ts | 2 + apps/desktop/src/i18n/zh-hant.ts | 2 + apps/desktop/src/i18n/zh.ts | 2 + 7 files changed, 102 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/app/settings/providers-settings.test.tsx b/apps/desktop/src/app/settings/providers-settings.test.tsx index d20f71b5ab4..1f1851932fc 100644 --- a/apps/desktop/src/app/settings/providers-settings.test.tsx +++ b/apps/desktop/src/app/settings/providers-settings.test.tsx @@ -36,6 +36,22 @@ function provider(id: string, loggedIn: boolean, patch: Partial = } } +// A backend-tagged provider env var (category=provider) for the API-keys view. +function keyVar(label: string, slug: string) { + return { + advanced: false, + category: 'provider', + description: `${label} direct API`, + is_password: true, + is_set: false, + provider: slug, + provider_label: label, + redacted_value: null, + tools: [], + url: '' + } +} + beforeEach(() => { onboarding.set({ manual: false }) getEnvVars.mockResolvedValue({}) @@ -124,4 +140,36 @@ describe('ProvidersSettings', () => { expect(await screen.findByText('WidgetAI')).toBeTruthy() }) + + it('orders API-key providers by priority then name, and filters them via search', async () => { + // These three providers have no curated PROVIDER_GROUPS priority, so they + // share the default priority and fall back to alphabetical among themselves + // (Acme, Middle, Zebra) — exercising the name tiebreak of the priority sort. + getEnvVars.mockResolvedValue({ + ZEBRA_API_KEY: keyVar('Zebra', 'zebra'), + ACME_API_KEY: keyVar('Acme', 'acme'), + MIDDLE_API_KEY: keyVar('Middle', 'middle') + }) + listOAuthProviders.mockResolvedValue({ providers: [] }) + + const { ProvidersSettings } = await import('./providers-settings') + render() + + // Equal priority → alphabetical tiebreak: Acme, Middle, Zebra. + await screen.findByText('Acme') + const labels = screen.getAllByText(/Acme|Middle|Zebra/).map(el => el.textContent) + expect(labels).toEqual(['Acme', 'Middle', 'Zebra']) + + // Typing narrows the list to matching providers only. + const search = screen.getByPlaceholderText('Search providers…') + fireEvent.change(search, { target: { value: 'mid' } }) + + await waitFor(() => expect(screen.queryByText('Acme')).toBeNull()) + expect(screen.getByText('Middle')).toBeTruthy() + expect(screen.queryByText('Zebra')).toBeNull() + + // A non-matching query shows the empty-state copy. + fireEvent.change(search, { target: { value: 'nonesuch-xyz' } }) + expect(await screen.findByText('No providers match your search.')).toBeTruthy() + }) }) diff --git a/apps/desktop/src/app/settings/providers-settings.tsx b/apps/desktop/src/app/settings/providers-settings.tsx index e0ae46e7da1..31ced164fff 100644 --- a/apps/desktop/src/app/settings/providers-settings.tsx +++ b/apps/desktop/src/app/settings/providers-settings.tsx @@ -12,6 +12,7 @@ import { sortProviders } from '@/components/desktop-onboarding-overlay' import { Button } from '@/components/ui/button' +import { SearchField } from '@/components/ui/search-field' import { disconnectOAuthProvider, listOAuthProviders } from '@/hermes' import { useI18n } from '@/i18n' import { Check, ChevronDown, ChevronRight, KeyRound, Loader2, Terminal, Trash2 } from '@/lib/icons' @@ -145,6 +146,7 @@ function OAuthPicker({ 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. + // Both lists preserve `sortProviders` order (curated priority, then name). const connected = rest.filter(p => p.status?.logged_in) const others = rest.filter(p => !p.status?.logged_in) const collapsible = others.length > 0 @@ -298,6 +300,8 @@ export function ProvidersSettings({ onClose, onViewChange, view }: ProvidersSett const [oauthProviders, setOauthProviders] = useState([]) const [openProvider, setOpenProvider] = useState(null) const [disconnecting, setDisconnecting] = useState(null) + // Free-text filter for the API-keys view (provider name / env-var key / desc). + const [keyQuery, setKeyQuery] = useState('') // 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. @@ -386,20 +390,49 @@ export function ProvidersSettings({ onClose, onViewChange, view }: ProvidersSett const keyGroups = buildProviderKeyGroups(vars) if (showApiKeys) { + const q = keyQuery.trim().toLowerCase() + const visibleGroups = q + ? keyGroups.filter(group => { + const haystack = [ + group.name, + group.description ?? '', + group.primary[0], + ...group.advanced.map(([k]) => k) + ] + + return haystack.some(s => s.toLowerCase().includes(q)) + }) + : keyGroups + return ( {keyGroups.length > 0 ? ( -
- {keyGroups.map(group => ( - setOpenProvider(group.name)} - onToggle={() => setOpenProvider(prev => (prev === group.name ? null : group.name))} - rowProps={rowProps} - /> - ))} +
+ + {visibleGroups.length > 0 ? ( +
+ {visibleGroups.map(group => ( + setOpenProvider(group.name)} + onToggle={() => setOpenProvider(prev => (prev === group.name ? null : group.name))} + rowProps={rowProps} + /> + ))} +
+ ) : ( +
+ {t.settings.providers.noKeysMatch} +
+ )}
) : ( diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index d27741c44db..158de543c49 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -581,6 +581,8 @@ export const en: Translations = { removedMessage: provider => `${provider} was removed.`, failedRemove: provider => `Could not remove ${provider}`, noProviderKeys: 'No provider API keys available.', + searchKeys: 'Search providers…', + noKeysMatch: 'No providers match your search.', loading: 'Loading providers...' }, sessions: { diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 194452ed407..244fc12ca49 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -700,6 +700,8 @@ export const ja = defineLocale({ removedMessage: provider => `${provider} を削除しました。`, failedRemove: provider => `${provider} を削除できませんでした`, noProviderKeys: '利用可能なプロバイダー API キーがありません。', + searchKeys: 'プロバイダーを検索…', + noKeysMatch: '一致するプロバイダーがありません。', loading: 'プロバイダーを読み込み中...' }, sessions: { diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 94489e5de9e..90168d28e86 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -462,6 +462,8 @@ export interface Translations { removedMessage: (provider: string) => string failedRemove: (provider: string) => string noProviderKeys: string + searchKeys: string + noKeysMatch: string loading: string } sessions: { diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index de329631098..c1eb3b8f883 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -677,6 +677,8 @@ export const zhHant = defineLocale({ removedMessage: provider => `${provider} 已移除。`, failedRemove: provider => `無法移除 ${provider}`, noProviderKeys: '沒有可用的提供方 API 金鑰。', + searchKeys: '搜尋提供方…', + noKeysMatch: '沒有符合的提供方。', loading: '正在載入提供方...' }, sessions: { diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index ac8c5c0b958..161a438b9e7 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -774,6 +774,8 @@ export const zh: Translations = { removedMessage: provider => `${provider} 已移除。`, failedRemove: provider => `无法移除 ${provider}`, noProviderKeys: '没有可用的提供方 API 密钥。', + searchKeys: '搜索提供方…', + noKeysMatch: '没有匹配的提供方。', loading: '正在加载提供方...' }, sessions: {