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 { resolveProjectPathById } from '@renderer/utils/projectLookup';
|
||||||
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
|
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
|
||||||
import { getRuntimeDisplayName as getHumanRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName';
|
import { getRuntimeDisplayName as getHumanRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName';
|
||||||
|
import { getVisibleTeamProviderModels } from '@renderer/utils/teamModelCatalog';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
|
@ -836,12 +837,17 @@ const InstalledBanner = ({
|
||||||
const modelCatalogLoading =
|
const modelCatalogLoading =
|
||||||
provider.modelCatalogRefreshState === 'loading' ||
|
provider.modelCatalogRefreshState === 'loading' ||
|
||||||
isOpenCodeCatalogHydrating(provider);
|
isOpenCodeCatalogHydrating(provider);
|
||||||
|
const hasProviderModels =
|
||||||
|
provider.providerId === 'opencode'
|
||||||
|
? getVisibleTeamProviderModels(provider.providerId, provider.models, provider)
|
||||||
|
.length > 0
|
||||||
|
: provider.models.length > 0;
|
||||||
const hasDetailContent = Boolean(
|
const hasDetailContent = Boolean(
|
||||||
(provider.backend?.label && !runtimeSummary) ||
|
(provider.backend?.label && !runtimeSummary) ||
|
||||||
runtimeSummary ||
|
runtimeSummary ||
|
||||||
connectionModeSummary ||
|
connectionModeSummary ||
|
||||||
credentialSummary ||
|
credentialSummary ||
|
||||||
provider.models.length === 0 ||
|
!hasProviderModels ||
|
||||||
modelCatalogLoading
|
modelCatalogLoading
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -905,7 +911,7 @@ const InstalledBanner = ({
|
||||||
{connectionModeSummary ? <span>{connectionModeSummary}</span> : null}
|
{connectionModeSummary ? <span>{connectionModeSummary}</span> : null}
|
||||||
{credentialSummary ? <span>{credentialSummary}</span> : null}
|
{credentialSummary ? <span>{credentialSummary}</span> : null}
|
||||||
{modelCatalogLoading ? <span>Loading models...</span> : null}
|
{modelCatalogLoading ? <span>Loading models...</span> : null}
|
||||||
{provider.models.length === 0 && !modelCatalogLoading && (
|
{!hasProviderModels && !modelCatalogLoading && (
|
||||||
<span>Models unavailable for this runtime build</span>
|
<span>Models unavailable for this runtime build</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1087,7 +1093,7 @@ const InstalledBanner = ({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!showSkeleton && !modelCatalogLoading && provider.models.length > 0 && (
|
{!showSkeleton && !modelCatalogLoading && hasProviderModels && (
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<ProviderModelBadges
|
<ProviderModelBadges
|
||||||
providerId={provider.providerId}
|
providerId={provider.providerId}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ import { filterMainScreenCliProviders } from '@renderer/utils/geminiUiFreeze';
|
||||||
import { resolveProjectPathById } from '@renderer/utils/projectLookup';
|
import { resolveProjectPathById } from '@renderer/utils/projectLookup';
|
||||||
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
|
import { refreshCliStatusForCurrentMode } from '@renderer/utils/refreshCliStatus';
|
||||||
import { getRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName';
|
import { getRuntimeDisplayName } from '@renderer/utils/runtimeDisplayName';
|
||||||
|
import { getVisibleTeamProviderModels } from '@renderer/utils/teamModelCatalog';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
|
@ -491,6 +492,14 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
const modelCatalogLoading =
|
const modelCatalogLoading =
|
||||||
provider.modelCatalogRefreshState === 'loading' ||
|
provider.modelCatalogRefreshState === 'loading' ||
|
||||||
isOpenCodeCatalogHydrating(provider);
|
isOpenCodeCatalogHydrating(provider);
|
||||||
|
const hasProviderModels =
|
||||||
|
provider.providerId === 'opencode'
|
||||||
|
? getVisibleTeamProviderModels(
|
||||||
|
provider.providerId,
|
||||||
|
provider.models,
|
||||||
|
provider
|
||||||
|
).length > 0
|
||||||
|
: provider.models.length > 0;
|
||||||
const connectionModeSummary = getProviderConnectionModeSummary(provider);
|
const connectionModeSummary = getProviderConnectionModeSummary(provider);
|
||||||
const credentialSummary = getProviderCredentialSummary(provider);
|
const credentialSummary = getProviderCredentialSummary(provider);
|
||||||
const disconnectAction = getProviderDisconnectAction(provider);
|
const disconnectAction = getProviderDisconnectAction(provider);
|
||||||
|
|
@ -499,7 +508,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
runtimeSummary ||
|
runtimeSummary ||
|
||||||
connectionModeSummary ||
|
connectionModeSummary ||
|
||||||
credentialSummary ||
|
credentialSummary ||
|
||||||
provider.models.length === 0 ||
|
!hasProviderModels ||
|
||||||
modelCatalogLoading
|
modelCatalogLoading
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -553,7 +562,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
) : null}
|
) : null}
|
||||||
{credentialSummary ? <span>{credentialSummary}</span> : null}
|
{credentialSummary ? <span>{credentialSummary}</span> : null}
|
||||||
{modelCatalogLoading ? <span>Loading models...</span> : null}
|
{modelCatalogLoading ? <span>Loading models...</span> : null}
|
||||||
{provider.models.length === 0 && !modelCatalogLoading && (
|
{!hasProviderModels && !modelCatalogLoading && (
|
||||||
<span>Models unavailable for this runtime build</span>
|
<span>Models unavailable for this runtime build</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -612,7 +621,7 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
</div>
|
</div>
|
||||||
{!effectiveShowSkeleton &&
|
{!effectiveShowSkeleton &&
|
||||||
!modelCatalogLoading &&
|
!modelCatalogLoading &&
|
||||||
provider.models.length > 0 && (
|
hasProviderModels && (
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<ProviderModelBadges
|
<ProviderModelBadges
|
||||||
providerId={provider.providerId}
|
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 () => {
|
it('preserves dashboard runtime backend refresh errors for the manage dialog', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
storeState.cliInstallerState = 'idle';
|
storeState.cliInstallerState = 'idle';
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,82 @@ describe('ProviderModelBadges', () => {
|
||||||
expect(host.textContent?.match(/Free/g)).toHaveLength(1);
|
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', () => {
|
it('renders OpenCode free badges from metadata when badgeLabel is absent', () => {
|
||||||
const host = render(
|
const host = render(
|
||||||
<ProviderModelBadges
|
<ProviderModelBadges
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue