diff --git a/src/vs/workbench/contrib/positronAssistant/browser/components/languageModelButton.tsx b/src/vs/workbench/contrib/positronAssistant/browser/components/languageModelButton.tsx index d712442803b3..703e660aca85 100644 --- a/src/vs/workbench/contrib/positronAssistant/browser/components/languageModelButton.tsx +++ b/src/vs/workbench/contrib/positronAssistant/browser/components/languageModelButton.tsx @@ -31,7 +31,7 @@ interface LanguageModelButtonProps { } /** Human-readable label for a provider's maturity status, or undefined for stable providers. */ -function getStatusLabel(status: LanguageModelButtonProps['status']): string | undefined { +export function getStatusLabel(status: LanguageModelButtonProps['status']): string | undefined { switch (status) { case 'preview': return localize('positron.languageModelButton.status.preview', "Preview"); diff --git a/src/vs/workbench/contrib/positronAssistant/browser/components/providerList.tsx b/src/vs/workbench/contrib/positronAssistant/browser/components/providerList.tsx new file mode 100644 index 000000000000..536057ca3b81 --- /dev/null +++ b/src/vs/workbench/contrib/positronAssistant/browser/components/providerList.tsx @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { useEffect, useState } from 'react'; + +import { localize } from '../../../../../nls.js'; +import { IDisposable } from '../../../../../base/common/lifecycle.js'; +import { usePositronReactServicesContext } from '../../../../../base/browser/positronReactRendererContext.js'; +import { IPositronAssistantConfigurationService, IPositronLanguageModelSource } from '../../common/interfaces/positronAssistantService.js'; +import { IAuthenticationService } from '../../../../services/authentication/common/authentication.js'; +import { groupProviders, ProviderSectionId } from '../../common/providerGrouping.js'; +import { syncAuthSessions } from '../languageModelSessionSync.js'; +import { ProviderListItem } from './providerListItem.js'; + +interface ProviderListProps { + sources: IPositronLanguageModelSource[]; +} + +/** + * One-line provider descriptions shown for not-yet-connected providers, keyed by + * provider id. Positron provider metadata does not carry a description yet, so + * this static map mirrors the copy from the provider-configuration design + * prototype. Missing ids simply render no description. + */ +const PROVIDER_DESCRIPTIONS: Record = { + 'amazon-bedrock': localize('positron.configureLLMProvidersModal.desc.bedrock', "Access Claude and other models via AWS"), + 'anthropic-api': localize('positron.configureLLMProvidersModal.desc.anthropic', "Access Claude models directly via Anthropic API"), + 'copilot-auth': localize('positron.configureLLMProvidersModal.desc.copilot', "AI models via GitHub Copilot subscription"), + 'deepseek-api': localize('positron.configureLLMProvidersModal.desc.deepseek', "Access DeepSeek reasoning models"), + 'google': localize('positron.configureLLMProvidersModal.desc.google', "Access Gemini models via Google AI Studio"), + 'google-cloud': localize('positron.configureLLMProvidersModal.desc.googleCloud', "Gemini via Google Cloud with enterprise features"), + 'ms-foundry': localize('positron.configureLLMProvidersModal.desc.msFoundry', "Access Azure OpenAI and AI Studio models"), + 'openai-api': localize('positron.configureLLMProvidersModal.desc.openai', "GPT-4o, o1, and OpenAI-compatible endpoints"), + 'posit-ai': localize('positron.configureLLMProvidersModal.desc.positAI', "Managed model service for Positron Desktop"), + 'snowflake-cortex': localize('positron.configureLLMProvidersModal.desc.snowflake', "Access LLMs via Snowflake data platform"), +}; + +/** Localized heading per section id. */ +function sectionTitle(id: ProviderSectionId): string { + switch (id) { + case 'connected': + return localize('positron.configureLLMProvidersModal.section.connected', "Connected Providers"); + case 'needs-attention': + return localize('positron.configureLLMProvidersModal.section.needsAttention', "Needs Attention"); + case 'model-providers': + return localize('positron.configureLLMProvidersModal.section.modelProviders', "Model Providers"); + } +} + +/** The grouped, sectioned provider list shown in the Configure LLM Providers modal. */ +export const ProviderList = (props: ProviderListProps) => { + const services = usePositronReactServicesContext(); + + // Local copy of sources so live auth/config changes re-render the list. + const [sources, setSources] = useState(props.sources); + + // Re-sync if the caller hands us a new sources array. + useEffect(() => setSources(props.sources), [props.sources]); + + // Provider config changes (sign in / out) while the modal is open. + useEffect(() => { + const configService = services.get(IPositronAssistantConfigurationService); + const disposables: IDisposable[] = []; + disposables.push(configService.onChangeProviderConfig(newSource => { + setSources(prev => prev.map(s => s.provider.id === newSource.provider.id ? newSource : s)); + })); + return () => disposables.forEach(d => d.dispose()); + }, [services]); + + // Auth session changes for API-key providers. + useEffect(() => { + const authService = services.get(IAuthenticationService); + const disposable = syncAuthSessions( + authService, + props.sources.map(s => s.provider.id), + (providerId, signedIn) => { + setSources(prev => { + const index = prev.findIndex(s => s.provider.id === providerId); + if (index === -1) { + return prev; + } + const next = [...prev]; + next[index] = { ...prev[index], signedIn }; + return next; + }); + } + ); + return () => disposable.dispose(); + }, [services, props.sources]); + + const sections = groupProviders(sources); + + return ( +
+ {sections.map(section => ( +
+ + {section.items.map(item => ( + + ))} +
+ ))} + +
+ +

+ {localize('positron.configureLLMProvidersModal.customDescription', "Works with any OpenAI-compatible API endpoint that uses the /v1/chat/completions endpoint for chat.")} +

+ {/* Placeholder until the custom-provider flow lands (see #14818). */} + +
+
+ ); +}; diff --git a/src/vs/workbench/contrib/positronAssistant/browser/components/providerListItem.tsx b/src/vs/workbench/contrib/positronAssistant/browser/components/providerListItem.tsx new file mode 100644 index 000000000000..0f83b6c982e2 --- /dev/null +++ b/src/vs/workbench/contrib/positronAssistant/browser/components/providerListItem.tsx @@ -0,0 +1,88 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../../nls.js'; +import { positronClassNames } from '../../../../../base/common/positronUtilities.js'; +import { IPositronLanguageModelSource, LanguageModelAutoconfigureType } from '../../common/interfaces/positronAssistantService.js'; +import { ProviderSectionId } from '../../common/providerGrouping.js'; +import { AuthMethod } from '../types.js'; +import { LanguageModelIcon, getStatusLabel } from './languageModelButton.js'; + +interface ProviderListItemProps { + source: IPositronLanguageModelSource; + /** Which section the row is rendered in; drives badges and the action label. */ + section: ProviderSectionId; + /** One-line description shown for not-yet-connected providers. */ + description?: string; + /** Invoked by the row's action button. The connect/manage flows (see #14818/#14819) hang off this. */ + onAction?: () => void; +} + +/** How a connected provider authenticated, shown as a badge. */ +function authBadgeLabel(source: IPositronLanguageModelSource): string | undefined { + const autoconfigure = source.defaults.autoconfigure; + if (autoconfigure?.type === LanguageModelAutoconfigureType.EnvVariable && autoconfigure.signedIn) { + return localize('positron.configureLLMProvidersModal.badge.environment', "Environment"); + } + if (source.supportedOptions.includes(AuthMethod.OAUTH)) { + return localize('positron.configureLLMProvidersModal.badge.oauth', "OAuth"); + } + return undefined; +} + +/** The per-section action button label. */ +function actionLabel(section: ProviderSectionId): string { + switch (section) { + case 'connected': + return localize('positron.configureLLMProvidersModal.action.edit', "Edit"); + case 'needs-attention': + return localize('positron.configureLLMProvidersModal.action.fix', "Fix Connection"); + case 'model-providers': + return localize('positron.configureLLMProvidersModal.action.connect', "Connect"); + } +} + +/** + * A single provider row: a rounded-square provider icon, name, status/maturity + * badges, and a per-section action button (the only interactive element - the + * row itself is not clickable). + */ +export const ProviderListItem = (props: ProviderListItemProps) => { + const { source, section, description, onAction } = props; + const maturityLabel = getStatusLabel(source.provider.status); + const authLabel = section === 'connected' ? authBadgeLabel(source) : undefined; + + return ( +
+
+ +
+
+
+ {source.provider.displayName} + {maturityLabel && {maturityLabel}} + {authLabel && {authLabel}} + {section === 'needs-attention' && + + {localize('positron.configureLLMProvidersModal.badge.error', "Error")} + + } +
+ {section === 'needs-attention' && source.statusMessage && +
{source.statusMessage}
+ } + {section === 'model-providers' && description && +
{description}
+ } +
+
+ +
+
+ ); +}; diff --git a/src/vs/workbench/contrib/positronAssistant/browser/configureLLMProvidersModal.css b/src/vs/workbench/contrib/positronAssistant/browser/configureLLMProvidersModal.css new file mode 100644 index 000000000000..e80064bd8f07 --- /dev/null +++ b/src/vs/workbench/contrib/positronAssistant/browser/configureLLMProvidersModal.css @@ -0,0 +1,138 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +.provider-list { + display: flex; + flex-direction: column; + gap: 20px; + overflow-y: auto; +} + +.provider-list-section { + display: flex; + flex-direction: column; + gap: 4px; +} + +.provider-list-section-heading { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--vscode-descriptionForeground); + margin-bottom: 4px; +} + +/* Provider row */ +.provider-list-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px; + border-radius: 4px; + text-align: left; +} + +/* Rounded-square provider avatar (36x36, 6px radius per the design). */ +.provider-list-item-icon { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 6px; + border: 1px solid var(--vscode-widget-border, rgba(128, 128, 128, 0.2)); + background-color: var(--vscode-editorWidget-background); + flex-shrink: 0; +} + +.provider-list-item-icon .language-model.icon { + width: 22px; + height: 22px; + flex-shrink: 0; +} + +.provider-list-item-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.provider-list-item-name { + display: flex; + align-items: center; + gap: 8px; +} + +.provider-list-item-display-name { + font-weight: 600; +} + +.provider-list-item-desc, +.provider-list-custom-desc { + font-size: 12px; + color: var(--vscode-descriptionForeground); +} + +.provider-list-item-error { + font-size: 12px; + color: var(--vscode-errorForeground); +} + +/* Badges */ +.provider-list-item-badge { + font-size: 11px; + line-height: 1.4; + padding: 0 6px; + border-radius: 8px; + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); + white-space: nowrap; +} + +.provider-list-item-badge.error { + background-color: transparent; + color: var(--vscode-errorForeground); + border: 1px solid var(--vscode-errorForeground); +} + +/* Action buttons */ +.provider-list-item-actions { + flex-shrink: 0; +} + +.provider-list-item-action, +.provider-list-add-custom { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 10px; + border-radius: 4px; + border: 1px solid var(--vscode-button-border, var(--vscode-contrastBorder, transparent)); + background-color: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + cursor: pointer; + font-size: 12px; +} + +.provider-list-item-action .codicon, +.provider-list-add-custom .codicon { + font-size: 14px; +} + +.provider-list-item-action:hover, +.provider-list-add-custom:hover { + background-color: var(--vscode-button-secondaryHoverBackground); +} + +.provider-list-add-custom { + align-self: flex-start; + margin-top: 4px; +} diff --git a/src/vs/workbench/contrib/positronAssistant/browser/configureLLMProvidersModal.tsx b/src/vs/workbench/contrib/positronAssistant/browser/configureLLMProvidersModal.tsx index cd96a088121d..655db91259b5 100644 --- a/src/vs/workbench/contrib/positronAssistant/browser/configureLLMProvidersModal.tsx +++ b/src/vs/workbench/contrib/positronAssistant/browser/configureLLMProvidersModal.tsx @@ -3,12 +3,16 @@ * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ +// CSS. +import './configureLLMProvidersModal.css'; + // Other dependencies. import { localize } from '../../../../nls.js'; import { IPositronLanguageModelConfig, IPositronLanguageModelSource, IShowLanguageModelConfigOptions } from '../common/interfaces/positronAssistantService.js'; import { OKModalDialog } from '../../../browser/positronComponents/positronModalDialog/positronOKModalDialog.js'; import { PositronModalReactRenderer } from '../../../../base/browser/positronModalReactRenderer.js'; import { VerticalStack } from '../../../browser/positronComponents/positronModalDialog/components/verticalStack.js'; +import { ProviderList } from './components/providerList.js'; /** * Hidden feature switch that selects the new "Configure LLM Providers" modal @@ -32,7 +36,7 @@ export const NEW_PROVIDER_MODAL_KEY = 'assistant.newProviderModal'; * are interchangeable at the single call site that reads the feature switch. */ export const showConfigureLLMProvidersModal = ( - _sources: IPositronLanguageModelSource[], + sources: IPositronLanguageModelSource[], _onAction: (source: IPositronLanguageModelSource, config: IPositronLanguageModelConfig, action: string) => Promise, onClose: () => void, _options?: IShowLanguageModelConfigOptions, @@ -41,13 +45,14 @@ export const showConfigureLLMProvidersModal = ( renderer.render(
- +
); }; interface ConfigureLLMProvidersProps { renderer: PositronModalReactRenderer; + sources: IPositronLanguageModelSource[]; onClose: () => void; } @@ -69,9 +74,7 @@ const ConfigureLLMProviders = (props: ConfigureLLMProvidersProps) => { onCancel={onClose} > -

- {localize('positron.configureLLMProvidersModal.placeholder', "This is the new provider configuration experience. It's still being built.")} -

+
; }; diff --git a/src/vs/workbench/contrib/positronAssistant/common/providerGrouping.ts b/src/vs/workbench/contrib/positronAssistant/common/providerGrouping.ts new file mode 100644 index 000000000000..144cd52549ee --- /dev/null +++ b/src/vs/workbench/contrib/positronAssistant/common/providerGrouping.ts @@ -0,0 +1,75 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IPositronLanguageModelSource } from './interfaces/positronAssistantService.js'; + +/** Section identifiers for the built-in provider groups, in fixed display order. */ +export type ProviderSectionId = 'connected' | 'needs-attention' | 'model-providers'; + +/** A non-empty group of providers to render under one heading. */ +export interface ProviderSection { + id: ProviderSectionId; + items: IPositronLanguageModelSource[]; +} + +const SECTION_ORDER: ProviderSectionId[] = ['connected', 'needs-attention', 'model-providers']; + +/** + * The OpenAI-compatible "Custom Provider" template. It has its own dedicated + * section in the modal (with an "Add custom provider" affordance), so it is + * excluded from the built-in provider groups. + */ +export const CUSTOM_PROVIDER_ID = 'openai-compatible'; + +/** Only chat providers (and the copilot-auth completion provider) are shown, mirroring the legacy modal. */ +function isDisplayable(source: IPositronLanguageModelSource): boolean { + if (source.provider.id === CUSTOM_PROVIDER_ID) { + return false; + } + return source.type === 'chat' || (source.type === 'completion' && source.provider.id === 'copilot-auth'); +} + +/** Which section a source belongs to, based on sign-in and connection status. */ +function sectionFor(source: IPositronLanguageModelSource): ProviderSectionId { + if (source.signedIn && source.status === 'error') { + return 'needs-attention'; + } + if (source.signedIn) { + return 'connected'; + } + return 'model-providers'; +} + +function compareSources(a: IPositronLanguageModelSource, b: IPositronLanguageModelSource): number { + return a.provider.displayName.localeCompare(b.provider.displayName); +} + +/** + * Groups language model sources into ordered, non-empty sections for the + * Configure LLM Providers modal: Connected, then Needs Attention, then Model + * Providers. Within a section, providers are sorted alphabetically by display + * name. The custom-provider template is handled by a separate section. + */ +export function groupProviders(sources: IPositronLanguageModelSource[]): ProviderSection[] { + const buckets = new Map(); + for (const source of sources) { + if (!isDisplayable(source)) { + continue; + } + const id = sectionFor(source); + const items = buckets.get(id) ?? []; + items.push(source); + buckets.set(id, items); + } + + const sections: ProviderSection[] = []; + for (const id of SECTION_ORDER) { + const items = buckets.get(id); + if (items && items.length > 0) { + sections.push({ id, items: items.sort(compareSources) }); + } + } + return sections; +} diff --git a/src/vs/workbench/contrib/positronAssistant/test/browser/providerList.vitest.tsx b/src/vs/workbench/contrib/positronAssistant/test/browser/providerList.vitest.tsx new file mode 100644 index 000000000000..855911bb8c95 --- /dev/null +++ b/src/vs/workbench/contrib/positronAssistant/test/browser/providerList.vitest.tsx @@ -0,0 +1,60 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +/// + +import { screen } from '@testing-library/react'; +import { Event } from '../../../../../base/common/event.js'; +import { setupRTLRenderer } from '../../../../../test/vitest/reactTestingLibrary.js'; +import { createTestContainer } from '../../../../../test/vitest/positronTestContainer.js'; +import { ProviderList } from '../../browser/components/providerList.js'; +import { IPositronAssistantConfigurationService, IPositronLanguageModelSource, PositronLanguageModelType } from '../../common/interfaces/positronAssistantService.js'; +import { IAuthenticationService } from '../../../../services/authentication/common/authentication.js'; + +function source(overrides: Partial & { id: string; displayName?: string }): IPositronLanguageModelSource { + const { id, displayName, ...rest } = overrides; + return { + type: PositronLanguageModelType.Chat, + provider: { id, displayName: displayName ?? id, settingName: id }, + supportedOptions: [], + defaults: {}, + ...rest, + } as IPositronLanguageModelSource; +} + +describe('ProviderList', () => { + const ctx = createTestContainer() + .withReactServices() + .stub(IPositronAssistantConfigurationService, { onChangeProviderConfig: Event.None }) + .stub(IAuthenticationService, { onDidChangeSessions: Event.None }) + .build(); + const rtl = setupRTLRenderer(() => ctx.reactServices); + + it('renders a heading per non-empty section', () => { + rtl.render(); + expect(screen.getByText('Connected Providers')).toBeInTheDocument(); + expect(screen.getByText('Model Providers')).toBeInTheDocument(); + }); + + it('does not render empty built-in section headings', () => { + rtl.render(); + expect(screen.queryByText('Connected Providers')).not.toBeInTheDocument(); + expect(screen.queryByText('Needs Attention')).not.toBeInTheDocument(); + }); + + it('always renders the Custom Provider section with an add button', () => { + rtl.render(); + expect(screen.getByText('Custom Provider')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Add custom provider/ })).toBeInTheDocument(); + }); + + it('shows the built-in description for a known provider', () => { + rtl.render(); + expect(screen.getByText('Access Claude models directly via Anthropic API')).toBeInTheDocument(); + }); +}); diff --git a/src/vs/workbench/contrib/positronAssistant/test/browser/providerListItem.vitest.tsx b/src/vs/workbench/contrib/positronAssistant/test/browser/providerListItem.vitest.tsx new file mode 100644 index 000000000000..a02cbbf30095 --- /dev/null +++ b/src/vs/workbench/contrib/positronAssistant/test/browser/providerListItem.vitest.tsx @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +/// + +import { userEvent } from '@testing-library/user-event'; +import { render, screen } from '@testing-library/react'; +import { ProviderListItem } from '../../browser/components/providerListItem.js'; +import { AuthMethod } from '../../browser/types.js'; +import { IPositronLanguageModelSource, LanguageModelAutoconfigureType, PositronLanguageModelType } from '../../common/interfaces/positronAssistantService.js'; + +function source(overrides: Partial & { id: string }): IPositronLanguageModelSource { + const { id, ...rest } = overrides; + return { + type: PositronLanguageModelType.Chat, + provider: { id, displayName: id, settingName: id }, + supportedOptions: [], + defaults: {}, + ...rest, + } as IPositronLanguageModelSource; +} + +describe('ProviderListItem', () => { + it('renders the display name and a maturity badge', () => { + render(); + expect(screen.getByText('Anthropic')).toBeInTheDocument(); + expect(screen.getByText('Preview')).toBeInTheDocument(); + }); + + it('shows a description and a Connect action in the model-providers section', () => { + render(); + expect(screen.getByText('Access Claude models')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument(); + }); + + it('shows an Environment badge and Edit action for an env-var connected provider', () => { + render(); + expect(screen.getByText('Environment')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument(); + }); + + it('shows an OAuth badge for an oauth connected provider', () => { + render(); + expect(screen.getByText('OAuth')).toBeInTheDocument(); + }); + + it('shows an Error badge, the error message, and a Fix Connection action in needs-attention', () => { + render(); + expect(screen.getByText('Error')).toBeInTheDocument(); + expect(screen.getByText('Session expired')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Fix Connection' })).toBeInTheDocument(); + }); + + it('calls onAction when the action button is clicked', async () => { + const user = userEvent.setup(); + const onAction = vi.fn(); + render(); + await user.click(screen.getByRole('button', { name: 'Connect' })); + expect(onAction).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/vs/workbench/contrib/positronAssistant/test/common/providerGrouping.vitest.ts b/src/vs/workbench/contrib/positronAssistant/test/common/providerGrouping.vitest.ts new file mode 100644 index 000000000000..834a423ad0e5 --- /dev/null +++ b/src/vs/workbench/contrib/positronAssistant/test/common/providerGrouping.vitest.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ + +/// + +import { groupProviders } from '../../common/providerGrouping.js'; +import { IPositronLanguageModelSource, PositronLanguageModelType } from '../../common/interfaces/positronAssistantService.js'; + +function source(overrides: Partial & { id: string }): IPositronLanguageModelSource { + const { id, ...rest } = overrides; + return { + type: PositronLanguageModelType.Chat, + provider: { id, displayName: id, settingName: id }, + supportedOptions: [], + defaults: {}, + ...rest, + } as IPositronLanguageModelSource; +} + +describe('groupProviders', () => { + it('orders sections connected, needs-attention, model-providers', () => { + const sections = groupProviders([ + source({ id: 'avail', signedIn: false }), + source({ id: 'err', signedIn: true, status: 'error' }), + source({ id: 'conn', signedIn: true, status: 'ok' }), + ]); + expect(sections.map(s => s.id)).toEqual(['connected', 'needs-attention', 'model-providers']); + }); + + it('buckets a signed-in error source into needs-attention', () => { + const sections = groupProviders([source({ id: 'a', signedIn: true, status: 'error' })]); + expect(sections).toHaveLength(1); + expect(sections[0].id).toBe('needs-attention'); + }); + + it('buckets a signed-in non-error source into connected and signed-out into model-providers', () => { + const sections = groupProviders([ + source({ id: 'a', signedIn: true, status: 'ok' }), + source({ id: 'b', signedIn: false }), + ]); + expect(sections.map(s => s.id)).toEqual(['connected', 'model-providers']); + }); + + it('omits empty sections', () => { + const sections = groupProviders([source({ id: 'b', signedIn: false })]); + expect(sections.map(s => s.id)).toEqual(['model-providers']); + }); + + it('filters out non-chat sources except copilot-auth completion', () => { + const sections = groupProviders([ + source({ id: 'comp', type: PositronLanguageModelType.Completion }), + source({ id: 'copilot-auth', type: PositronLanguageModelType.Completion, signedIn: false }), + ]); + expect(sections).toHaveLength(1); + expect(sections[0].items.map(i => i.provider.id)).toEqual(['copilot-auth']); + }); + + it('excludes the custom-provider template (openai-compatible) from the built-in sections', () => { + const sections = groupProviders([ + source({ id: 'openai-compatible', signedIn: false }), + source({ id: 'openai-api', signedIn: false }), + ]); + expect(sections).toHaveLength(1); + expect(sections[0].items.map(i => i.provider.id)).toEqual(['openai-api']); + }); + + it('sorts alphabetically by display name within a section', () => { + const sections = groupProviders([ + source({ id: 'zebra', provider: { id: 'zebra', displayName: 'Zebra', settingName: 'zebra' }, signedIn: false }), + source({ id: 'posit-ai', provider: { id: 'posit-ai', displayName: 'Posit AI', settingName: 'positAI' }, signedIn: false }), + source({ id: 'alpha', provider: { id: 'alpha', displayName: 'Alpha', settingName: 'alpha' }, signedIn: false }), + ]); + expect(sections[0].items.map(i => i.provider.displayName)).toEqual(['Alpha', 'Posit AI', 'Zebra']); + }); +});