fix(opencode): render catalog-backed provider badges
This commit is contained in:
parent
c88a11c1d8
commit
c632208dba
4 changed files with 283 additions and 6 deletions
|
|
@ -53,6 +53,7 @@ import { isMultimodelRuntimeStatus } from '@renderer/utils/multimodelProviderVis
|
|||
import { resolveProjectPathById } from '@renderer/utils/projectLookup';
|
||||
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
|
||||
import { getRuntimeDisplayName as getHumanRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName';
|
||||
import { getVisibleTeamProviderModels } from '@renderer/utils/teamModelCatalog';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
|
|
@ -836,12 +837,17 @@ const InstalledBanner = ({
|
|||
const modelCatalogLoading =
|
||||
provider.modelCatalogRefreshState === 'loading' ||
|
||||
isOpenCodeCatalogHydrating(provider);
|
||||
const hasProviderModels =
|
||||
provider.providerId === 'opencode'
|
||||
? getVisibleTeamProviderModels(provider.providerId, provider.models, provider)
|
||||
.length > 0
|
||||
: provider.models.length > 0;
|
||||
const hasDetailContent = Boolean(
|
||||
(provider.backend?.label && !runtimeSummary) ||
|
||||
runtimeSummary ||
|
||||
connectionModeSummary ||
|
||||
credentialSummary ||
|
||||
provider.models.length === 0 ||
|
||||
!hasProviderModels ||
|
||||
modelCatalogLoading
|
||||
);
|
||||
|
||||
|
|
@ -905,7 +911,7 @@ const InstalledBanner = ({
|
|||
{connectionModeSummary ? <span>{connectionModeSummary}</span> : null}
|
||||
{credentialSummary ? <span>{credentialSummary}</span> : null}
|
||||
{modelCatalogLoading ? <span>Loading models...</span> : null}
|
||||
{provider.models.length === 0 && !modelCatalogLoading && (
|
||||
{!hasProviderModels && !modelCatalogLoading && (
|
||||
<span>Models unavailable for this runtime build</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1087,7 +1093,7 @@ const InstalledBanner = ({
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{!showSkeleton && !modelCatalogLoading && provider.models.length > 0 && (
|
||||
{!showSkeleton && !modelCatalogLoading && hasProviderModels && (
|
||||
<div className="col-span-2">
|
||||
<ProviderModelBadges
|
||||
providerId={provider.providerId}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze';
|
|||
import { resolveProjectPathById } from '@renderer/utils/projectLookup';
|
||||
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
|
||||
import { getRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName';
|
||||
import { getVisibleTeamProviderModels } from '@renderer/utils/teamModelCatalog';
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
|
|
@ -491,6 +492,14 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
const modelCatalogLoading =
|
||||
provider.modelCatalogRefreshState === 'loading' ||
|
||||
isOpenCodeCatalogHydrating(provider);
|
||||
const hasProviderModels =
|
||||
provider.providerId === 'opencode'
|
||||
? getVisibleTeamProviderModels(
|
||||
provider.providerId,
|
||||
provider.models,
|
||||
provider
|
||||
).length > 0
|
||||
: provider.models.length > 0;
|
||||
const connectionModeSummary = getProviderConnectionModeSummary(provider);
|
||||
const credentialSummary = getProviderCredentialSummary(provider);
|
||||
const disconnectAction = getProviderDisconnectAction(provider);
|
||||
|
|
@ -499,7 +508,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
runtimeSummary ||
|
||||
connectionModeSummary ||
|
||||
credentialSummary ||
|
||||
provider.models.length === 0 ||
|
||||
!hasProviderModels ||
|
||||
modelCatalogLoading
|
||||
);
|
||||
|
||||
|
|
@ -553,7 +562,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
) : null}
|
||||
{credentialSummary ? <span>{credentialSummary}</span> : null}
|
||||
{modelCatalogLoading ? <span>Loading models...</span> : null}
|
||||
{provider.models.length === 0 && !modelCatalogLoading && (
|
||||
{!hasProviderModels && !modelCatalogLoading && (
|
||||
<span>Models unavailable for this runtime build</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -612,7 +621,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
</div>
|
||||
{!effectiveShowSkeleton &&
|
||||
!modelCatalogLoading &&
|
||||
provider.models.length > 0 && (
|
||||
hasProviderModels && (
|
||||
<div className="col-span-2">
|
||||
<ProviderModelBadges
|
||||
providerId={provider.providerId}
|
||||
|
|
|
|||
|
|
@ -807,6 +807,192 @@ describe('CLI status visibility during completed install state', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('shows OpenCode catalog models on the dashboard when provider models are empty', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliInstallerState = 'idle';
|
||||
storeState.cliStatus = createInstalledCliStatus({
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
displayName: 'Multimodel runtime',
|
||||
supportsSelfUpdate: false,
|
||||
showVersionDetails: false,
|
||||
showBinaryPath: false,
|
||||
authLoggedIn: true,
|
||||
providers: [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode (200+ models)',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'Ready',
|
||||
models: [],
|
||||
canLoginFromUi: false,
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: false,
|
||||
},
|
||||
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
||||
modelCatalog: {
|
||||
schemaVersion: 1,
|
||||
providerId: 'opencode',
|
||||
source: 'app-server',
|
||||
status: 'ready',
|
||||
fetchedAt: '2026-05-12T00:00:00.000Z',
|
||||
staleAt: '2026-05-12T00:10:00.000Z',
|
||||
defaultModelId: 'opencode/big-pickle',
|
||||
defaultLaunchModel: 'opencode/big-pickle',
|
||||
models: [
|
||||
{
|
||||
id: 'opencode/big-pickle',
|
||||
launchModel: 'opencode/big-pickle',
|
||||
displayName: 'opencode/big-pickle',
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: [],
|
||||
defaultReasoningEffort: null,
|
||||
inputModalities: ['text'],
|
||||
supportsPersonality: true,
|
||||
isDefault: true,
|
||||
upgrade: false,
|
||||
source: 'app-server',
|
||||
badgeLabel: 'Free',
|
||||
},
|
||||
{
|
||||
id: 'openai/gpt-5.4',
|
||||
launchModel: 'openai/gpt-5.4',
|
||||
displayName: 'openai/gpt-5.4',
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: [],
|
||||
defaultReasoningEffort: null,
|
||||
inputModalities: ['text'],
|
||||
supportsPersonality: true,
|
||||
isDefault: false,
|
||||
upgrade: false,
|
||||
source: 'app-server',
|
||||
badgeLabel: null,
|
||||
},
|
||||
],
|
||||
diagnostics: {
|
||||
configReadState: 'ready',
|
||||
appServerState: 'healthy',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(CliStatusBanner));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('big-pickle');
|
||||
expect(host.textContent).toContain('GPT-5.4');
|
||||
expect(host.textContent).not.toContain('Models unavailable for this runtime build');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows OpenCode catalog models in settings when provider models are empty', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliInstallerState = 'idle';
|
||||
storeState.cliStatus = createInstalledCliStatus({
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
displayName: 'Multimodel runtime',
|
||||
supportsSelfUpdate: false,
|
||||
showVersionDetails: false,
|
||||
showBinaryPath: false,
|
||||
authLoggedIn: true,
|
||||
providers: [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
displayName: 'OpenCode (200+ models)',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
verificationState: 'verified',
|
||||
statusMessage: 'Ready',
|
||||
models: [],
|
||||
canLoginFromUi: false,
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: false,
|
||||
},
|
||||
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
||||
modelCatalog: {
|
||||
schemaVersion: 1,
|
||||
providerId: 'opencode',
|
||||
source: 'app-server',
|
||||
status: 'ready',
|
||||
fetchedAt: '2026-05-12T00:00:00.000Z',
|
||||
staleAt: '2026-05-12T00:10:00.000Z',
|
||||
defaultModelId: 'opencode/big-pickle',
|
||||
defaultLaunchModel: 'opencode/big-pickle',
|
||||
models: [
|
||||
{
|
||||
id: 'opencode/big-pickle',
|
||||
launchModel: 'opencode/big-pickle',
|
||||
displayName: 'opencode/big-pickle',
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: [],
|
||||
defaultReasoningEffort: null,
|
||||
inputModalities: ['text'],
|
||||
supportsPersonality: true,
|
||||
isDefault: true,
|
||||
upgrade: false,
|
||||
source: 'app-server',
|
||||
badgeLabel: 'Free',
|
||||
},
|
||||
{
|
||||
id: 'openai/gpt-5.4',
|
||||
launchModel: 'openai/gpt-5.4',
|
||||
displayName: 'openai/gpt-5.4',
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: [],
|
||||
defaultReasoningEffort: null,
|
||||
inputModalities: ['text'],
|
||||
supportsPersonality: true,
|
||||
isDefault: false,
|
||||
upgrade: false,
|
||||
source: 'app-server',
|
||||
badgeLabel: null,
|
||||
},
|
||||
],
|
||||
diagnostics: {
|
||||
configReadState: 'ready',
|
||||
appServerState: 'healthy',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(CliStatusSection));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('big-pickle');
|
||||
expect(host.textContent).toContain('GPT-5.4');
|
||||
expect(host.textContent).not.toContain('Models unavailable for this runtime build');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves dashboard runtime backend refresh errors for the manage dialog', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliInstallerState = 'idle';
|
||||
|
|
|
|||
|
|
@ -171,6 +171,82 @@ describe('ProviderModelBadges', () => {
|
|||
expect(host.textContent?.match(/Free/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('uses the OpenCode catalog when provider models are summary-only', () => {
|
||||
const host = render(
|
||||
<ProviderModelBadges
|
||||
providerId="opencode"
|
||||
models={['opencode/big-pickle']}
|
||||
providerStatus={{
|
||||
providerId: 'opencode',
|
||||
authMethod: 'opencode_managed',
|
||||
backend: { kind: 'opencode-cli', label: 'OpenCode CLI' },
|
||||
modelCatalog: {
|
||||
schemaVersion: 1,
|
||||
providerId: 'opencode',
|
||||
source: 'app-server',
|
||||
status: 'ready',
|
||||
fetchedAt: '2026-05-12T00:00:00.000Z',
|
||||
staleAt: '2026-05-12T00:10:00.000Z',
|
||||
defaultModelId: 'opencode/big-pickle',
|
||||
defaultLaunchModel: 'opencode/big-pickle',
|
||||
models: [
|
||||
{
|
||||
id: 'opencode/big-pickle',
|
||||
launchModel: 'opencode/big-pickle',
|
||||
displayName: 'opencode/big-pickle',
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: [],
|
||||
defaultReasoningEffort: null,
|
||||
inputModalities: ['text'],
|
||||
supportsPersonality: true,
|
||||
isDefault: true,
|
||||
upgrade: false,
|
||||
source: 'app-server',
|
||||
badgeLabel: 'Free',
|
||||
},
|
||||
{
|
||||
id: 'openai/gpt-5.4',
|
||||
launchModel: 'openai/gpt-5.4',
|
||||
displayName: 'openai/gpt-5.4',
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: [],
|
||||
defaultReasoningEffort: null,
|
||||
inputModalities: ['text'],
|
||||
supportsPersonality: true,
|
||||
isDefault: false,
|
||||
upgrade: false,
|
||||
source: 'app-server',
|
||||
badgeLabel: null,
|
||||
},
|
||||
{
|
||||
id: 'openrouter/hidden-model',
|
||||
launchModel: 'openrouter/hidden-model',
|
||||
displayName: 'openrouter/hidden-model',
|
||||
hidden: true,
|
||||
supportedReasoningEfforts: [],
|
||||
defaultReasoningEffort: null,
|
||||
inputModalities: ['text'],
|
||||
supportsPersonality: true,
|
||||
isDefault: false,
|
||||
upgrade: false,
|
||||
source: 'app-server',
|
||||
badgeLabel: null,
|
||||
},
|
||||
],
|
||||
diagnostics: {
|
||||
configReadState: 'ready',
|
||||
appServerState: 'healthy',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(host.textContent).toContain('big-pickle');
|
||||
expect(host.textContent).toContain('GPT-5.4');
|
||||
expect(host.textContent).not.toContain('hidden-model');
|
||||
});
|
||||
|
||||
it('renders OpenCode free badges from metadata when badgeLabel is absent', () => {
|
||||
const host = render(
|
||||
<ProviderModelBadges
|
||||
|
|
|
|||
Loading…
Reference in a new issue