diff --git a/packages/common/src/dto/api/index.ts b/packages/common/src/dto/api/index.ts index 33ca0821b1..22ec7a7e1c 100644 --- a/packages/common/src/dto/api/index.ts +++ b/packages/common/src/dto/api/index.ts @@ -19,6 +19,8 @@ import { CoreV1EventList, V1PodList } from '@kubernetes/client-node'; import * as webSocket from './webSocket'; import { ReadStream } from 'fs'; +import { Architecture } from '../cluster-config'; + export { webSocket }; export type GitOauthProvider = @@ -120,6 +122,8 @@ export interface AiToolDefinition { envVarName?: string; /** One-time setup command run in the editor container at postStart */ setupCommand?: string; + /** Architectures this tool supports. Omit to support all architectures. */ + arch?: Architecture[]; } export interface AiProviderDefinition { diff --git a/packages/common/src/dto/cluster-config.ts b/packages/common/src/dto/cluster-config.ts index 49dfcfd6fb..11bfa6b79e 100644 --- a/packages/common/src/dto/cluster-config.ts +++ b/packages/common/src/dto/cluster-config.ts @@ -10,7 +10,14 @@ * Red Hat, Inc. - initial API and implementation */ -export type Architecture = 'x86_64' | 'arm64' | 's390x' | 'ppc64le'; +// Linux kernel names (uname -m) and OCI/Docker aliases are both accepted. +export type Architecture = + | 'x86_64' + | 'amd64' + | 'arm64' + | 'aarch64' + | 's390x' + | 'ppc64le'; export interface ClusterConfig { dashboardWarning?: string; diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/aiRegistryApi.spec.ts b/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/aiRegistryApi.spec.ts index 2679c7f519..6522572251 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/aiRegistryApi.spec.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/aiRegistryApi.spec.ts @@ -81,7 +81,7 @@ describe('AI Registry API Service', () => { it('should return parsed registry from ConfigMap data', async () => { const registryData = { providers: [{ id: 'provider1', name: 'Provider 1' }], - tools: [{ id: 'tool1', name: 'Tool 1' }], + tools: [{ id: 'tool1', name: 'Tool 1', providerId: 'provider1' }], defaultAiProviders: ['provider1'], }; @@ -133,7 +133,7 @@ describe('AI Registry API Service', () => { it('should skip ConfigMaps with undefined data', async () => { const registryData = { providers: [{ id: 'provider2', name: 'Provider 2' }], - tools: [{ id: 'tool2', name: 'Tool 2' }], + tools: [{ id: 'tool2', name: 'Tool 2', providerId: 'provider2' }], defaultAiProviders: ['provider2'], }; @@ -207,5 +207,220 @@ describe('AI Registry API Service', () => { expect(result).toEqual(EMPTY_REGISTRY); }); + + it('should filter out tools without a string providerId', async () => { + const registryData = { + providers: [], + tools: [{ foo: 42 }, { providerId: 123 }, { providerId: 'valid/provider', name: 'Valid' }], + defaultAiProviders: [], + }; + + mockCoreV1Api.listNamespacedConfigMap.mockResolvedValueOnce({ + items: [ + { + metadata: { name: 'ai-tool-registry' }, + data: { 'registry.json': JSON.stringify(registryData) }, + } as V1ConfigMap, + ], + } as V1ConfigMapList); + + const result = await service.get(); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0]).toMatchObject({ providerId: 'valid/provider' }); + }); + }); + + describe('arch filtering', () => { + const toolNoArch = { + providerId: 'google/gemini', + tag: 'latest', + name: 'Gemini CLI', + url: 'https://example.com', + binary: 'gemini', + pattern: 'bundle', + injectorImage: 'quay.io/test/gemini:latest', + }; + const toolX86Only = { + providerId: 'test/x86-only', + tag: 'latest', + name: 'X86 Only Tool', + url: 'https://example.com', + binary: 'x86tool', + pattern: 'init', + injectorImage: 'quay.io/test/x86tool:latest', + arch: ['x86_64'], + }; + const toolX86AndArm = { + providerId: 'test/multi-arch', + tag: 'latest', + name: 'Multi Arch Tool', + url: 'https://example.com', + binary: 'multitool', + pattern: 'init', + injectorImage: 'quay.io/test/multitool:latest', + arch: ['x86_64', 'arm64'], + }; + + const registryData = { + providers: [{ id: 'provider1', name: 'Provider 1', publisher: 'Test' }], + tools: [toolNoArch, toolX86Only, toolX86AndArm], + defaultAiProviders: ['provider1'], + }; + + beforeEach(() => { + mockCoreV1Api.listNamespacedConfigMap.mockResolvedValue({ + items: [ + { + metadata: { name: 'ai-tool-registry' }, + data: { + 'registry.json': JSON.stringify(registryData), + }, + } as V1ConfigMap, + ], + } as V1ConfigMapList); + }); + + it('should return all tools when currentArch is not provided', async () => { + const result = await service.get(); + + expect(result.tools).toHaveLength(3); + expect(result.tools).toEqual(registryData.tools); + }); + + it('should filter tools by architecture when currentArch is provided', async () => { + const result = await service.get('arm64'); + + expect(result.tools).toHaveLength(2); + expect(result.tools).toContainEqual(toolNoArch); + expect(result.tools).toContainEqual(toolX86AndArm); + expect(result.tools).not.toContainEqual(toolX86Only); + }); + + it('should include tools with no arch field on any architecture', async () => { + const result = await service.get('s390x'); + + expect(result.tools).toHaveLength(1); + expect(result.tools).toContainEqual(toolNoArch); + }); + + it('should return tools matching x86_64 architecture', async () => { + const result = await service.get('x86_64'); + + expect(result.tools).toHaveLength(3); + expect(result.tools).toContainEqual(toolNoArch); + expect(result.tools).toContainEqual(toolX86Only); + expect(result.tools).toContainEqual(toolX86AndArm); + }); + + it('should skip null elements in tools and return remaining valid tools', async () => { + mockCoreV1Api.listNamespacedConfigMap.mockResolvedValue({ + items: [ + { + metadata: { name: 'ai-tool-registry' }, + data: { + 'registry.json': JSON.stringify({ + providers: registryData.providers, + tools: [null, toolNoArch, null, toolX86Only], + defaultAiProviders: registryData.defaultAiProviders, + }), + }, + } as V1ConfigMap, + ], + } as V1ConfigMapList); + + const result = await service.get('x86_64'); + + expect(result.tools).toHaveLength(2); + expect(result.tools).toContainEqual(toolNoArch); + expect(result.tools).toContainEqual(toolX86Only); + }); + + it('should match tools using OCI alias "amd64" when cluster reports x86_64', async () => { + mockCoreV1Api.listNamespacedConfigMap.mockResolvedValue({ + items: [ + { + metadata: { name: 'ai-tool-registry' }, + data: { + 'registry.json': JSON.stringify({ + providers: registryData.providers, + tools: [{ ...toolX86Only, arch: ['amd64', 'arm64'] }], + defaultAiProviders: registryData.defaultAiProviders, + }), + }, + } as V1ConfigMap, + ], + } as V1ConfigMapList); + + const result = await service.get('x86_64'); + + expect(result.tools).toHaveLength(1); + }); + + it('should match tools using OCI alias "aarch64" when cluster reports arm64', async () => { + mockCoreV1Api.listNamespacedConfigMap.mockResolvedValue({ + items: [ + { + metadata: { name: 'ai-tool-registry' }, + data: { + 'registry.json': JSON.stringify({ + providers: registryData.providers, + tools: [{ ...toolX86Only, arch: ['aarch64'] }], + defaultAiProviders: registryData.defaultAiProviders, + }), + }, + } as V1ConfigMap, + ], + } as V1ConfigMapList); + + const result = await service.get('arm64'); + + expect(result.tools).toHaveLength(1); + }); + + it('should match x86_64 tools when currentArch is passed as "amd64"', async () => { + const result = await service.get('amd64'); + + expect(result.tools).toHaveLength(3); + expect(result.tools).toContainEqual(toolNoArch); + expect(result.tools).toContainEqual(toolX86Only); + expect(result.tools).toContainEqual(toolX86AndArm); + }); + + it('should match arm64 tools when currentArch is passed as "aarch64"', async () => { + const result = await service.get('aarch64'); + + expect(result.tools).toHaveLength(2); + expect(result.tools).toContainEqual(toolNoArch); + expect(result.tools).toContainEqual(toolX86AndArm); + expect(result.tools).not.toContainEqual(toolX86Only); + }); + + it('should treat a non-array arch value as no restriction when filtering', async () => { + const toolMalformedArch = { + ...toolX86Only, + providerId: 'test/malformed', + arch: 'x86_64' as unknown as string[], + }; + mockCoreV1Api.listNamespacedConfigMap.mockResolvedValue({ + items: [ + { + metadata: { name: 'ai-tool-registry' }, + data: { + 'registry.json': JSON.stringify({ + providers: registryData.providers, + tools: [toolMalformedArch], + defaultAiProviders: registryData.defaultAiProviders, + }), + }, + } as V1ConfigMap, + ], + } as V1ConfigMapList); + + const result = await service.get('arm64'); + + expect(result.tools).toHaveLength(1); + expect(result.tools[0]).toMatchObject({ providerId: 'test/malformed' }); + }); }); }); diff --git a/packages/dashboard-backend/src/devworkspaceClient/services/aiRegistryApi.ts b/packages/dashboard-backend/src/devworkspaceClient/services/aiRegistryApi.ts index 92b6794d91..e0390ecf64 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/services/aiRegistryApi.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/services/aiRegistryApi.ts @@ -12,7 +12,7 @@ // Generated by AI Assistant -import { api } from '@eclipse-che/common'; +import { api, Architecture } from '@eclipse-che/common'; import * as k8s from '@kubernetes/client-node'; import { V1ConfigMapList } from '@kubernetes/client-node'; @@ -46,7 +46,7 @@ export class AiRegistryApiService implements IAiRegistryApi { }; } - async get(): Promise { + async get(currentArch?: Architecture): Promise { if (!this.env.NAMESPACE) { logger.warn('Mandatory environment variables are not defined: $CHECLUSTER_CR_NAMESPACE'); return EMPTY_REGISTRY; @@ -75,9 +75,31 @@ export class AiRegistryApiService implements IAiRegistryApi { continue; } const registry = parsed as Record; + const rawTools = Array.isArray(registry.tools) ? (registry.tools as unknown[]) : []; + const allTools = rawTools.filter( + (t): t is api.AiToolDefinition => + typeof t === 'object' && + t !== null && + typeof (t as Record).providerId === 'string', + ); + // Normalize OCI/Docker aliases to Linux kernel names before comparing so + // both 'amd64'/'x86_64' and 'aarch64'/'arm64' match each other. + const toCanonical = (a: Architecture): Architecture => { + if (a === 'amd64') return 'x86_64'; + if (a === 'aarch64') return 'arm64'; + return a; + }; + const canonicalArch = currentArch ? toCanonical(currentArch) : undefined; + const filteredTools = canonicalArch + ? allTools.filter( + tool => + !Array.isArray(tool.arch) || + tool.arch.map(a => toCanonical(a as Architecture)).includes(canonicalArch), + ) + : allTools; return { providers: Array.isArray(registry.providers) ? registry.providers : [], - tools: Array.isArray(registry.tools) ? registry.tools : [], + tools: filteredTools, defaultAiProviders: Array.isArray(registry.defaultAiProviders) ? registry.defaultAiProviders : [], diff --git a/packages/dashboard-backend/src/devworkspaceClient/types/index.ts b/packages/dashboard-backend/src/devworkspaceClient/types/index.ts index 6e8dc993df..7c7751edbd 100644 --- a/packages/dashboard-backend/src/devworkspaceClient/types/index.ts +++ b/packages/dashboard-backend/src/devworkspaceClient/types/index.ts @@ -592,8 +592,11 @@ export interface IAiRegistryApi { /** * Reads the AI tool registry from a ConfigMap in the cluster. * Returns providers, tools, and default provider selections. + * When currentArch is provided, tools whose arch list does not include + * the current architecture are filtered out. Tools with no arch field + * are always returned. */ - get(): Promise; + get(currentArch?: Architecture): Promise; } export interface IAiProviderKeyApi { diff --git a/packages/dashboard-backend/src/routes/api/__tests__/aiRegistry.spec.ts b/packages/dashboard-backend/src/routes/api/__tests__/aiRegistry.spec.ts index 87498b3f31..38b1cc89b0 100644 --- a/packages/dashboard-backend/src/routes/api/__tests__/aiRegistry.spec.ts +++ b/packages/dashboard-backend/src/routes/api/__tests__/aiRegistry.spec.ts @@ -16,7 +16,9 @@ import { FastifyInstance } from 'fastify'; import { baseApiPath } from '@/constants/config'; import { stubAiRegistry } from '@/routes/api/helpers/__mocks__/getDevWorkspaceClient'; +import { getDevWorkspaceClient } from '@/routes/api/helpers/getDevWorkspaceClient'; import { setup, teardown } from '@/utils/appBuilder'; +import { logger } from '@/utils/logger'; jest.mock('../helpers/getDevWorkspaceClient.ts'); jest.mock('../helpers/getServiceAccountToken.ts'); @@ -39,4 +41,20 @@ describe('AI Registry Route', () => { expect(res.statusCode).toEqual(200); expect(res.json()).toEqual(stubAiRegistry); }); + + test('logs a warning and returns all tools when arch detection fails', async () => { + jest.mocked(getDevWorkspaceClient).mockReturnValueOnce({ + aiRegistryApi: { get: jest.fn().mockResolvedValue(stubAiRegistry) }, + serverConfigApi: { + getCurrentArchitecture: jest.fn().mockRejectedValue(new Error('uname failed')), + }, + } as unknown as ReturnType); + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(jest.fn() as typeof logger.warn); + + const res = await app.inject().get(`${baseApiPath}/ai-registry`); + + expect(res.statusCode).toEqual(200); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); }); diff --git a/packages/dashboard-backend/src/routes/api/aiRegistry.ts b/packages/dashboard-backend/src/routes/api/aiRegistry.ts index d11d19eb6f..651245260d 100644 --- a/packages/dashboard-backend/src/routes/api/aiRegistry.ts +++ b/packages/dashboard-backend/src/routes/api/aiRegistry.ts @@ -19,6 +19,7 @@ import { baseApiPath } from '@/constants/config'; import { getDevWorkspaceClient } from '@/routes/api/helpers/getDevWorkspaceClient'; import { getServiceAccountToken } from '@/routes/api/helpers/getServiceAccountToken'; import { getSchema } from '@/services/helpers'; +import { logger } from '@/utils/logger'; const tags = ['AI Registry']; @@ -35,8 +36,15 @@ export function registerAiRegistryRoute(isLocalRun: boolean, instance: FastifyIn return EMPTY_REGISTRY; } const token = getServiceAccountToken(); - const { aiRegistryApi } = getDevWorkspaceClient(token); - return aiRegistryApi.get(); + const { aiRegistryApi, serverConfigApi } = getDevWorkspaceClient(token); + const currentArch = await serverConfigApi.getCurrentArchitecture().catch(error => { + logger.warn( + error, + 'Failed to detect current architecture; serving all tools without arch filtering', + ); + return undefined; + }); + return aiRegistryApi.get(currentArch); }); }); } diff --git a/packages/dashboard-backend/src/routes/api/helpers/__mocks__/getDevWorkspaceClient.ts b/packages/dashboard-backend/src/routes/api/helpers/__mocks__/getDevWorkspaceClient.ts index 0cf9a1bbb4..9a78fb3307 100644 --- a/packages/dashboard-backend/src/routes/api/helpers/__mocks__/getDevWorkspaceClient.ts +++ b/packages/dashboard-backend/src/routes/api/helpers/__mocks__/getDevWorkspaceClient.ts @@ -313,7 +313,7 @@ export const getDevWorkspaceClient = jest.fn( delete: (_namespace, _providerId) => Promise.resolve(), } as IAiProviderKeyApi, aiRegistryApi: { - get: () => Promise.resolve(stubAiRegistry), + get: (_currentArch?) => Promise.resolve(stubAiRegistry), } as IAiRegistryApi, sccPermissionApi: { checkSccPermission: (_namespace, _sccName) => Promise.resolve(true), diff --git a/packages/dashboard-frontend/src/components/AiSelector/Gallery/Entry/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/AiSelector/Gallery/Entry/__tests__/index.spec.tsx index 5ae2bfbe0c..ab5b9a7083 100644 --- a/packages/dashboard-frontend/src/components/AiSelector/Gallery/Entry/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/AiSelector/Gallery/Entry/__tests__/index.spec.tsx @@ -11,6 +11,7 @@ */ import { api } from '@eclipse-che/common'; +import { fireEvent } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; @@ -20,8 +21,9 @@ import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentR const { renderComponent } = getComponentRenderer(getComponent); const mockOnSelect = jest.fn(); +const mockOnVersionChange = jest.fn(); -const geminiProvider: api.AiToolDefinition = { +const geminiTool: api.AiToolDefinition = { providerId: 'google/gemini', tag: 'latest', name: 'Gemini', @@ -32,18 +34,24 @@ const geminiProvider: api.AiToolDefinition = { envVarName: 'GEMINI_API_KEY', }; +const geminiToolV2: api.AiToolDefinition = { + ...geminiTool, + tag: 'v2', + injectorImage: 'quay.io/example/gemini-cli:v2', +}; + afterEach(() => { jest.clearAllMocks(); }); describe('AiProviderEntry', () => { it('renders provider name', () => { - renderComponent(geminiProvider, false, false); + renderComponent([geminiTool], false, false); expect(screen.getByText('Gemini')).toBeInTheDocument(); }); it('calls onSelect when the card is clicked and not already selected', async () => { - renderComponent(geminiProvider, false, false); + renderComponent([geminiTool], false, false); const card = screen.getByText('Gemini').closest('[class*="pf-v6-c-card"]'); await userEvent.click(card!); expect(mockOnSelect).toHaveBeenCalledTimes(1); @@ -51,46 +59,224 @@ describe('AiProviderEntry', () => { }); it('calls onToggle when already selected (to deselect)', async () => { - renderComponent(geminiProvider, true, false); + renderComponent([geminiTool], true, false); const card = screen.getByText('Gemini').closest('[class*="pf-v6-c-card"]'); await userEvent.click(card!); expect(mockOnSelect).toHaveBeenCalledWith('google/gemini'); }); it('shows "Key configured" badge when key exists', () => { - renderComponent(geminiProvider, false, true); + renderComponent([geminiTool], false, true); expect(screen.getByText(/Key configured/i)).toBeInTheDocument(); }); it('does not show key badge when no key exists', () => { - renderComponent(geminiProvider, false, false); + renderComponent([geminiTool], false, false); expect(screen.queryByText(/Key configured/i)).toBeNull(); }); it('shows Tech-Preview badge when provider has Tech-Preview tag', () => { - renderComponent(geminiProvider, false, false, ['Tech-Preview']); + renderComponent([geminiTool], false, false, ['Tech-Preview']); expect(screen.getByText('Tech-Preview')).toBeInTheDocument(); }); it('does not show Tech-Preview badge when provider has no tags', () => { - renderComponent(geminiProvider, false, false); + renderComponent([geminiTool], false, false); expect(screen.queryByText('Tech-Preview')).toBeNull(); }); + + describe('version dropdown', () => { + it('does not show version dropdown when onVersionChange is not provided', () => { + renderComponent([geminiTool], false, false); + expect(screen.queryByRole('button', { name: /version options/i })).toBeNull(); + }); + + it('does not show version dropdown for multi-version tool when onVersionChange is not provided', () => { + renderComponent([geminiTool, geminiToolV2], false, false); + expect(screen.queryByRole('button', { name: /version options/i })).toBeNull(); + }); + + it('does not show version dropdown for single-version tool even when onVersionChange is provided', () => { + renderComponent( + [geminiTool], + false, + false, + undefined, + undefined, + undefined, + mockOnVersionChange, + ); + expect(screen.queryByRole('button', { name: /version options/i })).toBeNull(); + }); + + it('shows version dropdown when onVersionChange is provided and multiple versions exist', () => { + renderComponent( + [geminiTool, geminiToolV2], + false, + false, + undefined, + undefined, + undefined, + mockOnVersionChange, + ); + expect(screen.getByRole('button', { name: /version options/i })).toBeInTheDocument(); + }); + + it('opens dropdown and shows all version tags when button is clicked', async () => { + renderComponent( + [geminiTool, geminiToolV2], + false, + false, + undefined, + undefined, + undefined, + mockOnVersionChange, + ); + const btn = screen.getByRole('button', { name: /version options/i }); + await userEvent.click(btn); + const menuItems = screen.getAllByTestId('ai-provider-version-option'); + expect(menuItems).toHaveLength(2); + expect(screen.getByText('v2')).toBeInTheDocument(); + }); + + it('shows the version label for the active tool', () => { + renderComponent([geminiTool], false, false); + expect(screen.getByText('latest')).toBeInTheDocument(); + }); + + it('switches active tool when a version is selected from dropdown', async () => { + renderComponent( + [geminiTool, geminiToolV2], + false, + false, + undefined, + undefined, + undefined, + mockOnVersionChange, + ); + const btn = screen.getByRole('button', { name: /version options/i }); + await userEvent.click(btn); + const v2Item = screen.getByText('v2'); + expect(v2Item).toBeInTheDocument(); + await userEvent.click(v2Item); + expect(btn).toHaveAttribute('aria-expanded', 'false'); + }); + + it('calls onVersionChange when version changes on a selected card', async () => { + renderComponent( + [geminiTool, geminiToolV2], + true, + false, + undefined, + undefined, + undefined, + mockOnVersionChange, + ); + const btn = screen.getByRole('button', { name: /version options/i }); + await userEvent.click(btn); + const v2Item = screen.getByText('v2'); + await userEvent.click(v2Item); + expect(mockOnVersionChange).toHaveBeenCalledTimes(1); + expect(mockOnVersionChange).toHaveBeenCalledWith('google/gemini', 'v2'); + expect(mockOnSelect).not.toHaveBeenCalled(); + }); + }); + + describe('icon and description props', () => { + it('renders the provider icon when icon prop is provided', () => { + renderComponent([geminiTool], false, false, undefined, 'https://example.com/icon.png'); + const img = screen.getByRole('img', { name: /gemini/i }); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute('src', 'https://example.com/icon.png'); + }); + + it('does not render an img element when icon prop is absent', () => { + renderComponent([geminiTool], false, false); + expect(screen.queryByRole('img')).toBeNull(); + }); + + it('hides the icon image when it fails to load', () => { + renderComponent([geminiTool], false, false, undefined, 'https://example.com/icon.png'); + const img = screen.getByRole('img'); + fireEvent.error(img); + expect(img).toHaveStyle('display: none'); + }); + + it('renders description in card footer when description is provided', () => { + renderComponent([geminiTool], false, false, undefined, undefined, 'Gemini CLI description'); + expect(screen.getByText('Gemini CLI description')).toBeInTheDocument(); + }); + + it('does not render card footer when description is absent', () => { + renderComponent([geminiTool], false, false); + expect(screen.queryByText('Gemini CLI description')).toBeNull(); + }); + }); + + describe('componentDidUpdate', () => { + it('falls back to first tool when the previously active tag is no longer in toolGroup', () => { + const { reRenderComponent } = renderComponent([geminiTool, geminiToolV2], false, false); + // Initially active is geminiTool (tag: 'latest') + expect(screen.getByText('latest')).toBeInTheDocument(); + + // Remove the 'latest' version — only v2 remains + reRenderComponent([geminiToolV2], false, false); + expect(screen.getByText('v2')).toBeInTheDocument(); + }); + + it('keeps active tool when its tag still exists after toolGroup update', () => { + const { reRenderComponent } = renderComponent([geminiTool, geminiToolV2], false, false); + expect(screen.getByText('latest')).toBeInTheDocument(); + + // Both versions still present — active stays 'latest' + reRenderComponent([geminiToolV2, geminiTool], false, false); + expect(screen.getByText('latest')).toBeInTheDocument(); + }); + }); + + describe('keyboard navigation', () => { + it('triggers toggle when Enter key is pressed on the card', () => { + renderComponent([geminiTool], false, false); + const card = screen.getByText('Gemini').closest('[id^="ai-provider-card-"]') as HTMLElement; + fireEvent.keyDown(card, { key: 'Enter' }); + expect(mockOnSelect).toHaveBeenCalledWith('google/gemini'); + }); + + it('triggers toggle when Space key is pressed on the card', () => { + renderComponent([geminiTool], false, false); + const card = screen.getByText('Gemini').closest('[id^="ai-provider-card-"]') as HTMLElement; + fireEvent.keyDown(card, { key: ' ' }); + expect(mockOnSelect).toHaveBeenCalledWith('google/gemini'); + }); + + it('does not throw when Arrow key is pressed', () => { + renderComponent([geminiTool], false, false); + const card = screen.getByText('Gemini').closest('[id^="ai-provider-card-"]') as HTMLElement; + expect(() => fireEvent.keyDown(card, { key: 'ArrowRight' })).not.toThrow(); + expect(() => fireEvent.keyDown(card, { key: 'ArrowLeft' })).not.toThrow(); + }); + }); }); function getComponent( - provider: api.AiToolDefinition, + toolGroup: [api.AiToolDefinition, ...api.AiToolDefinition[]], isSelected: boolean, hasExistingKey: boolean, tags?: string[], + icon?: string, + description?: string, + onVersionChange?: (providerId: string, tag: string) => void, ): React.ReactElement { return ( ); } diff --git a/packages/dashboard-frontend/src/components/AiSelector/Gallery/Entry/index.module.css b/packages/dashboard-frontend/src/components/AiSelector/Gallery/Entry/index.module.css index abe3689d5a..4db7d4efce 100644 --- a/packages/dashboard-frontend/src/components/AiSelector/Gallery/Entry/index.module.css +++ b/packages/dashboard-frontend/src/components/AiSelector/Gallery/Entry/index.module.css @@ -10,6 +10,10 @@ * Red Hat, Inc. - initial API and implementation */ +.cardTitle { + padding-top: var(--pf-t--global--spacer--xs); +} + .activeCard { font-weight: 600; } @@ -20,7 +24,6 @@ width: 20px; height: 20px; - margin-right: 6px; padding: 1px; vertical-align: middle; @@ -35,3 +38,10 @@ .description { font-size: 75%; } + +.badgeGroup { + display: inline-flex; + flex-wrap: nowrap; + gap: 4px; + align-items: center; +} diff --git a/packages/dashboard-frontend/src/components/AiSelector/Gallery/Entry/index.tsx b/packages/dashboard-frontend/src/components/AiSelector/Gallery/Entry/index.tsx index a5d46be70e..a5a6da0988 100644 --- a/packages/dashboard-frontend/src/components/AiSelector/Gallery/Entry/index.tsx +++ b/packages/dashboard-frontend/src/components/AiSelector/Gallery/Entry/index.tsx @@ -11,33 +11,69 @@ */ import { api } from '@eclipse-che/common'; -import { Badge, Card, CardFooter, CardHeader, CardTitle } from '@patternfly/react-core'; -import { CheckCircleIcon } from '@patternfly/react-icons'; +import { + Badge, + Card, + CardFooter, + CardHeader, + CardTitle, + Dropdown, + DropdownItem, + DropdownList, + LabelGroup, + MenuToggle, + MenuToggleElement, +} from '@patternfly/react-core'; +import { CheckIcon, EllipsisVIcon } from '@patternfly/react-icons'; import React from 'react'; import styles from '@/components/AiSelector/Gallery/Entry/index.module.css'; +import { TagLabel } from '@/components/TagLabel'; export type Props = { - provider: api.AiToolDefinition; + toolGroup: [api.AiToolDefinition, ...api.AiToolDefinition[]]; icon?: string; description?: string; tags?: string[]; isSelected: boolean; hasExistingKey: boolean; onToggle: (providerId: string) => void; + onVersionChange?: (providerId: string, tag: string) => void; }; -export class AiProviderEntry extends React.PureComponent { +type State = { + activeTool: api.AiToolDefinition; + isKebabOpen: boolean; +}; + +export class AiProviderEntry extends React.PureComponent { + constructor(props: Props) { + super(props); + this.state = { + activeTool: props.toolGroup[0], + isKebabOpen: false, + }; + } + + public componentDidUpdate(prevProps: Props): void { + const prevTags = prevProps.toolGroup.map(t => t.tag).join(','); + const nextTags = this.props.toolGroup.map(t => t.tag).join(','); + if (prevTags !== nextTags) { + const stillActive = this.props.toolGroup.find(t => t.tag === this.state.activeTool.tag); + this.setState({ activeTool: stillActive ?? this.props.toolGroup[0] }); + } + } + private get cardId(): string { - return `ai-provider-card-${this.props.provider.providerId.replace(/\//g, '-')}`; + return `ai-provider-card-${this.state.activeTool.providerId.replace(/\//g, '-')}`; } private get selectableActionId(): string { - return `ai-provider-input-${this.props.provider.providerId.replace(/\//g, '-')}`; + return `ai-provider-input-${this.state.activeTool.providerId.replace(/\//g, '-')}`; } private handleToggle = (): void => { - this.props.onToggle(this.props.provider.providerId); + this.props.onToggle(this.state.activeTool.providerId); }; private handleKeyDown = (event: React.KeyboardEvent): void => { @@ -72,7 +108,69 @@ export class AiProviderEntry extends React.PureComponent { } }; - private getTags(): React.ReactElement[] { + private handleDropdownToggle = (event: React.MouseEvent): void => { + event.stopPropagation(); + this.setState(prev => ({ isKebabOpen: !prev.isKebabOpen })); + }; + + private handleVersionSelect = ( + event: React.MouseEvent | React.KeyboardEvent, + tool: api.AiToolDefinition, + ): void => { + event.stopPropagation(); + event.preventDefault(); + + const { isSelected, onVersionChange } = this.props; + const { activeTool } = this.state; + + this.setState({ activeTool: tool, isKebabOpen: false }); + + if (isSelected && activeTool.tag !== tool.tag && onVersionChange) { + onVersionChange(tool.providerId, tool.tag); + } + }; + + private buildVersionDropdown(): React.ReactElement | null { + const { toolGroup, onVersionChange } = this.props; + if (!onVersionChange || toolGroup.length <= 1) { + return null; + } + const { activeTool, isKebabOpen } = this.state; + + const items = toolGroup.map(tool => ( + this.handleVersionSelect(event, tool)} + data-testid="ai-provider-version-option" + aria-checked={tool.tag === activeTool.tag} + icon={tool.tag === activeTool.tag ? : undefined} + > + {tool.tag} + + )); + + return ( + ) => ( + } + /> + )} + isOpen={isKebabOpen} + onOpenChange={isOpen => this.setState({ isKebabOpen: isOpen })} + popperProps={{ position: 'right' }} + > + {items} + + ); + } + + private getTechPreviewBadges(): React.ReactElement[] { const { tags } = this.props; if (!tags) { return []; @@ -87,10 +185,12 @@ export class AiProviderEntry extends React.PureComponent { } public render(): React.ReactElement { - const { provider, icon, description, isSelected, hasExistingKey } = this.props; + const { icon, description, isSelected, hasExistingKey } = this.props; + const { activeTool } = this.state; - const titleClassName = isSelected ? styles.activeCard : ''; - const tagBadges = this.getTags(); + const titleClassName = `${styles.cardTitle}${isSelected ? ` ${styles.activeCard}` : ''}`; + const techPreviewBadges = this.getTechPreviewBadges(); + const versionDropdown = this.buildVersionDropdown(); return ( { hasNoOffset: true, isHidden: true, }} - actions={tagBadges.length > 0 ? { actions: <>{tagBadges} } : undefined} + actions={{ actions: versionDropdown }} > + {icon && ( + {`${activeTool.name} { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + )} + + + - {icon && ( - {`${provider.name} { - (e.target as HTMLImageElement).style.display = 'none'; - }} - /> - )} - {provider.name} - {provider.envVarName && hasExistingKey && ( - - Key configured - - )} +
{activeTool.name}
+ + {activeTool.envVarName && hasExistingKey && ( + + Key configured + + )} + {techPreviewBadges} +
{description && ( diff --git a/packages/dashboard-frontend/src/components/AiSelector/Gallery/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/components/AiSelector/Gallery/__tests__/__snapshots__/index.spec.tsx.snap index d64a34a1e8..65d1a77318 100644 --- a/packages/dashboard-frontend/src/components/AiSelector/Gallery/__tests__/__snapshots__/index.spec.tsx.snap +++ b/packages/dashboard-frontend/src/components/AiSelector/Gallery/__tests__/__snapshots__/index.spec.tsx.snap @@ -57,14 +57,50 @@ exports[`AiProviderGallery snapshot 1`] = `
+
+
+
    +
  • + + + + latest + + + +
  • +
+
+
- Claude +
+ Claude +
+
@@ -117,14 +153,50 @@ exports[`AiProviderGallery snapshot 1`] = `
+
+
+
    +
  • + + + + latest + + + +
  • +
+
+
- Gemini +
+ Gemini +
+
diff --git a/packages/dashboard-frontend/src/components/AiSelector/Gallery/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/AiSelector/Gallery/__tests__/index.spec.tsx index 77f1c95070..28a926e9f0 100644 --- a/packages/dashboard-frontend/src/components/AiSelector/Gallery/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/AiSelector/Gallery/__tests__/index.spec.tsx @@ -44,6 +44,12 @@ const mockProviders: api.AiToolDefinition[] = [ }, ]; +const geminiV2: api.AiToolDefinition = { + ...mockProviders[0], + tag: 'v2', + injectorImage: 'quay.io/example/gemini-cli:v2', +}; + describe('AiProviderGallery', () => { afterEach(() => { jest.clearAllMocks(); @@ -76,12 +82,29 @@ describe('AiProviderGallery', () => { expect(screen.getByText(/Key configured/i)).toBeInTheDocument(); }); + + test('groups tools with same providerId into a single card', () => { + const providers = [...mockProviders, geminiV2]; + renderComponent(undefined, {}, providers); + + // Only two cards (gemini group + claude), not three + const geminiCards = screen.getAllByText(/Gemini/i); + expect(geminiCards).toHaveLength(1); + + // Version dropdown is only shown when onVersionChange is provided; + // the gallery test fixture does not pass it, so no dropdown buttons expected. + expect(screen.queryAllByRole('button', { name: /version options/i })).toHaveLength(0); + }); }); -function getComponent(selectedProviderId?: string, providerKeyExists?: Record) { +function getComponent( + selectedProviderId?: string, + providerKeyExists?: Record, + providers?: api.AiToolDefinition[], +) { return ( ; onToggle: (providerId: string) => void; + onVersionChange?: (providerId: string, tag: string) => void; }; export class AiProviderGallery extends React.PureComponent { - private getProvider(tool: api.AiToolDefinition): api.AiProviderDefinition | undefined { - return this.props.aiProviders.find(p => p.id === tool.providerId); + private getProviderDef(providerId: string): api.AiProviderDefinition | undefined { + return this.props.aiProviders.find(p => p.id === providerId); + } + + private groupByProviderId(): ReturnType { + return groupToolsByProvider(this.props.providers); } public render(): React.ReactElement { - const { providers, selectedProviderIds, providerKeyExists, onToggle } = this.props; + const { selectedProviderIds, providerKeyExists, onToggle, onVersionChange } = this.props; - const sorted = [...providers].sort((a, b) => a.name.localeCompare(b.name)); + const groups = this.groupByProviderId(); return ( - {sorted.map(provider => { - const providerDef = this.getProvider(provider); + {groups.map(toolGroup => { + const providerId = toolGroup[0].providerId; + const providerDef = this.getProviderDef(providerId); return ( ); })} diff --git a/packages/dashboard-frontend/src/components/TagLabel/index.module.css b/packages/dashboard-frontend/src/components/TagLabel/index.module.css index f7bf45c87c..00abff5a6d 100644 --- a/packages/dashboard-frontend/src/components/TagLabel/index.module.css +++ b/packages/dashboard-frontend/src/components/TagLabel/index.module.css @@ -11,6 +11,9 @@ */ .label { + position: relative; + top: 0.125rem; + margin-left: 5px; padding: 0 5px; diff --git a/packages/dashboard-frontend/src/components/WorkspaceEvents/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/components/WorkspaceEvents/__tests__/index.spec.tsx index 23c9c45805..cac10e6bea 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceEvents/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceEvents/__tests__/index.spec.tsx @@ -31,6 +31,7 @@ const { createSnapshot, renderComponent } = getComponentRenderer(getComponent); describe('The WorkspaceEvents component', () => { const devworkspaceId = 'devWorkspace'; + // In real DevSpaces, pods are named -- const podName = devworkspaceId + '-pod'; let devWorkspaceBuilder: DevWorkspaceBuilder; diff --git a/packages/dashboard-frontend/src/components/WorkspaceEvents/index.tsx b/packages/dashboard-frontend/src/components/WorkspaceEvents/index.tsx index ec3798e1ad..76b062c9d5 100644 --- a/packages/dashboard-frontend/src/components/WorkspaceEvents/index.tsx +++ b/packages/dashboard-frontend/src/components/WorkspaceEvents/index.tsx @@ -78,7 +78,7 @@ class WorkspaceEvents extends React.PureComponent { return; } const startResourceVersion = startedWorkspaces[workspaceUID!] || '0'; - const events = eventsFromResourceVersionFn(startResourceVersion); + const events = eventsFromResourceVersionFn(startResourceVersion, workspace.id, workspace.name); for (const event of events) { if (!event.message) { continue; @@ -103,7 +103,11 @@ class WorkspaceEvents extends React.PureComponent { } const startResourceVersion = startedWorkspaces[workspaceUID!] || '0'; - const events = eventsFromResourceVersionFn(startResourceVersion); + const events = eventsFromResourceVersionFn( + startResourceVersion, + workspace?.id, + workspace?.name, + ); for (const event of events) { if (!event.message) { @@ -172,7 +176,11 @@ class WorkspaceEvents extends React.PureComponent { } const startResourceVersion = startedWorkspaces[workspaceUID] || '0'; - const liveEvents = this.props.eventsFromResourceVersionFn(startResourceVersion); + const liveEvents = this.props.eventsFromResourceVersionFn( + startResourceVersion, + workspace?.id, + workspace?.name, + ); const visibleEvents = isPaused ? frozenEvents : liveEvents; const pendingCount = isPaused diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/InfoModal.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/InfoModal.tsx index 2f84d389b6..def5aa6e28 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/InfoModal.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/InfoModal.tsx @@ -14,6 +14,8 @@ import { api } from '@eclipse-che/common'; import { Content, Modal, ModalBody, ModalHeader, ModalVariant } from '@patternfly/react-core'; import React from 'react'; +import { groupToolsByProvider } from '@/services/helpers/aiTools'; + type Props = { isOpen: boolean; aiTools: api.AiToolDefinition[]; @@ -49,7 +51,8 @@ export class AiToolInfoModal extends React.PureComponent { containers. The selected tool binary is copied to a shared volume and added to{' '} PATH. - {aiTools.map(def => { + {groupToolsByProvider(aiTools).map(toolGroup => { + const def = toolGroup[0]; const provider = aiProviders.find(p => p.id === def.providerId); return ( diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/SelectorModal.module.css b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/SelectorModal.module.css new file mode 100644 index 0000000000..c32f5e9241 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/SelectorModal.module.css @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +.checkboxLabel { + display: inline-flex; + gap: 4px; + align-items: center; +} + +.versionLabel { + min-width: 2.8125rem; + height: 0.8125rem; + margin-left: 0; +} diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/SelectorModal.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/SelectorModal.tsx index 27260312c7..ffc0c864f7 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/SelectorModal.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/SelectorModal.tsx @@ -16,41 +16,157 @@ import { Checkbox, Content, ContentVariants, + Dropdown, + DropdownItem, + DropdownList, + Label, + MenuToggle, + MenuToggleElement, Modal, ModalBody, ModalFooter, ModalHeader, ModalVariant, } from '@patternfly/react-core'; +import { CheckIcon, EllipsisVIcon } from '@patternfly/react-icons'; import React from 'react'; +import styles from '@/pages/WorkspaceDetails/OverviewTab/AiTool/SelectorModal.module.css'; +import { groupToolsByProvider } from '@/services/helpers/aiTools'; + +export type SelectedVersions = Record; + type Props = { isOpen: boolean; aiTools: api.AiToolDefinition[]; aiProviders: api.AiProviderDefinition[]; selected: string[]; + selectedVersions: SelectedVersions; originSelection: string[]; + originVersions: SelectedVersions; onToggle: (toolId: string) => void; - onConfirm: () => void; + onConfirm: (selectedVersions: SelectedVersions) => void; onCancel: () => void; }; -export class AiToolSelectorModal extends React.PureComponent { +type State = { + selectedVersions: SelectedVersions; + openDropdownId: string | null; +}; + +export class AiToolSelectorModal extends React.PureComponent { + constructor(props: Props) { + super(props); + this.state = { + selectedVersions: { ...props.selectedVersions }, + openDropdownId: null, + }; + } + + public componentDidUpdate(prevProps: Props): void { + if (prevProps.selectedVersions !== this.props.selectedVersions) { + this.setState({ selectedVersions: { ...this.props.selectedVersions } }); + } + } + + private groupByProviderId(): ReturnType { + return groupToolsByProvider(this.props.aiTools); + } + + private handleVersionSelect( + event: React.MouseEvent | React.KeyboardEvent, + providerId: string, + tag: string, + ): void { + event.stopPropagation(); + event.preventDefault(); + this.setState(prev => ({ + selectedVersions: { ...prev.selectedVersions, [providerId]: tag }, + openDropdownId: null, + })); + // Auto-select the tool when the user picks a version + if (!this.props.selected.includes(providerId)) { + this.props.onToggle(providerId); + } + } + + private buildVersionDropdown(toolGroup: api.AiToolDefinition[]): React.ReactElement | null { + if (toolGroup.length <= 1) { + return null; + } + const { openDropdownId, selectedVersions } = this.state; + const providerId = toolGroup[0].providerId; + const activeTag = selectedVersions[providerId] ?? toolGroup[0].tag; + const isOpen = openDropdownId === providerId; + + const items = toolGroup.map(tool => ( + this.handleVersionSelect(event, providerId, tool.tag)} + aria-checked={tool.tag === activeTag} + icon={tool.tag === activeTag ? : undefined} + > + {tool.tag} + + )); + + return ( + ) => ( + { + e.stopPropagation(); + this.setState({ openDropdownId: isOpen ? null : providerId }); + }} + isExpanded={isOpen} + aria-label={`${toolGroup[0].name} version options`} + icon={} + /> + )} + isOpen={isOpen} + onOpenChange={open => this.setState({ openDropdownId: open ? providerId : null })} + popperProps={{ position: 'right' }} + > + {items} + + ); + } + public render(): React.ReactNode { const { isOpen, - aiTools, aiProviders, selected, originSelection, + originVersions, onToggle, onConfirm, onCancel, } = this.props; + const { selectedVersions } = this.state; + + const groups = this.groupByProviderId(); + + const allTools = groups.flat(); + const getEffectiveVersion = (id: string): string => + selectedVersions[id] ?? allTools.find(t => t.providerId === id)?.tag ?? ''; + + const hasVersionChange = selected.some(id => { + if (!originSelection.includes(id)) { + return false; // newly added — first two conditions handle it + } + const newTag = getEffectiveVersion(id); + const oldTag = originVersions[id] ?? allTools.find(t => t.providerId === id)?.tag ?? ''; + return newTag !== oldTag; + }); const hasChanged = selected.length !== originSelection.length || - selected.some(id => !originSelection.includes(id)); + selected.some(id => !originSelection.includes(id)) || + hasVersionChange; return ( { - {aiTools.length === 0 ? ( + {groups.length === 0 ? ( No AI tools are available. Ask your administrator to configure AI tools in the CheCluster custom resource. @@ -70,16 +186,30 @@ export class AiToolSelectorModal extends React.PureComponent { ) : ( <> Select AI coding tools - {aiTools.map(def => { - const provider = aiProviders.find(p => p.id === def.providerId); + {groups.map(toolGroup => { + const providerId = toolGroup[0].providerId; + const activeTag = selectedVersions[providerId] ?? toolGroup[0].tag; + const provider = aiProviders.find(p => p.id === providerId); + const versionDropdown = this.buildVersionDropdown(toolGroup); + + const checkboxLabel = ( + + {toolGroup[0].name} + + {versionDropdown} + + ); + return ( - + onToggle(def.providerId)} + isChecked={selected.includes(providerId)} + onChange={() => onToggle(providerId)} /> ); @@ -89,7 +219,12 @@ export class AiToolSelectorModal extends React.PureComponent { -