From 633b35fd7577e3e5d941f48f24bbeb6e30143da8 Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Thu, 13 Aug 2026 16:36:17 +0300 Subject: [PATCH 1/8] feat(ai-registry): filter AI tools by cluster architecture The backend reads the node architecture from DevWorkspace operator server info and filters the AI tool registry so only tools whose arch list includes the running architecture are returned. On IBM Z (s390x) and Power (ppc64le) clusters this prevents tools with no compatible injector image from appearing in the dashboard. Hardened the type predicate to require a string providerId so malformed ConfigMap entries are rejected rather than passed through as typed values. Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- packages/common/src/dto/api/index.ts | 4 + .../services/__tests__/aiRegistryApi.spec.ts | 159 +++++++++++++++++- .../services/aiRegistryApi.ts | 16 +- .../src/devworkspaceClient/types/index.ts | 5 +- .../routes/api/__tests__/aiRegistry.spec.ts | 18 ++ .../src/routes/api/aiRegistry.ts | 12 +- .../__mocks__/getDevWorkspaceClient.ts | 2 +- 7 files changed, 207 insertions(+), 9 deletions(-) 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/dashboard-backend/src/devworkspaceClient/services/__tests__/aiRegistryApi.spec.ts b/packages/dashboard-backend/src/devworkspaceClient/services/__tests__/aiRegistryApi.spec.ts index 2679c7f519..6fecb8e332 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,160 @@ 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 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..53260c97b7 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,19 @@ 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', + ); + const filteredTools = currentArch + ? allTools.filter(tool => !Array.isArray(tool.arch) || tool.arch.includes(currentArch)) + : 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), From a5557dd983b3a0bada8dcb8cbca3653825f5bf9b Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Thu, 13 Aug 2026 16:36:27 +0300 Subject: [PATCH 2/8] fix(events): filter workspace events using blocklist approach Switches from allowlist (show only current workspace events) to blocklist (hide events belonging to other workspaces, show everything else). This preserves generic cluster events and avoids false negatives when event object names do not carry a workspace ID prefix. Uses a lightweight selector that extracts only {id, name} pairs from raw DevWorkspace state to avoid recomputing full Workspace adapter objects on every status update. Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- .../WorkspaceEvents/__tests__/index.spec.tsx | 1 + .../src/components/WorkspaceEvents/index.tsx | 14 +- .../store/Events/__tests__/selectors.spec.ts | 164 ++++++++++++++++-- .../src/store/Events/selectors.ts | 72 ++++++-- 4 files changed, 222 insertions(+), 29 deletions(-) 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/store/Events/__tests__/selectors.spec.ts b/packages/dashboard-frontend/src/store/Events/__tests__/selectors.spec.ts index cf22592b69..7058c94f7d 100644 --- a/packages/dashboard-frontend/src/store/Events/__tests__/selectors.spec.ts +++ b/packages/dashboard-frontend/src/store/Events/__tests__/selectors.spec.ts @@ -10,7 +10,10 @@ * Red Hat, Inc. - initial API and implementation */ +import { WorkspaceAdapter } from '@/services/workspace-adapter'; import { RootState } from '@/store'; +import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; import { selectAllEvents, selectEventsError, @@ -30,7 +33,7 @@ describe('Events Selectors', () => { error: 'Something went wrong', resourceVersion: '125', }, - } as RootState; + } as unknown as RootState; it('should select all events', () => { const result = selectAllEvents(mockState); @@ -38,27 +41,166 @@ describe('Events Selectors', () => { }); it('should select events from a specific resource version', () => { - const selectFromResourceVersion = selectEventsFromResourceVersion(mockState); - const result = selectFromResourceVersion('124'); - expect(result).toEqual([ + // No workspaces in store — all events pass the blocklist + const store = new MockStoreBuilder() + .withEvents({ events: mockState.events.events as never[], resourceVersion: '125' }) + .build(); + const fn = selectEventsFromResourceVersion(store.getState() as RootState); + expect(fn('124')).toEqual([ { metadata: { name: 'event2', resourceVersion: '124' } }, { metadata: { name: 'event3', resourceVersion: '125' } }, ]); }); it('should return an empty array if resource version is invalid', () => { - const selectFromResourceVersion = selectEventsFromResourceVersion(mockState); - const result = selectFromResourceVersion('invalid'); - expect(result).toEqual([]); + const store = new MockStoreBuilder() + .withEvents({ events: mockState.events.events as never[], resourceVersion: '125' }) + .build(); + const fn = selectEventsFromResourceVersion(store.getState() as RootState); + expect(fn('invalid')).toEqual([]); }); it('should select events error', () => { - const result = selectEventsError(mockState); - expect(result).toEqual(mockState.events.error); + expect(selectEventsError(mockState)).toEqual(mockState.events.error); }); it('should select events resource version', () => { - const result = selectEventsResourceVersion(mockState); - expect(result).toEqual(mockState.events.resourceVersion); + expect(selectEventsResourceVersion(mockState)).toEqual(mockState.events.resourceVersion); + }); + + describe('workspace filtering — blocklist approach', () => { + // Build two real DevWorkspace objects via DevWorkspaceBuilder + const currentDW = new DevWorkspaceBuilder() + .withId('workspaceabc') + .withName('my-workspace') + .withNamespace('user-che') + .build(); + const otherDW = new DevWorkspaceBuilder() + .withId('workspaceXYZ') + .withName('other-workspace') + .withNamespace('user-che') + .build(); + + const currentWorkspaceId = WorkspaceAdapter.getId(currentDW); + const currentWorkspaceName = 'my-workspace'; + + const events = [ + // current workspace — DevWorkspace CR event + { + metadata: { resourceVersion: '100' }, + involvedObject: { name: 'my-workspace' }, + message: 'devworkspace event', + }, + // current workspace — Deployment event + { + metadata: { resourceVersion: '101' }, + involvedObject: { name: currentWorkspaceId }, + message: 'deployment event', + }, + // current workspace — ReplicaSet event + { + metadata: { resourceVersion: '102' }, + involvedObject: { name: currentWorkspaceId + '-7867c75d84' }, + message: 'replicaset event', + }, + // current workspace — Pod event + { + metadata: { resourceVersion: '103' }, + involvedObject: { name: currentWorkspaceId + '-7867c75d84-kwb97' }, + message: 'pod event', + }, + // generic cluster event (not tied to any workspace) + { + metadata: { resourceVersion: '104' }, + involvedObject: { name: 'some-other-resource' }, + message: 'generic event', + }, + // other workspace — DevWorkspace CR event + { + metadata: { resourceVersion: '105' }, + involvedObject: { name: 'other-workspace' }, + message: 'other devworkspace event', + }, + // other workspace — pod event + { + metadata: { resourceVersion: '106' }, + involvedObject: { name: WorkspaceAdapter.getId(otherDW) + '-abc123-pod1' }, + message: 'other pod event', + }, + ]; + + let state: RootState; + beforeEach(() => { + state = new MockStoreBuilder() + .withDevWorkspaces({ workspaces: [currentDW, otherDW] }) + .withEvents({ events: events as never[], resourceVersion: '106' }) + .build() + .getState() as RootState; + }); + + it('should show current workspace DevWorkspace events', () => { + const result = selectEventsFromResourceVersion(state)( + '100', + currentWorkspaceId, + currentWorkspaceName, + ); + expect(result.map(e => (e as { message: string }).message)).toContain('devworkspace event'); + }); + + it('should show current workspace Deployment events', () => { + const result = selectEventsFromResourceVersion(state)( + '100', + currentWorkspaceId, + currentWorkspaceName, + ); + expect(result.map(e => (e as { message: string }).message)).toContain('deployment event'); + }); + + it('should show current workspace ReplicaSet events', () => { + const result = selectEventsFromResourceVersion(state)( + '100', + currentWorkspaceId, + currentWorkspaceName, + ); + expect(result.map(e => (e as { message: string }).message)).toContain('replicaset event'); + }); + + it('should show current workspace Pod events', () => { + const result = selectEventsFromResourceVersion(state)( + '100', + currentWorkspaceId, + currentWorkspaceName, + ); + expect(result.map(e => (e as { message: string }).message)).toContain('pod event'); + }); + + it('should show generic cluster events not tied to any workspace', () => { + const result = selectEventsFromResourceVersion(state)( + '100', + currentWorkspaceId, + currentWorkspaceName, + ); + expect(result.map(e => (e as { message: string }).message)).toContain('generic event'); + }); + + it('should hide other workspace DevWorkspace events', () => { + const result = selectEventsFromResourceVersion(state)( + '100', + currentWorkspaceId, + currentWorkspaceName, + ); + expect(result.map(e => (e as { message: string }).message)).not.toContain( + 'other devworkspace event', + ); + }); + + it('should hide other workspace Pod events', () => { + const result = selectEventsFromResourceVersion(state)( + '100', + currentWorkspaceId, + currentWorkspaceName, + ); + expect(result.map(e => (e as { message: string }).message)).not.toContain('other pod event'); + }); }); }); diff --git a/packages/dashboard-frontend/src/store/Events/selectors.ts b/packages/dashboard-frontend/src/store/Events/selectors.ts index d6f6c54faf..ca52a759f6 100644 --- a/packages/dashboard-frontend/src/store/Events/selectors.ts +++ b/packages/dashboard-frontend/src/store/Events/selectors.ts @@ -13,26 +13,68 @@ import { createSelector } from '@reduxjs/toolkit'; import { RootState } from '@/store'; +import { selectAllDevWorkspaces } from '@/store/Workspaces/devWorkspaces/selectors'; const selectState = (state: RootState) => state.events; export const selectAllEvents = createSelector(selectState, state => state.events); -export const selectEventsFromResourceVersion = createSelector(selectAllEvents, allEvents => { - return (fromResourceVersionStr: string) => { - return allEvents.filter(event => { - if (event.metadata.resourceVersion === undefined) { - return false; - } - const resourceVersion = parseInt(event.metadata.resourceVersion, 10); - const fromResourceVersion = parseInt(fromResourceVersionStr, 10); - if (isNaN(resourceVersion) || isNaN(fromResourceVersion)) { - return false; - } - return fromResourceVersion <= resourceVersion; - }); - }; -}); +// Lightweight selector that extracts only {id, name} from raw DevWorkspace state. +// This avoids constructing full Workspace adapter objects on every workspace status +// update, so selectEventsFromResourceVersion only recomputes when workspace +// identifiers actually change (not on every status patch). +const selectWorkspaceIdNamePairs = createSelector(selectAllDevWorkspaces, devWorkspaces => + devWorkspaces.map(dw => ({ + id: dw.status?.devworkspaceId ?? 'workspace' + dw.metadata.uid.split('-').slice(0, 3).join(''), + name: dw.metadata.name, + })), +); + +export const selectEventsFromResourceVersion = createSelector( + selectAllEvents, + selectWorkspaceIdNamePairs, + (allEvents, allWorkspaces) => { + // Pre-build the sets of all workspace IDs and names once per selector invocation. + const allWorkspaceIds = allWorkspaces.map(w => w.id); + const allWorkspaceNames = allWorkspaces.map(w => w.name); + + return ( + fromResourceVersionStr: string, + currentWorkspaceId?: string, + currentWorkspaceName?: string, + ) => { + return allEvents.filter(event => { + if (event.metadata.resourceVersion === undefined) { + return false; + } + const resourceVersion = parseInt(event.metadata.resourceVersion, 10); + const fromResourceVersion = parseInt(fromResourceVersionStr, 10); + if (isNaN(resourceVersion) || isNaN(fromResourceVersion)) { + return false; + } + if (fromResourceVersion > resourceVersion) { + return false; + } + + if (currentWorkspaceId === undefined) { + return true; + } + + // Blocklist approach: hide events that belong to a DIFFERENT workspace. + // Events for the current workspace (id-prefix match or exact name match) and + // events not associated with any devworkspace are all kept. + const objName = event.involvedObject?.name ?? ''; + + const belongsToOtherWorkspace = + allWorkspaceIds.some( + id => id !== currentWorkspaceId && (objName === id || objName.startsWith(id + '-')), + ) || allWorkspaceNames.some(name => name !== currentWorkspaceName && objName === name); + + return !belongsToOtherWorkspace; + }); + }; + }, +); export const selectEventsError = createSelector(selectState, state => state.error); From b55bb8b98532eb5a0e85ed50e8f0fa1ed03a43ca Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Thu, 13 Aug 2026 16:36:40 +0300 Subject: [PATCH 3/8] refactor(ai-tool): extract shared aiTools helpers for grouping and version tracking Adds groupToolsByProvider to group tool definitions by providerId into non-empty sorted arrays, consolidates getInjectedAiToolIds and getInjectedAiToolVersions into a single getInjectedAiToolInfo call, and extends addAiToolToWorkspace with an optional tag parameter so callers can inject a specific image version rather than always picking the first match. Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- .../helpers/__tests__/aiTools.spec.ts | 238 +++++++++++++++++- .../src/services/helpers/aiTools.ts | 70 +++++- 2 files changed, 301 insertions(+), 7 deletions(-) diff --git a/packages/dashboard-frontend/src/services/helpers/__tests__/aiTools.spec.ts b/packages/dashboard-frontend/src/services/helpers/__tests__/aiTools.spec.ts index 2ed8457239..c0425a7956 100644 --- a/packages/dashboard-frontend/src/services/helpers/__tests__/aiTools.spec.ts +++ b/packages/dashboard-frontend/src/services/helpers/__tests__/aiTools.spec.ts @@ -18,7 +18,9 @@ import { addAiToolToWorkspace, ADMIN_MANAGEABLE_ATTRIBUTE, getInjectedAiToolIds, + getInjectedAiToolInfo, getInjectedAiToolNames, + getInjectedAiToolVersions, PENDING_CLEANUP_ANNOTATION, removeAiToolFromWorkspace, sanitizeStaleAiTools, @@ -51,7 +53,19 @@ const OPENCODE_TOOL: api.AiToolDefinition = { envVarName: 'GOOGLE_GENERATIVE_AI_API_KEY', }; -const ALL_TOOLS = [CLAUDE_TOOL, OPENCODE_TOOL]; +// Bundle-pattern tool: binary is in /opt//bin/, not copied directly +const GEMINI_TOOL: api.AiToolDefinition = { + providerId: 'google/gemini', + tag: 'latest', + name: 'Gemini CLI', + url: 'https://github.com/google-gemini/gemini-cli', + binary: 'gemini', + pattern: 'bundle', + injectorImage: 'quay.io/example/gemini-cli:latest', + envVarName: 'GEMINI_API_KEY', +}; + +const ALL_TOOLS = [CLAUDE_TOOL, OPENCODE_TOOL, GEMINI_TOOL]; function buildWorkspaceWithComponents( components: Array>, @@ -204,6 +218,104 @@ describe('aiTools', () => { }); }); + describe('getInjectedAiToolVersions', () => { + it('should return empty object when no AI tool is injected', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + ]); + expect(getInjectedAiToolVersions(workspace, ALL_TOOLS)).toEqual({}); + }); + + it('should return the tag from the injected image', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + { name: 'claude-code-injector', container: { image: 'quay.io/example/claude-code:next' } }, + ]); + expect(getInjectedAiToolVersions(workspace, ALL_TOOLS)).toEqual({ + 'anthropic/claude': 'next', + }); + }); + + it('should extract the hash portion from a digest image reference', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + { + name: 'claude-code-injector', + container: { image: 'quay.io/example/claude-code@sha256:abc123' }, + }, + ]); + // Regex extracts the part after the last ':' — for sha256 digest that is the hash + const versions = getInjectedAiToolVersions(workspace, ALL_TOOLS); + expect(versions['anthropic/claude']).toBe('abc123'); + }); + + it('should fall back to tool tag when image has no colon at all', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + { + name: 'claude-code-injector', + // stripImageTag strips tags, so a bare image name with no ':' matches the base + container: { image: 'quay.io/example/claude-code' }, + }, + ]); + const versions = getInjectedAiToolVersions(workspace, ALL_TOOLS); + expect(versions['anthropic/claude']).toBe(CLAUDE_TOOL.tag); + }); + + it('should return versions for multiple injected tools', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + { name: 'claude-code-injector', container: { image: 'quay.io/example/claude-code:v2' } }, + { name: 'gemini-cli-injector', container: { image: 'quay.io/example/gemini-cli:v1' } }, + ]); + expect(getInjectedAiToolVersions(workspace, ALL_TOOLS)).toEqual({ + 'anthropic/claude': 'v2', + 'google/gemini': 'v1', + }); + }); + + it('should not record the same provider twice', () => { + // Two components with the same base image (shouldn't happen in practice, but guard) + const workspace = buildWorkspaceWithComponents([ + { name: 'claude-code-injector', container: { image: 'quay.io/example/claude-code:v1' } }, + { name: 'claude-code-injector2', container: { image: 'quay.io/example/claude-code:v2' } }, + ]); + const versions = getInjectedAiToolVersions(workspace, ALL_TOOLS); + // First match wins + expect(versions['anthropic/claude']).toBe('v1'); + expect(Object.keys(versions)).toHaveLength(1); + }); + }); + + describe('getInjectedAiToolInfo', () => { + it('should return both ids and versions in a single call', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + { + name: 'claude-code-injector', + container: { image: 'quay.io/example/claude-code:v2' }, + }, + { + name: 'opencode-injector', + container: { image: 'quay.io/example/opencode:next' }, + }, + ]); + const { ids, versions } = getInjectedAiToolInfo(workspace, ALL_TOOLS); + expect(ids).toEqual(['anthropic/claude', 'opencodeai/opencode']); + expect(versions['anthropic/claude']).toBe('v2'); + expect(versions['opencodeai/opencode']).toBe('next'); + }); + + it('should return empty ids and versions when no tools are injected', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + ]); + const { ids, versions } = getInjectedAiToolInfo(workspace, ALL_TOOLS); + expect(ids).toEqual([]); + expect(versions).toEqual({}); + }); + }); + describe('addAiToolToWorkspace', () => { it('should add init-pattern tool components, commands, and events', () => { const workspace = buildWorkspaceWithComponents([ @@ -416,6 +528,56 @@ describe('aiTools', () => { expect(symlinkCmd?.exec?.commandLine).toContain('PATH'); }); + it('should add bundle-pattern tool with cp-a copy command', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + ]); + + const patched = addAiToolToWorkspace(workspace, 'google/gemini', ALL_TOOLS); + const injector = patched.spec.template.components?.find( + c => c.name === 'gemini-cli-injector', + ); + expect(injector).toBeDefined(); + expect(injector?.container?.image).toBe(GEMINI_TOOL.injectorImage); + expect(injector?.container?.args?.[1]).toContain( + 'cp -a /opt/gemini-cli/. /injected-tools/gemini-cli/', + ); + }); + + it('should create symlink postStart command for bundle-pattern tool', () => { + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + ]); + + const patched = addAiToolToWorkspace(workspace, 'google/gemini', ALL_TOOLS); + const symlinkCmd = patched.spec.template.commands?.find( + (c: { id?: string }) => c.id === 'symlink-gemini-cli', + ) as { exec?: { commandLine?: string } } | undefined; + expect(symlinkCmd).toBeDefined(); + expect(symlinkCmd?.exec?.commandLine).toContain( + 'ln -sf /injected-tools/gemini-cli/bin/gemini /injected-tools/bin/gemini', + ); + }); + + it('should prepend setupCommand when tool has setupCommand defined', () => { + const toolWithSetup: api.AiToolDefinition = { + ...CLAUDE_TOOL, + setupCommand: 'mkdir -p ~/.config/claude', + }; + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + ]); + + const patched = addAiToolToWorkspace(workspace, 'anthropic/claude', [toolWithSetup]); + const symlinkCmd = patched.spec.template.commands?.find( + (c: { id?: string }) => c.id === 'symlink-claude-code', + ) as { exec?: { commandLine?: string } } | undefined; + expect(symlinkCmd?.exec?.commandLine).toContain('mkdir -p ~/.config/claude'); + expect(symlinkCmd?.exec?.commandLine).toMatch( + /^\{ mkdir -p ~\/.config\/claude; \} 2>\/dev\/null;/, + ); + }); + it('should throw for unknown tool ID', () => { const workspace = buildWorkspaceWithComponents([]); expect(() => addAiToolToWorkspace(workspace, 'unknown/tool', ALL_TOOLS)).toThrow( @@ -423,6 +585,62 @@ describe('aiTools', () => { ); }); + describe('tag parameter', () => { + const CLAUDE_NEXT: api.AiToolDefinition = { + ...CLAUDE_TOOL, + tag: 'next', + injectorImage: 'quay.io/example/claude-code:next', + }; + const CLAUDE_INSIDERS: api.AiToolDefinition = { + ...CLAUDE_TOOL, + tag: 'insiders', + injectorImage: 'quay.io/example/claude-code:insiders', + }; + const MULTI_VERSION_TOOLS = [...ALL_TOOLS, CLAUDE_INSIDERS]; + + it('should use the specified tag when a matching version exists', () => { + const workspace = buildWorkspaceWithComponents([]); + const patched = addAiToolToWorkspace( + workspace, + 'anthropic/claude', + MULTI_VERSION_TOOLS, + 'insiders', + ); + const injector = patched.spec.template.components?.find( + c => c.name === 'claude-code-injector', + ) as { container?: { image?: string } } | undefined; + expect(injector?.container?.image).toBe('quay.io/example/claude-code:insiders'); + }); + + it('should fall back to first match by providerId when tag is unknown', () => { + const workspace = buildWorkspaceWithComponents([]); + const patched = addAiToolToWorkspace( + workspace, + 'anthropic/claude', + MULTI_VERSION_TOOLS, + 'nonexistent', + ); + const injector = patched.spec.template.components?.find( + c => c.name === 'claude-code-injector', + ) as { container?: { image?: string } } | undefined; + // Falls back to CLAUDE_TOOL (first match by providerId) + expect(injector?.container?.image).toBe(CLAUDE_TOOL.injectorImage); + }); + + it('should use first match by providerId when tag is undefined', () => { + const workspace = buildWorkspaceWithComponents([]); + const withTag = addAiToolToWorkspace(workspace, 'anthropic/claude', [CLAUDE_NEXT], 'next'); + const withoutTag = addAiToolToWorkspace(workspace, 'anthropic/claude', [CLAUDE_NEXT]); + const injectorWithTag = withTag.spec.template.components?.find( + c => c.name === 'claude-code-injector', + ) as { container?: { image?: string } } | undefined; + const injectorWithoutTag = withoutTag.spec.template.components?.find( + c => c.name === 'claude-code-injector', + ) as { container?: { image?: string } } | undefined; + expect(injectorWithTag?.container?.image).toBe(injectorWithoutTag?.container?.image); + }); + }); + it('should not mutate the original workspace', () => { const workspace = buildWorkspaceWithComponents([ { name: 'editor', container: { image: 'che-code:latest' } }, @@ -658,6 +876,24 @@ describe('aiTools', () => { expect(sanitizeStaleAiTools(workspace.ref, ALL_TOOLS)).toBeNull(); }); + it('should treat admin-manageable component with no image as stale', () => { + // A component with no container.image should not be considered recognized + const workspace = buildWorkspaceWithComponents([ + { name: 'editor', container: { image: 'che-code:latest' } }, + { + name: 'no-image-injector', + attributes: { [ADMIN_MANAGEABLE_ATTRIBUTE]: true }, + container: {}, + }, + ]); + + const result = sanitizeStaleAiTools(workspace.ref, ALL_TOOLS); + expect(result).not.toBeNull(); + expect( + result!.spec.template.components?.find(c => c.name === 'no-image-injector'), + ).toBeUndefined(); + }); + it('should remove admin-manageable components with unrecognized images', () => { // Simulate a workspace with a stale tool: admin-manageable but image not in allTools const workspace = buildWorkspaceWithComponents([ diff --git a/packages/dashboard-frontend/src/services/helpers/aiTools.ts b/packages/dashboard-frontend/src/services/helpers/aiTools.ts index 0509698cf8..08716abf2f 100644 --- a/packages/dashboard-frontend/src/services/helpers/aiTools.ts +++ b/packages/dashboard-frontend/src/services/helpers/aiTools.ts @@ -79,27 +79,59 @@ export function toolCommandIds(slug: string): { } /** - * Returns all toolIds of AI tools injected into this workspace. - * Detects by matching the injectorImage from any known tool in `allTools`. + * Returns the injected AI tool IDs and their active versions in one pass over + * the workspace components. Use this when both values are needed together to + * avoid iterating the component list twice. */ -export function getInjectedAiToolIds( +export function getInjectedAiToolInfo( workspace: Workspace, allTools: api.AiToolDefinition[], -): string[] { +): { ids: string[]; versions: Record } { const components: Array<{ name?: string; container?: { image?: string } }> = (workspace.ref.spec?.template?.components as Array<{ name?: string; container?: { image?: string }; }>) ?? []; const ids: string[] = []; + const versions: Record = {}; for (const comp of components) { const image: string = comp.container?.image ?? ''; const tool = allTools.find(t => stripImageTag(t.injectorImage) === stripImageTag(image)); if (tool && !ids.includes(tool.providerId)) { ids.push(tool.providerId); + // Extract the tag from the actual injected image. + // For digest-pinned images (e.g. quay.io/example/claude-code@sha256:abc123) the + // regex extracts the hash after "sha256:" as the "version". This means a digest→tag + // (or tag→digest) upgrade will always look like a version change and re-enable Save. + // That is intentional: the operation is idempotent, and digest versions are treated + // opaquely — no attempt is made to normalise them to a canonical form. + const tagMatch = image.match(/:([^:@]+)$/); + versions[tool.providerId] = tagMatch ? tagMatch[1] : tool.tag; } } - return ids; + return { ids, versions }; +} + +/** + * Returns all toolIds of AI tools injected into this workspace. + * Detects by matching the injectorImage from any known tool in `allTools`. + */ +export function getInjectedAiToolIds( + workspace: Workspace, + allTools: api.AiToolDefinition[], +): string[] { + return getInjectedAiToolInfo(workspace, allTools).ids; +} + +/** + * Returns a map of providerId → injected tag for all injected AI tools. + * Used to detect which version of a tool is currently in the workspace. + */ +export function getInjectedAiToolVersions( + workspace: Workspace, + allTools: api.AiToolDefinition[], +): Record { + return getInjectedAiToolInfo(workspace, allTools).versions; } /** @@ -196,8 +228,11 @@ export function addAiToolToWorkspace( workspaceOrDevWorkspace: Workspace | devfileApi.DevWorkspace, toolId: string, allTools: api.AiToolDefinition[], + tag?: string, ): devfileApi.DevWorkspace { - const tool = allTools.find(t => t.providerId === toolId); + const tool = + (tag ? allTools.find(t => t.providerId === toolId && t.tag === tag) : undefined) ?? + allTools.find(t => t.providerId === toolId); if (!tool) { throw new Error(`Unknown AI tool: ${toolId}`); } @@ -756,6 +791,29 @@ export function updateOutdatedAiTools( return result; } +/** + * Groups tools by providerId with deterministic alphabetical ordering. + * + * Tools are sorted by name before grouping so the gallery and the selector + * modal always show providers in the same order. Each group is guaranteed + * non-empty by the grouping logic. + */ +export function groupToolsByProvider( + tools: api.AiToolDefinition[], +): [api.AiToolDefinition, ...api.AiToolDefinition[]][] { + const sorted = [...tools].sort((a, b) => a.name.localeCompare(b.name)); + const map = new Map(); + for (const tool of sorted) { + const existing = map.get(tool.providerId); + if (existing) { + existing.push(tool); + } else { + map.set(tool.providerId, [tool]); + } + } + return Array.from(map.values()); +} + /** * Selects the best tool from a list of candidates sharing the same providerId. * Priority: "next" > "latest" > highest semver > first in list. From e45fb2f5b7d561d554ebd615ead53983808d83a1 Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Thu, 13 Aug 2026 16:36:51 +0300 Subject: [PATCH 4/8] feat(ai-selector): add version dropdown to gallery card for multi-version providers When a provider has more than one tool entry (e.g. next vs insiders), the gallery card shows a kebab version dropdown. Selecting a version updates the card's active tool display and calls onVersionChange so the parent can persist the choice. Single-version providers and gallery contexts that do not supply onVersionChange show no dropdown. Also adds the version TagLabel to the card header and moves Tech-Preview badges into the card title so the layout is consistent across providers. Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- .../Gallery/Entry/__tests__/index.spec.tsx | 206 +++++++++++++++++- .../AiSelector/Gallery/Entry/index.module.css | 12 +- .../AiSelector/Gallery/Entry/index.tsx | 162 +++++++++++--- .../__snapshots__/index.spec.tsx.snap | 80 ++++++- .../Gallery/__tests__/index.spec.tsx | 27 ++- .../components/AiSelector/Gallery/index.tsx | 28 ++- .../src/components/TagLabel/index.module.css | 3 + 7 files changed, 463 insertions(+), 55 deletions(-) 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; From eb1b5b0a8b93250e1dfe254efe82a2a93e35dd64 Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Thu, 13 Aug 2026 16:37:05 +0300 Subject: [PATCH 5/8] feat(ai-tool): add version selection for existing workspace AI tools The Change AI Tools modal now shows a version dropdown for multi-version providers and tracks the selected version independently of the checkbox state. Auto-selects the provider when the user picks a version. Saves button becomes active when only the version changes with no selection change. AiToolFormGroup uses getInjectedAiToolInfo to initialise and sync both the selected IDs and the selected versions from the workspace, and passes originVersions to the modal so it can detect version-only changes. When confirmed, tools whose version changed are removed and re-added with the new tag using addAiToolToWorkspace. InfoModal now uses groupToolsByProvider for deduplication to stay in sync with SelectorModal and Gallery. Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- .../OverviewTab/AiTool/InfoModal.tsx | 5 +- .../AiTool/SelectorModal.module.css | 23 +++ .../OverviewTab/AiTool/SelectorModal.tsx | 161 ++++++++++++++++-- .../AiTool/__tests__/SelectorModal.spec.tsx | 88 ++++++++++ .../AiTool/__tests__/index.spec.tsx | 18 +- .../OverviewTab/AiTool/index.tsx | 72 +++++--- 6 files changed, 323 insertions(+), 44 deletions(-) create mode 100644 packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/AiTool/SelectorModal.module.css 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 { -