fix(runtime-provider-management): unify opencode provider list

This commit is contained in:
777genius 2026-04-25 21:15:52 +03:00
parent 2518445b3b
commit c2e14ea9df
4 changed files with 263 additions and 106 deletions

View file

@ -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,
]
);

View file

@ -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({
</span>
{provider.recommended ? <Badge variant="secondary">Recommended</Badge> : null}
<span
className={`rounded-md border px-2 py-0.5 text-[11px] ${
provider.state === 'connected'
? 'border-emerald-400/35 bg-emerald-400/10 text-emerald-200'
: 'border-white/10 bg-white/[0.04] text-[var(--color-text-muted)]'
}`}
className={`rounded-md border px-2 py-0.5 text-[11px] ${directorySetupKindClassName(provider)}`}
>
{formatDirectorySetupKind(provider)}
</span>
@ -718,6 +758,59 @@ function DirectoryProviderRow({
) : null}
</div>
</div>
{formOpen ? (
<div
className="mt-3 rounded-md border p-3"
style={{ borderColor: 'var(--color-border-subtle)' }}
onClick={(event) => event.stopPropagation()}
>
<div className="space-y-1.5">
<Label htmlFor={`runtime-provider-key-${provider.providerId}`} className="text-xs">
{provider.displayName} API key
</Label>
<Input
id={`runtime-provider-key-${provider.providerId}`}
type="password"
value={apiKeyValue}
disabled={disabled || busy}
onChange={(event) => actions.setApiKeyValue(event.target.value)}
placeholder="Paste API key"
className="h-9 text-sm"
autoFocus
/>
</div>
<div className="mt-3 flex justify-end gap-2">
<Button
type="button"
size="sm"
variant="ghost"
disabled={busy}
onClick={actions.cancelConnect}
>
Cancel
</Button>
<Button
type="button"
size="sm"
disabled={disabled || busy || !apiKeyValue.trim()}
onClick={() => void actions.submitConnect(provider.providerId)}
>
{busy ? <Loader2 className="mr-1 size-3.5 animate-spin" /> : null}
Save key
</Button>
</div>
</div>
) : null}
{active && provider.state === 'connected' && provider.modelCount !== 0 ? (
<ProviderModelList
state={state}
actions={actions}
provider={directoryEntryToProviderConnection(provider)}
disabled={disabled || busy}
/>
) : null}
</div>
);
}
@ -813,21 +906,14 @@ function ProviderDirectoryPanel({
<div key={provider.providerId}>
<DirectoryProviderRow
provider={provider}
state={state}
active={active}
formOpen={state.activeFormProviderId === provider.providerId}
apiKeyValue={state.apiKeyValue}
disabled={disabled || state.directoryLoading}
busy={state.savingProviderId === provider.providerId}
actions={actions}
/>
{active && provider.state === 'connected' && provider.modelCount !== 0 ? (
<div className="ml-3 border-l border-sky-300/25 pl-3">
<ProviderModelList
state={state}
actions={actions}
provider={directoryEntryToProviderConnection(provider)}
disabled={disabled || state.directoryLoading}
/>
</div>
) : null}
</div>
);
})}
@ -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 (
<div className="space-y-3">
@ -1182,32 +1277,32 @@ export function RuntimeProviderManagementPanelView({
</div>
) : null}
{state.directoryOpen ? (
<ProviderDirectoryPanel state={state} actions={actions} disabled={disabled} />
) : null}
{!state.directoryOpen && state.directorySupported ? (
<button
type="button"
className="w-full rounded-lg border px-3 py-2.5 text-left transition-colors hover:border-sky-300/55 hover:bg-sky-400/[0.07]"
style={{
borderColor: 'var(--color-border-subtle)',
color: 'var(--color-text-secondary)',
}}
onClick={actions.openDirectory}
>
<div className="text-sm font-medium text-[var(--color-text)]">
Browse all OpenCode providers
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<div className="text-sm font-medium text-[var(--color-text)]">Providers</div>
<div className="text-xs text-[var(--color-text-muted)]">
{providerCountLabel}. Connected and recommended providers are shown first.
</div>
<div className="mt-0.5 text-xs">
{state.directoryTotalCount === null
? 'Load the dynamic provider catalog from OpenCode'
: `${state.directoryTotalCount} providers available from OpenCode`}
</div>
</button>
) : null}
</div>
{state.directorySupported ? (
<Button
type="button"
size="sm"
variant="ghost"
disabled={disabled || state.directoryLoading || state.directoryRefreshing}
onClick={() => void actions.refreshDirectory()}
>
{state.directoryRefreshing ? (
<Loader2 className="mr-1 size-3.5 animate-spin" />
) : (
<RefreshCcw className="mr-1 size-3.5" />
)}
Refresh catalog
</Button>
) : null}
</div>
{!state.directoryOpen && state.providers.length > 0 ? (
{state.providers.length > 0 || state.directorySupported ? (
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-text-muted)]" />
<Input
@ -1227,42 +1322,86 @@ export function RuntimeProviderManagementPanelView({
</div>
) : null}
{!state.directoryOpen && canSearchDirectory ? (
<button
type="button"
className="w-full rounded-md border px-3 py-2 text-left text-sm transition-colors hover:border-sky-300/55 hover:bg-sky-400/[0.07]"
style={{
borderColor: 'var(--color-border-subtle)',
color: 'var(--color-text)',
}}
onClick={() => actions.searchAllProviders(state.providerQuery.trim())}
>
Search all OpenCode providers for "{state.providerQuery.trim()}"
</button>
) : null}
{!state.directoryOpen ? (
<div className="max-h-[62vh] space-y-2 overflow-y-auto pr-1">
{state.loading && state.providers.length === 0 ? (
<RuntimeProviderLoadingPlaceholder />
) : null}
{filteredProviders.map((provider) => (
<ProviderRow
key={provider.providerId}
provider={provider}
state={state}
active={provider.providerId === state.selectedProviderId}
formOpen={state.activeFormProviderId === provider.providerId}
apiKeyValue={state.apiKeyValue}
busy={state.savingProviderId === provider.providerId}
disabled={disabled || state.loading}
actions={actions}
/>
))}
{state.directoryError ? (
<div className="rounded-md border border-red-400/25 bg-red-400/10 px-3 py-2 text-xs text-red-200">
{state.directoryError}
</div>
) : null}
{!state.directoryOpen &&
<div className="max-h-[min(52vh,640px)] space-y-2 overflow-y-auto pr-1">
{useDirectoryRows ? (
<>
{state.directoryLoading && state.directoryEntries.length === 0 ? (
<RuntimeProviderLoadingPlaceholder />
) : null}
{visibleDirectoryRows.map((provider) => (
<DirectoryProviderRow
key={provider.providerId}
provider={provider}
state={state}
active={provider.providerId === state.selectedProviderId}
formOpen={state.activeFormProviderId === provider.providerId}
apiKeyValue={state.apiKeyValue}
busy={state.savingProviderId === provider.providerId}
disabled={disabled || state.directoryLoading}
actions={actions}
/>
))}
{state.directoryNextCursor ? (
<div className="flex justify-center py-1">
<Button
type="button"
size="sm"
variant="outline"
disabled={disabled || state.directoryRefreshing}
onClick={() => void actions.loadMoreDirectory()}
>
{state.directoryRefreshing ? (
<Loader2 className="mr-1 size-3.5 animate-spin" />
) : null}
Load more providers
</Button>
</div>
) : null}
</>
) : (
<>
{state.loading && state.providers.length === 0 ? (
<RuntimeProviderLoadingPlaceholder />
) : null}
{filteredProviders.map((provider) => (
<ProviderRow
key={provider.providerId}
provider={provider}
state={state}
active={provider.providerId === state.selectedProviderId}
formOpen={state.activeFormProviderId === provider.providerId}
apiKeyValue={state.apiKeyValue}
busy={state.savingProviderId === provider.providerId}
disabled={disabled || state.loading}
actions={actions}
/>
))}
</>
)}
</div>
{useDirectoryRows &&
!state.directoryLoading &&
visibleDirectoryRows.length === 0 &&
!state.directoryError ? (
<div
className="rounded-lg border p-3 text-sm"
style={{
borderColor: 'var(--color-border-subtle)',
color: 'var(--color-text-secondary)',
}}
>
No providers match that search.
</div>
) : null}
{!useDirectoryRows &&
!state.loading &&
state.providers.length > 0 &&
filteredProviders.length === 0 ? (
@ -1277,7 +1416,7 @@ export function RuntimeProviderManagementPanelView({
</div>
) : null}
{!state.directoryOpen && !state.loading && state.providers.length === 0 ? (
{!useDirectoryRows && !state.loading && state.providers.length === 0 ? (
<div
className="rounded-lg border p-3 text-sm"
style={{

View file

@ -1070,7 +1070,7 @@ export const ProviderRuntimeSettingsDialog = ({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogContent className="w-[min(96vw,980px)] max-w-[min(96vw,980px)]">
<DialogHeader>
<DialogTitle>Provider Settings</DialogTitle>
<DialogDescription>

View file

@ -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 () => {