feat(desktop): add API-keys search; keep provider lists priority-sorted

- 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.
This commit is contained in:
Austin Pickett 2026-06-19 08:35:50 -04:00 committed by Teknium
parent 8fe7b52ebf
commit ee0de638d7
7 changed files with 102 additions and 11 deletions

View file

@ -36,6 +36,22 @@ function provider(id: string, loggedIn: boolean, patch: Partial<OAuthProvider> =
}
}
// 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(<ProvidersSettings onClose={vi.fn()} onViewChange={vi.fn()} view="keys" />)
// 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()
})
})

View file

@ -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<OAuthProvider[]>([])
const [openProvider, setOpenProvider] = useState<null | string>(null)
const [disconnecting, setDisconnecting] = useState<null | string>(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 (
<SettingsContent>
{keyGroups.length > 0 ? (
<div className="grid gap-2">
{keyGroups.map(group => (
<ProviderKeyRows
expanded={openProvider === group.name}
group={group}
key={group.name}
onExpand={() => setOpenProvider(group.name)}
onToggle={() => setOpenProvider(prev => (prev === group.name ? null : group.name))}
rowProps={rowProps}
/>
))}
<div className="grid gap-3">
<SearchField
aria-label={t.settings.providers.searchKeys}
containerClassName="w-full"
onChange={setKeyQuery}
placeholder={t.settings.providers.searchKeys}
value={keyQuery}
/>
{visibleGroups.length > 0 ? (
<div className="grid gap-2">
{visibleGroups.map(group => (
<ProviderKeyRows
expanded={openProvider === group.name}
group={group}
key={group.name}
onExpand={() => setOpenProvider(group.name)}
onToggle={() => setOpenProvider(prev => (prev === group.name ? null : group.name))}
rowProps={rowProps}
/>
))}
</div>
) : (
<div className="grid min-h-24 place-items-center px-4 py-6 text-center text-[length:var(--conversation-caption-font-size)] text-muted-foreground">
{t.settings.providers.noKeysMatch}
</div>
)}
</div>
) : (
<NoProviderKeys />

View file

@ -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: {

View file

@ -700,6 +700,8 @@ export const ja = defineLocale({
removedMessage: provider => `${provider} を削除しました。`,
failedRemove: provider => `${provider} を削除できませんでした`,
noProviderKeys: '利用可能なプロバイダー API キーがありません。',
searchKeys: 'プロバイダーを検索…',
noKeysMatch: '一致するプロバイダーがありません。',
loading: 'プロバイダーを読み込み中...'
},
sessions: {

View file

@ -462,6 +462,8 @@ export interface Translations {
removedMessage: (provider: string) => string
failedRemove: (provider: string) => string
noProviderKeys: string
searchKeys: string
noKeysMatch: string
loading: string
}
sessions: {

View file

@ -677,6 +677,8 @@ export const zhHant = defineLocale({
removedMessage: provider => `${provider} 已移除。`,
failedRemove: provider => `無法移除 ${provider}`,
noProviderKeys: '沒有可用的提供方 API 金鑰。',
searchKeys: '搜尋提供方…',
noKeysMatch: '沒有符合的提供方。',
loading: '正在載入提供方...'
},
sessions: {

View file

@ -774,6 +774,8 @@ export const zh: Translations = {
removedMessage: provider => `${provider} 已移除。`,
failedRemove: provider => `无法移除 ${provider}`,
noProviderKeys: '没有可用的提供方 API 密钥。',
searchKeys: '搜索提供方…',
noKeysMatch: '没有匹配的提供方。',
loading: '正在加载提供方...'
},
sessions: {