diff --git a/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts b/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts index 525f4acb..61edae80 100644 --- a/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts +++ b/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts @@ -356,7 +356,7 @@ export function useRuntimeProviderManagement( }, [options.enabled, refresh]); useEffect(() => { - if (!options.enabled || !directoryOpen || !directorySupported) { + if (!options.enabled || !directorySupported) { return; } @@ -376,7 +376,6 @@ export function useRuntimeProviderManagement( }, [ directoryFilter, directoryLoaded, - directoryOpen, directoryQuery, directorySupported, loadDirectoryPage, @@ -572,6 +571,18 @@ export function useRuntimeProviderManagement( setSuccessMessage(null); }, []); + const updateProviderQuery = useCallback( + (value: string): void => { + setProviderQuery(value); + if (!directorySupported) { + return; + } + setDirectoryQuery(value); + setDirectoryNextCursor(null); + }, + [directorySupported] + ); + const cancelConnect = useCallback((): void => { setActiveFormProviderId(null); setApiKeyValue(''); @@ -612,12 +623,7 @@ export function useRuntimeProviderManagement( setApiKeyValue(''); void Promise.resolve(options.onProviderChanged?.()) .then(() => refresh()) - .then(() => { - if (directoryOpen) { - return loadDirectoryPage({ refresh: true, cursor: null }); - } - return undefined; - }) + .then(() => loadDirectoryPage({ refresh: true, cursor: null })) .catch((refreshError) => { setError( refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers' @@ -631,7 +637,7 @@ export function useRuntimeProviderManagement( setSavingProviderId(null); } }, - [apiKeyValue, directoryOpen, loadDirectoryPage, options, refresh] + [apiKeyValue, loadDirectoryPage, options, refresh] ); const forgetProvider = useCallback( @@ -659,12 +665,7 @@ export function useRuntimeProviderManagement( setSavingProviderId(null); void Promise.resolve(options.onProviderChanged?.()) .then(() => refresh()) - .then(() => { - if (directoryOpen) { - return loadDirectoryPage({ refresh: true, cursor: null }); - } - return undefined; - }) + .then(() => loadDirectoryPage({ refresh: true, cursor: null })) .catch((refreshError) => { setError( refreshError instanceof Error ? refreshError.message : 'Failed to refresh providers' @@ -678,7 +679,7 @@ export function useRuntimeProviderManagement( setSavingProviderId(null); } }, - [directoryOpen, loadDirectoryPage, options, refresh] + [loadDirectoryPage, options, refresh] ); const openModelPicker = useCallback( @@ -882,7 +883,7 @@ export function useRuntimeProviderManagement( () => ({ refresh, selectProvider, - setProviderQuery, + setProviderQuery: updateProviderQuery, openDirectory, closeDirectory, setDirectoryQuery: updateDirectoryQuery, @@ -923,6 +924,7 @@ export function useRuntimeProviderManagement( submitConnect, testModel, updateDirectoryQuery, + updateProviderQuery, useModelForNewTeams, ] ); diff --git a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx index 9f7063c2..c8b8c57c 100644 --- a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx +++ b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx @@ -113,6 +113,44 @@ function getDirectoryModelsLabel(provider: RuntimeProviderDirectoryEntryDto): st return `${provider.modelCount} model${provider.modelCount === 1 ? '' : 's'}`; } +function directoryEntryMatchesQuery( + provider: RuntimeProviderDirectoryEntryDto, + query: string +): boolean { + if (!query) { + return true; + } + return [ + provider.providerId, + provider.displayName, + provider.detail ?? '', + provider.defaultModelId ?? '', + provider.sourceLabel ?? '', + provider.providerSource ?? '', + getDirectoryModelsLabel(provider), + formatDirectorySetupKind(provider), + ...provider.authMethods, + ] + .join(' ') + .toLowerCase() + .includes(query); +} + +function directorySetupKindClassName(provider: RuntimeProviderDirectoryEntryDto): string { + switch (provider.setupKind) { + case 'connected': + return 'border-emerald-400/35 bg-emerald-400/10 text-emerald-200'; + case 'connect-api-key': + case 'available-readonly': + return 'border-sky-400/30 bg-sky-400/10 text-sky-200'; + case 'configure-manually': + case 'requires-environment': + return 'border-white/10 bg-white/[0.04] text-[var(--color-text-muted)]'; + case 'unsupported': + return 'border-red-400/25 bg-red-400/10 text-red-200'; + } +} + function directoryEntryToProviderConnection( provider: RuntimeProviderDirectoryEntryDto ): RuntimeProviderConnectionDto { @@ -593,13 +631,19 @@ function ProviderRow({ function DirectoryProviderRow({ provider, + state, active, + formOpen, + apiKeyValue, disabled, busy, actions, }: { readonly provider: RuntimeProviderDirectoryEntryDto; + readonly state: RuntimeProviderManagementState; readonly active: boolean; + readonly formOpen: boolean; + readonly apiKeyValue: string; readonly disabled: boolean; readonly busy: boolean; readonly actions: RuntimeProviderManagementActions; @@ -636,11 +680,7 @@ function DirectoryProviderRow({ {provider.recommended ? Recommended : null} {formatDirectorySetupKind(provider)} @@ -718,6 +758,59 @@ function DirectoryProviderRow({ ) : null} + + {formOpen ? ( +
event.stopPropagation()} + > +
+ + actions.setApiKeyValue(event.target.value)} + placeholder="Paste API key" + className="h-9 text-sm" + autoFocus + /> +
+
+ + +
+
+ ) : null} + + {active && provider.state === 'connected' && provider.modelCount !== 0 ? ( + + ) : null} ); } @@ -813,21 +906,14 @@ function ProviderDirectoryPanel({
- {active && provider.state === 'connected' && provider.modelCount !== 0 ? ( -
- -
- ) : null}
); })} @@ -1142,8 +1228,17 @@ export function RuntimeProviderManagementPanelView({ .includes(providerQuery) ) : state.providers; - const canSearchDirectory = - state.directorySupported && providerQuery.length >= 2 && filteredProviders.length === 0; + const useDirectoryRows = + state.directorySupported && + (state.directoryLoaded || state.directoryLoading || state.directoryEntries.length > 0); + const visibleDirectoryRows = state.directoryEntries.filter((provider) => + directoryEntryMatchesQuery(provider, providerQuery) + ); + const providerCountLabel = state.directoryTotalCount + ? `${state.directoryTotalCount} OpenCode providers` + : state.directorySupported + ? 'OpenCode provider catalog' + : 'OpenCode providers'; return (
@@ -1182,32 +1277,32 @@ export function RuntimeProviderManagementPanelView({
) : null} - {state.directoryOpen ? ( - - ) : null} - - {!state.directoryOpen && state.directorySupported ? ( - - ) : null} + + {state.directorySupported ? ( + + ) : null} + - {!state.directoryOpen && state.providers.length > 0 ? ( + {state.providers.length > 0 || state.directorySupported ? (
) : null} - {!state.directoryOpen && canSearchDirectory ? ( - - ) : null} - - {!state.directoryOpen ? ( -
- {state.loading && state.providers.length === 0 ? ( - - ) : null} - {filteredProviders.map((provider) => ( - - ))} + {state.directoryError ? ( +
+ {state.directoryError}
) : null} - {!state.directoryOpen && +
+ {useDirectoryRows ? ( + <> + {state.directoryLoading && state.directoryEntries.length === 0 ? ( + + ) : null} + {visibleDirectoryRows.map((provider) => ( + + ))} + {state.directoryNextCursor ? ( +
+ +
+ ) : null} + + ) : ( + <> + {state.loading && state.providers.length === 0 ? ( + + ) : null} + {filteredProviders.map((provider) => ( + + ))} + + )} +
+ + {useDirectoryRows && + !state.directoryLoading && + visibleDirectoryRows.length === 0 && + !state.directoryError ? ( +
+ No providers match that search. +
+ ) : null} + + {!useDirectoryRows && !state.loading && state.providers.length > 0 && filteredProviders.length === 0 ? ( @@ -1277,7 +1416,7 @@ export function RuntimeProviderManagementPanelView({
) : null} - {!state.directoryOpen && !state.loading && state.providers.length === 0 ? ( + {!useDirectoryRows && !state.loading && state.providers.length === 0 ? (
- + Provider Settings diff --git a/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts b/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts index f0513035..4ae35787 100644 --- a/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts +++ b/test/renderer/features/runtime-provider-management/RuntimeProviderManagementPanelView.test.ts @@ -372,7 +372,7 @@ describe('RuntimeProviderManagementPanelView', () => { expect(host.textContent).toContain('DeepSeek'); expect(host.textContent).toContain('62 models'); expect(host.textContent).toContain('OpenCode catalog'); - expect(host.querySelector('[data-testid="runtime-provider-directory-search"]')).not.toBeNull(); + expect(host.querySelector('[data-testid="runtime-provider-search"]')).not.toBeNull(); await act(async () => { host @@ -384,7 +384,7 @@ describe('RuntimeProviderManagementPanelView', () => { expect(actions.selectDirectoryProvider).toHaveBeenCalledWith('deepseek'); }); - it('offers global provider search when compact search has no matches', async () => { + it('uses the unified provider search when compact search has no matches', async () => { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); @@ -398,6 +398,31 @@ describe('RuntimeProviderManagementPanelView', () => { ...state, providers: state.view?.providers ?? [], providerQuery: 'deep', + directoryLoaded: true, + directoryTotalCount: 1, + directoryEntries: [ + { + providerId: 'deepseek', + displayName: 'DeepSeek', + state: 'available', + setupKind: 'available-readonly', + ownership: [], + recommended: false, + modelCount: 62, + defaultModelId: null, + authMethods: [], + actions: [], + sources: ['opencode-provider'], + sourceLabel: 'OpenCode catalog', + providerSource: 'models.dev', + detail: 'Models are visible, but no connected credential was reported', + metadata: { + hasKnownModels: true, + requiresManualConfig: false, + supportedInlineAuth: false, + }, + }, + ], }, actions, disabled: false, @@ -406,17 +431,8 @@ describe('RuntimeProviderManagementPanelView', () => { await Promise.resolve(); }); - const searchAll = Array.from(host.querySelectorAll('button')).find((button) => - button.textContent?.includes('Search all OpenCode providers for "deep"') - ); - expect(searchAll).not.toBeNull(); - - await act(async () => { - searchAll?.dispatchEvent(new MouseEvent('click', { bubbles: true })); - await Promise.resolve(); - }); - - expect(actions.searchAllProviders).toHaveBeenCalledWith('deep'); + expect(host.textContent).toContain('DeepSeek'); + expect(host.textContent).not.toContain('Search all OpenCode providers'); }); it('renders connected provider model picker actions', async () => {